From 6c8705e5c7723c63aad26043e9a6e1f015e18d32 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 24 Jul 2026 09:35:38 +0000 Subject: [PATCH 1/5] feat(agent-adapter): discover Claude Code plugins --- .../host/agent-adapter/src/plugins/adapter.ts | 14 + .../agent-adapter/src/plugins/claude-code.ts | 293 ++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 packages/host/agent-adapter/src/plugins/adapter.ts create mode 100644 packages/host/agent-adapter/src/plugins/claude-code.ts diff --git a/packages/host/agent-adapter/src/plugins/adapter.ts b/packages/host/agent-adapter/src/plugins/adapter.ts new file mode 100644 index 00000000..70f8b539 --- /dev/null +++ b/packages/host/agent-adapter/src/plugins/adapter.ts @@ -0,0 +1,14 @@ +import type { Plugin, PluginProvider } from '@linkcode/schema'; + +export interface PluginDiscoveryOptions { + /** Project root used by providers that expose repository-scoped marketplaces. */ + cwd?: string; +} + +/** Read-only provider boundary for discovering native plugin catalogs. */ +export interface PluginProviderAdapter { + readonly provider: PluginProvider; + list(opts?: PluginDiscoveryOptions): Promise; +} + +export type PluginProviderAdapterFactory = (provider: PluginProvider) => PluginProviderAdapter; diff --git a/packages/host/agent-adapter/src/plugins/claude-code.ts b/packages/host/agent-adapter/src/plugins/claude-code.ts new file mode 100644 index 00000000..0dfcf470 --- /dev/null +++ b/packages/host/agent-adapter/src/plugins/claude-code.ts @@ -0,0 +1,293 @@ +import { execFile } from 'node:child_process'; +import { readdir, readFile } from 'node:fs/promises'; +import { isAbsolute, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import type { Plugin, PluginComponent, PluginSource } from '@linkcode/schema'; +import { PluginSchema } from '@linkcode/schema'; +import { z } from 'zod'; +import { agentRuntimeProber, ClaudeCodeProbe } from '../probe'; +import type { PluginDiscoveryOptions, PluginProviderAdapter } from './adapter'; + +const execFileAsync = promisify(execFile); +const GIT_SOURCE_RE = /^(?:https?:\/\/|git@)/; + +const ClaudeMarketplaceSchema = z.object({ + name: z.string().min(1), + source: z.string().min(1), + path: z.string().min(1).optional(), + installLocation: z.string().min(1).optional(), +}); + +const ClaudeInstalledPluginSchema = z.object({ + id: z.string().min(1), + version: z.string().min(1).optional(), + scope: z.enum(['user', 'project', 'local', 'managed']).optional(), + enabled: z.boolean(), + installPath: z.string().min(1).optional(), +}); + +const ClaudeAvailablePluginSchema = z.object({ + pluginId: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + marketplaceName: z.string().min(1), + version: z.string().min(1).optional(), + source: z.string().min(1), +}); + +const ClaudePluginListSchema = z.object({ + installed: z.array(ClaudeInstalledPluginSchema), + available: z.array(ClaudeAvailablePluginSchema), +}); + +const ClaudePluginManifestSchema = z.object({ + name: z.string().min(1).optional(), + version: z.string().min(1).optional(), + description: z.string().optional(), + author: z.union([z.string().min(1), z.object({ name: z.string().min(1) })]).optional(), + category: z.string().min(1).optional(), + keywords: z.array(z.string().min(1)).optional(), +}); + +const NamedConfigSchema = z.object({ + hooks: z.record(z.string(), z.unknown()).optional(), + mcpServers: z.record(z.string(), z.unknown()).optional(), + lspServers: z.record(z.string(), z.unknown()).optional(), +}); + +type ClaudeMarketplace = z.infer; +type ClaudeInstalledPlugin = z.infer; +type ClaudeAvailablePlugin = z.infer; +type ClaudePluginManifest = z.infer; + +export type ClaudePluginCommand = ( + args: string[], + opts: PluginDiscoveryOptions, +) => Promise; + +interface ClaudePluginRecord { + id: string; + available?: ClaudeAvailablePlugin; + installations: ClaudeInstalledPlugin[]; +} + +interface ClaudePackageMetadata { + manifest?: ClaudePluginManifest; + components: PluginComponent[]; +} + +const CLAUDE_MANAGEMENT_CAPABILITIES = { + install: true, + uninstall: true, + update: true, + enable: true, + disable: true, +} as const; + +/** + * Claude's verified machine-readable plugin surface (CLI 2.1.212): `plugin list --available + * --json` returns installed/available records and `plugin marketplace list --json` supplies their + * local marketplace roots. Package component files remain provider-owned and are read-only here. + */ +export class ClaudeCodePluginAdapter implements PluginProviderAdapter { + readonly provider = 'claude-code' as const; + + constructor(private readonly command: ClaudePluginCommand = runClaudePluginCommand) {} + + async list(opts: PluginDiscoveryOptions = {}): Promise { + const [pluginsValue, marketplacesValue] = await Promise.all([ + this.command(['plugin', 'list', '--available', '--json'], opts), + this.command(['plugin', 'marketplace', 'list', '--json'], opts), + ]); + const catalog = ClaudePluginListSchema.parse(pluginsValue); + const marketplaces = z.array(ClaudeMarketplaceSchema).parse(marketplacesValue); + return normalizeClaudePlugins(catalog, marketplaces); + } +} + +async function runClaudePluginCommand( + args: string[], + opts: PluginDiscoveryOptions, +): Promise { + const binaryPath = + agentRuntimeProber.resolveBinary('claude-code') ?? + new ClaudeCodeProbe().sdkPlatformBinaryPath(); + if (!binaryPath) throw new Error('claude-code: CLI is not available'); + const { stdout } = await execFileAsync(binaryPath, args, { + cwd: opts.cwd, + maxBuffer: 10 * 1024 * 1024, + timeout: 30000, + windowsHide: true, + }); + + return JSON.parse(stdout) as unknown; +} + +async function normalizeClaudePlugins( + catalog: z.infer, + marketplaces: ClaudeMarketplace[], +): Promise { + const marketplaceByName = new Map( + marketplaces.map((marketplace) => [marketplace.name, marketplace]), + ); + const records = new Map(); + for (const available of catalog.available) { + records.set(available.pluginId, { + id: available.pluginId, + available, + installations: [], + }); + } + for (const installed of catalog.installed) { + const record = records.get(installed.id) ?? { id: installed.id, installations: [] }; + record.installations.push(installed); + records.set(installed.id, record); + } + + return Promise.all( + [...records.values()] + .sort((left, right) => left.id.localeCompare(right.id)) + .map((record) => normalizeClaudePlugin(record, marketplaceByName)), + ); +} + +async function normalizeClaudePlugin( + record: ClaudePluginRecord, + marketplaceByName: ReadonlyMap, +): Promise { + const identity = claudePluginIdentity(record.id); + const marketplaceName = record.available?.marketplaceName ?? identity.marketplaceName; + const marketplace = marketplaceName ? marketplaceByName.get(marketplaceName) : undefined; + const availablePackagePath = record.available + ? claudePackagePath(record.available.source, marketplace) + : undefined; + const packagePath = + record.installations.find((item) => item.installPath)?.installPath ?? availablePackagePath; + const metadata = packagePath ? await readClaudePackage(packagePath) : { components: [] }; + const manifest = metadata.manifest; + const authorName = + typeof manifest?.author === 'string' ? manifest.author : manifest?.author?.name; + + return PluginSchema.parse({ + provider: 'claude-code', + id: record.id, + name: record.available?.name ?? manifest?.name ?? identity.name, + version: record.available?.version ?? manifest?.version, + description: record.available?.description ?? manifest?.description, + author: authorName ? { name: authorName } : undefined, + category: manifest?.category, + keywords: manifest?.keywords ?? [], + marketplace: marketplaceName + ? { + name: marketplaceName, + path: marketplace?.installLocation ?? marketplace?.path, + } + : undefined, + source: claudePluginSource(record, marketplace, packagePath), + availability: 'available', + installations: record.installations.map((installed) => ({ + enabled: installed.enabled, + version: installed.version, + scope: installed.scope, + path: installed.installPath, + })), + components: metadata.components, + assets: [], + managementCapabilities: CLAUDE_MANAGEMENT_CAPABILITIES, + }); +} + +function claudePluginIdentity(id: string): { name: string; marketplaceName?: string } { + const separator = id.lastIndexOf('@'); + if (separator <= 0 || separator === id.length - 1) return { name: id }; + return { name: id.slice(0, separator), marketplaceName: id.slice(separator + 1) }; +} + +function claudePackagePath( + source: string, + marketplace: ClaudeMarketplace | undefined, +): string | undefined { + if (isAbsolute(source)) return source; + const marketplacePath = marketplace?.installLocation ?? marketplace?.path; + if (marketplacePath && (source.startsWith('./') || source.startsWith('../'))) { + return resolve(marketplacePath, source); + } + return undefined; +} + +function claudePluginSource( + record: ClaudePluginRecord, + marketplace: ClaudeMarketplace | undefined, + packagePath: string | undefined, +): PluginSource | undefined { + const source = record.available?.source; + const availablePath = source ? claudePackagePath(source, marketplace) : undefined; + if (availablePath) return { type: 'local', path: availablePath }; + if (source && GIT_SOURCE_RE.test(source)) return { type: 'git', url: source }; + if (packagePath) return { type: 'local', path: packagePath }; + return source ? { type: 'remote' } : undefined; +} + +async function readClaudePackage(packagePath: string): Promise { + const [manifest, ...componentGroups] = await Promise.all([ + readClaudeManifest(packagePath), + readDirectoryComponents(packagePath, 'skills', 'skill'), + readDirectoryComponents(packagePath, 'commands', 'command'), + readDirectoryComponents(packagePath, 'agents', 'agent'), + readDirectoryComponents(packagePath, 'output-styles', 'output-style'), + readNamedConfig(join(packagePath, 'hooks', 'hooks.json'), 'hooks', 'hook'), + readNamedConfig(join(packagePath, '.mcp.json'), 'mcpServers', 'mcp-server'), + readNamedConfig(join(packagePath, '.lsp.json'), 'lspServers', 'lsp-server'), + ]); + const components = componentGroups.flat().sort((left, right) => { + const kind = left.kind.localeCompare(right.kind); + return kind === 0 ? left.name.localeCompare(right.name) : kind; + }); + return { manifest, components }; +} + +async function readClaudeManifest(packagePath: string): Promise { + try { + const value: unknown = JSON.parse( + await readFile(join(packagePath, '.claude-plugin', 'plugin.json'), 'utf8'), + ); + const parsed = ClaudePluginManifestSchema.safeParse(value); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } +} + +async function readDirectoryComponents( + packagePath: string, + directory: string, + kind: Extract, +): Promise { + try { + const entries = await readdir(join(packagePath, directory), { withFileTypes: true }); + return entries.flatMap((entry) => { + if (entry.isDirectory()) return [{ kind, name: entry.name }]; + if (entry.isFile() && entry.name.endsWith('.md')) { + return [{ kind, name: entry.name.slice(0, -3) }]; + } + return []; + }); + } catch { + return []; + } +} + +async function readNamedConfig( + file: string, + field: 'hooks' | 'mcpServers' | 'lspServers', + kind: Extract, +): Promise { + try { + const value: unknown = JSON.parse(await readFile(file, 'utf8')); + const parsed = NamedConfigSchema.safeParse(value); + if (!parsed.success) return []; + return Object.keys(parsed.data[field] ?? {}).map((name) => ({ kind, name })); + } catch { + return []; + } +} From 99328d826c5e5727551a4ff52612fead96c6a8f9 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 24 Jul 2026 09:35:48 +0000 Subject: [PATCH 2/5] feat(agent-adapter): discover Codex plugins --- packages/host/agent-adapter/src/index.ts | 1 + .../host/agent-adapter/src/plugins/codex.ts | 293 ++++++++++++++++++ .../host/agent-adapter/src/plugins/index.ts | 25 ++ 3 files changed, 319 insertions(+) create mode 100644 packages/host/agent-adapter/src/plugins/codex.ts create mode 100644 packages/host/agent-adapter/src/plugins/index.ts diff --git a/packages/host/agent-adapter/src/index.ts b/packages/host/agent-adapter/src/index.ts index 22b8b9ae..4941656c 100644 --- a/packages/host/agent-adapter/src/index.ts +++ b/packages/host/agent-adapter/src/index.ts @@ -14,6 +14,7 @@ export { CodexAdapter } from './native/codex'; export { GrokBuildAdapter } from './native/grok-build'; export { OpenCodeAdapter } from './native/opencode'; export { PiAdapter } from './native/pi'; +export * from './plugins'; export * from './probe'; export * from './registry'; export * from './util'; diff --git a/packages/host/agent-adapter/src/plugins/codex.ts b/packages/host/agent-adapter/src/plugins/codex.ts new file mode 100644 index 00000000..04585b51 --- /dev/null +++ b/packages/host/agent-adapter/src/plugins/codex.ts @@ -0,0 +1,293 @@ +import type { Plugin, PluginComponent, PluginLinks, PluginSource } from '@linkcode/schema'; +import { PluginSchema } from '@linkcode/schema'; +import { never } from 'foxts/guard'; +import { noop } from 'foxts/noop'; +import { z } from 'zod'; +import { CodexAppServer, resolveCodexBinaryPath } from '../native/codex/app-server'; +import { agentRuntimeProber } from '../probe'; +import type { PluginDiscoveryOptions, PluginProviderAdapter } from './adapter'; + +const OptionalStringSchema = z.string().min(1).nullable(); +const OptionalUrlSchema = z.url().nullable().catch(null); +const OptionalHttpUrlSchema = z.httpUrl().nullable().catch(null); +const DISCOVERY_TIMEOUT_MS = 30000; + +const CodexPluginSourceSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('local'), path: z.string().min(1) }), + z.object({ + type: z.literal('git'), + url: z.string().min(1), + path: OptionalStringSchema, + refName: OptionalStringSchema, + sha: OptionalStringSchema, + }), + z.object({ + type: z.literal('npm'), + package: z.string().min(1), + version: OptionalStringSchema, + registry: OptionalHttpUrlSchema, + }), + z.object({ type: z.literal('remote') }), +]); + +const CodexPluginInterfaceSchema = z.object({ + displayName: OptionalStringSchema, + shortDescription: z.string().nullable(), + longDescription: z.string().nullable(), + developerName: OptionalStringSchema, + category: OptionalStringSchema, + capabilities: z.array(z.string()), + websiteUrl: OptionalUrlSchema, + privacyPolicyUrl: OptionalUrlSchema, + termsOfServiceUrl: OptionalUrlSchema, +}); + +const CodexPluginSummarySchema = z.object({ + id: z.string().min(1), + remotePluginId: OptionalStringSchema, + version: OptionalStringSchema, + localVersion: OptionalStringSchema, + name: z.string().min(1), + source: CodexPluginSourceSchema, + installed: z.boolean(), + enabled: z.boolean(), + availability: z.enum(['AVAILABLE', 'DISABLED_BY_ADMIN']), + interface: CodexPluginInterfaceSchema.nullable(), + keywords: z.array(z.string().min(1)), +}); + +const CodexMarketplaceSchema = z.object({ + name: z.string().min(1), + path: OptionalStringSchema, + interface: z.object({ displayName: OptionalStringSchema }).nullable(), + plugins: z.array(CodexPluginSummarySchema), +}); + +const CodexPluginListSchema = z.object({ + marketplaces: z.array(CodexMarketplaceSchema), +}); + +const CodexPluginDetailSchema = z.object({ + description: z.string().nullable(), + skills: z.array( + z.object({ + name: z.string().min(1), + description: z.string(), + enabled: z.boolean(), + }), + ), + hooks: z.array( + z.object({ + key: z.string().min(1), + eventName: z.string().min(1), + }), + ), + apps: z.array( + z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().nullable(), + }), + ), + appTemplates: z.array( + z.object({ + templateId: z.string().min(1), + name: z.string().min(1), + description: z.string().nullable(), + }), + ), + mcpServers: z.array(z.string().min(1)), +}); + +const CodexPluginReadSchema = z.object({ plugin: CodexPluginDetailSchema }); + +type CodexMarketplace = z.infer; +type CodexPluginSummary = z.infer; +type CodexPluginDetail = z.infer; + +export type CodexPluginServer = Pick; +export type StartCodexPluginServer = () => Promise; + +const CODEX_MANAGEMENT_CAPABILITIES = { + install: true, + uninstall: true, + update: false, + enable: false, + disable: false, +} as const; + +/** Codex plugin discovery over its generated experimental app-server protocol (0.144.1). */ +export class CodexPluginAdapter implements PluginProviderAdapter { + readonly provider = 'codex' as const; + + constructor(private readonly startServer: StartCodexPluginServer = startCodexPluginServer) {} + + async list(opts: PluginDiscoveryOptions = {}): Promise { + const server = await this.startServer(); + const timeout = setTimeout(() => server.close(), DISCOVERY_TIMEOUT_MS); + try { + const value = await server.request('plugin/list', { + cwds: opts.cwd ? [opts.cwd] : undefined, + }); + const catalog = CodexPluginListSchema.parse(value); + const entries = catalog.marketplaces.flatMap((marketplace) => + marketplace.plugins.map((summary) => ({ marketplace, summary })), + ); + const plugins = await Promise.all( + entries.map(async ({ marketplace, summary }) => + normalizeCodexPlugin( + marketplace, + summary, + await readCodexPluginDetail(server, marketplace, summary), + ), + ), + ); + return plugins.sort((left, right) => left.id.localeCompare(right.id)); + } finally { + clearTimeout(timeout); + server.close(); + } + } +} + +async function readCodexPluginDetail( + server: CodexPluginServer, + marketplace: CodexMarketplace, + summary: CodexPluginSummary, +): Promise { + const pluginName = marketplace.path ? summary.name : summary.remotePluginId; + if (!pluginName) return undefined; + + try { + const detailValue = await server.request('plugin/read', { + pluginName, + ...(marketplace.path + ? { marketplacePath: marketplace.path } + : { remoteMarketplaceName: marketplace.name }), + }); + return CodexPluginReadSchema.parse(detailValue).plugin; + } catch { + return undefined; + } +} + +async function startCodexPluginServer(): Promise { + const binaryPath = agentRuntimeProber.resolveBinary('codex') ?? resolveCodexBinaryPath(); + return CodexAppServer.start({ + binaryPath, + onNotification: noop, + onExit: noop, + }); +} + +function normalizeCodexPlugin( + marketplace: CodexMarketplace, + summary: CodexPluginSummary, + detail: CodexPluginDetail | undefined, +): Plugin { + const pluginInterface = summary.interface; + return PluginSchema.parse({ + provider: 'codex', + id: summary.id, + name: summary.name, + displayName: pluginInterface?.displayName ?? undefined, + description: + detail?.description ?? + pluginInterface?.shortDescription ?? + pluginInterface?.longDescription ?? + undefined, + version: summary.version ?? undefined, + author: pluginInterface?.developerName ? { name: pluginInterface.developerName } : undefined, + category: pluginInterface?.category ?? undefined, + keywords: summary.keywords, + links: codexPluginLinks(pluginInterface), + marketplace: { + name: marketplace.name, + displayName: marketplace.interface?.displayName ?? undefined, + path: marketplace.path ?? undefined, + }, + source: normalizeCodexSource(summary.source), + availability: summary.availability === 'AVAILABLE' ? 'available' : 'blocked', + installations: summary.installed + ? [ + { + enabled: summary.enabled, + version: summary.localVersion ?? undefined, + path: summary.source.type === 'local' ? summary.source.path : undefined, + }, + ] + : [], + components: detail ? codexComponents(detail) : [], + assets: [], + managementCapabilities: CODEX_MANAGEMENT_CAPABILITIES, + }); +} + +function normalizeCodexSource(source: z.infer): PluginSource { + switch (source.type) { + case 'local': + return source; + case 'git': + return { + type: 'git', + url: source.url, + path: source.path ?? undefined, + ref: source.refName ?? undefined, + commit: source.sha ?? undefined, + }; + case 'npm': + return { + type: 'npm', + package: source.package, + version: source.version ?? undefined, + registry: source.registry ?? undefined, + }; + case 'remote': + return source; + default: + return never(source, 'codex plugin source'); + } +} + +function codexPluginLinks( + pluginInterface: CodexPluginSummary['interface'], +): PluginLinks | undefined { + if (!pluginInterface) return undefined; + const links = { + homepage: pluginInterface.websiteUrl ?? undefined, + privacyPolicy: pluginInterface.privacyPolicyUrl ?? undefined, + termsOfService: pluginInterface.termsOfServiceUrl ?? undefined, + }; + return Object.values(links).some((value) => value !== undefined) ? links : undefined; +} + +function codexComponents(detail: CodexPluginDetail): PluginComponent[] { + const components: PluginComponent[] = [ + ...detail.skills.map((skill) => ({ + kind: 'skill' as const, + name: skill.name, + description: skill.description || undefined, + enabled: skill.enabled, + })), + ...detail.hooks.map((hook) => ({ + kind: 'hook' as const, + name: hook.key, + description: hook.eventName, + })), + ...detail.apps.map((app) => ({ + kind: 'app' as const, + name: app.name, + description: app.description ?? undefined, + })), + ...detail.appTemplates.map((template) => ({ + kind: 'app-template' as const, + name: template.name, + description: template.description ?? undefined, + })), + ...detail.mcpServers.map((name) => ({ kind: 'mcp-server' as const, name })), + ]; + return components.sort((left, right) => { + const kind = left.kind.localeCompare(right.kind); + return kind === 0 ? left.name.localeCompare(right.name) : kind; + }); +} diff --git a/packages/host/agent-adapter/src/plugins/index.ts b/packages/host/agent-adapter/src/plugins/index.ts new file mode 100644 index 00000000..99471526 --- /dev/null +++ b/packages/host/agent-adapter/src/plugins/index.ts @@ -0,0 +1,25 @@ +import type { PluginProvider } from '@linkcode/schema'; +import { never } from 'foxts/guard'; +import type { PluginProviderAdapter } from './adapter'; +import { ClaudeCodePluginAdapter } from './claude-code'; +import { CodexPluginAdapter } from './codex'; + +export type { + PluginDiscoveryOptions, + PluginProviderAdapter, + PluginProviderAdapterFactory, +} from './adapter'; +export { ClaudeCodePluginAdapter } from './claude-code'; +export { CodexPluginAdapter } from './codex'; + +/** The only provider-plugin factory; upper layers never branch on native formats. */ +export function createPluginProviderAdapter(provider: PluginProvider): PluginProviderAdapter { + switch (provider) { + case 'claude-code': + return new ClaudeCodePluginAdapter(); + case 'codex': + return new CodexPluginAdapter(); + default: + return never(provider, 'plugin provider'); + } +} From 99dbdc166b8acb6095f033edee3f1ff15f8a64c8 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 24 Jul 2026 09:35:58 +0000 Subject: [PATCH 3/5] test(agent-adapter): cover plugin discovery --- .../src/__tests__/claude-code-plugins.test.ts | 130 ++++++++++++ .../src/__tests__/codex-plugins.test.ts | 189 ++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts create mode 100644 packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts new file mode 100644 index 00000000..3b3c2431 --- /dev/null +++ b/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts @@ -0,0 +1,130 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ClaudePluginCommand } from '../plugins/claude-code'; +import { ClaudeCodePluginAdapter } from '../plugins/claude-code'; + +const tempRoots: string[] = []; + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('ClaudeCodePluginAdapter', () => { + it('normalizes native marketplaces, install scopes, manifests, and component files', async () => { + const marketplaceRoot = await mkdtemp(join(tmpdir(), 'linkcode-claude-marketplace-')); + tempRoots.push(marketplaceRoot); + const packageRoot = join(marketplaceRoot, 'plugins', 'latex'); + await Promise.all([ + mkdir(join(packageRoot, '.claude-plugin'), { recursive: true }), + mkdir(join(packageRoot, 'skills', 'latex'), { recursive: true }), + mkdir(join(packageRoot, 'commands'), { recursive: true }), + mkdir(join(packageRoot, 'agents'), { recursive: true }), + mkdir(join(packageRoot, 'hooks'), { recursive: true }), + ]); + await Promise.all([ + writeFile( + join(packageRoot, '.claude-plugin', 'plugin.json'), + JSON.stringify({ + name: 'latex', + version: '1.2.3', + description: 'Compile LaTeX documents', + author: { name: 'LinkCode' }, + category: 'documents', + keywords: ['latex', 'pdf'], + }), + ), + writeFile(join(packageRoot, 'commands', 'render.md'), '# Render'), + writeFile(join(packageRoot, 'agents', 'reviewer.md'), '# Review'), + writeFile( + join(packageRoot, 'hooks', 'hooks.json'), + JSON.stringify({ hooks: { PostToolUse: [] } }), + ), + writeFile(join(packageRoot, '.mcp.json'), JSON.stringify({ mcpServers: { documents: {} } })), + ]); + const command: ClaudePluginCommand = vi.fn((args) => { + if (args[1] === 'marketplace') { + return Promise.resolve([ + { + name: 'team-tools', + source: 'directory', + path: marketplaceRoot, + installLocation: marketplaceRoot, + }, + ]); + } + return Promise.resolve({ + installed: [ + { + id: 'latex@team-tools', + version: '1.2.3', + scope: 'user', + enabled: true, + installPath: packageRoot, + }, + { + id: 'latex@team-tools', + version: '1.2.3', + scope: 'project', + enabled: false, + installPath: packageRoot, + }, + ], + available: [], + }); + }); + + const plugins = await new ClaudeCodePluginAdapter(command).list({ cwd: '/workspace/demo' }); + + expect(plugins).toEqual([ + expect.objectContaining({ + provider: 'claude-code', + id: 'latex@team-tools', + name: 'latex', + version: '1.2.3', + description: 'Compile LaTeX documents', + author: { name: 'LinkCode' }, + category: 'documents', + keywords: ['latex', 'pdf'], + marketplace: { + name: 'team-tools', + path: marketplaceRoot, + }, + source: { type: 'local', path: packageRoot }, + installations: [ + { + enabled: true, + version: '1.2.3', + scope: 'user', + path: packageRoot, + }, + { + enabled: false, + version: '1.2.3', + scope: 'project', + path: packageRoot, + }, + ], + components: [ + { kind: 'agent', name: 'reviewer' }, + { kind: 'command', name: 'render' }, + { kind: 'hook', name: 'PostToolUse' }, + { kind: 'mcp-server', name: 'documents' }, + { kind: 'skill', name: 'latex' }, + ], + assets: [], + }), + ]); + expect(command).toHaveBeenCalledWith(['plugin', 'list', '--available', '--json'], { + cwd: '/workspace/demo', + }); + }); + + it('rejects malformed provider output at the boundary', async () => { + const command: ClaudePluginCommand = (args) => + Promise.resolve(args[1] === 'marketplace' ? [] : { installed: 'invalid', available: [] }); + + await expect(new ClaudeCodePluginAdapter(command).list()).rejects.toThrow(); + }); +}); diff --git a/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts b/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts new file mode 100644 index 00000000..f4e02500 --- /dev/null +++ b/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts @@ -0,0 +1,189 @@ +import { noop } from 'foxts/noop'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CodexPluginServer, StartCodexPluginServer } from '../plugins/codex'; +import { CodexPluginAdapter } from '../plugins/codex'; + +function codexInterface(displayName: string) { + return { + displayName, + shortDescription: 'Compile LaTeX documents', + longDescription: null, + developerName: 'LinkCode', + category: 'documents', + capabilities: ['skills', 'mcp'], + websiteUrl: 'https://example.com/latex', + privacyPolicyUrl: null, + termsOfServiceUrl: null, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('CodexPluginAdapter', () => { + it('maps local and remote marketplaces through plugin/list and plugin/read', async () => { + const request = vi.fn((method: string, params: unknown) => { + if (method === 'plugin/list') { + return Promise.resolve({ + marketplaces: [ + { + name: 'local-tools', + path: '/marketplaces/local-tools', + interface: { displayName: 'Local Tools' }, + plugins: [ + { + id: 'latex@local-tools', + remotePluginId: null, + version: '2.0.0', + localVersion: '1.9.0', + name: 'latex', + source: { type: 'local', path: '/plugins/latex' }, + installed: true, + enabled: true, + availability: 'AVAILABLE', + interface: codexInterface('LaTeX'), + keywords: ['latex'], + }, + ], + }, + { + name: 'cloud-tools', + path: null, + interface: null, + plugins: [ + { + id: 'review@cloud-tools', + remotePluginId: 'remote-review-123', + version: '1.0.0', + localVersion: null, + name: 'review', + source: { type: 'remote' }, + installed: false, + enabled: false, + availability: 'DISABLED_BY_ADMIN', + interface: { + ...codexInterface('Review'), + websiteUrl: 'not-a-url', + }, + keywords: [], + }, + ], + }, + ], + }); + } + const pluginName = + typeof params === 'object' && params !== null && 'pluginName' in params + ? params.pluginName + : undefined; + if (pluginName === 'latex') { + return Promise.resolve({ + plugin: { + description: 'Compile documents with Tectonic', + skills: [ + { + name: 'latex', + description: 'Compile a document', + enabled: true, + }, + ], + hooks: [{ key: 'render-complete', eventName: 'AfterTool' }], + apps: [], + appTemplates: [], + mcpServers: ['documents'], + }, + }); + } + return Promise.reject(new Error('remote detail unavailable')); + }); + const close = vi.fn(); + const server: CodexPluginServer = { request, close }; + const startServer: StartCodexPluginServer = () => Promise.resolve(server); + + const plugins = await new CodexPluginAdapter(startServer).list({ cwd: '/workspace/demo' }); + + expect(plugins).toEqual([ + expect.objectContaining({ + provider: 'codex', + id: 'latex@local-tools', + displayName: 'LaTeX', + description: 'Compile documents with Tectonic', + author: { name: 'LinkCode' }, + marketplace: { + name: 'local-tools', + displayName: 'Local Tools', + path: '/marketplaces/local-tools', + }, + source: { type: 'local', path: '/plugins/latex' }, + installations: [ + { + enabled: true, + version: '1.9.0', + path: '/plugins/latex', + }, + ], + components: [ + { kind: 'hook', name: 'render-complete', description: 'AfterTool' }, + { kind: 'mcp-server', name: 'documents' }, + { + kind: 'skill', + name: 'latex', + description: 'Compile a document', + enabled: true, + }, + ], + }), + expect.objectContaining({ + id: 'review@cloud-tools', + displayName: 'Review', + description: 'Compile LaTeX documents', + availability: 'blocked', + installations: [], + source: { type: 'remote' }, + components: [], + }), + ]); + expect(request).toHaveBeenCalledWith('plugin/list', { cwds: ['/workspace/demo'] }); + expect(request).toHaveBeenCalledWith('plugin/read', { + pluginName: 'latex', + marketplacePath: '/marketplaces/local-tools', + }); + expect(request).toHaveBeenCalledWith('plugin/read', { + pluginName: 'remote-review-123', + remoteMarketplaceName: 'cloud-tools', + }); + expect(close).toHaveBeenCalledOnce(); + }); + + it('closes the app-server when provider output is malformed', async () => { + const close = vi.fn(); + const server: CodexPluginServer = { + request: () => Promise.resolve({ marketplaces: 'invalid' }), + close, + }; + + await expect(new CodexPluginAdapter(() => Promise.resolve(server)).list()).rejects.toThrow(); + expect(close).toHaveBeenCalledOnce(); + }); + + it('closes the app-server when plugin discovery exceeds its deadline', async () => { + vi.useFakeTimers(); + let rejectRequest: (reason?: unknown) => void = noop; + const request = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectRequest = reject; + }), + ); + const close = vi.fn(() => rejectRequest(new Error('app-server closed'))); + const server: CodexPluginServer = { request, close }; + + const discovery = new CodexPluginAdapter(() => Promise.resolve(server)).list(); + const rejection = expect(discovery).rejects.toThrow('app-server closed'); + await vi.advanceTimersByTimeAsync(30000); + + await rejection; + expect(close).toHaveBeenCalled(); + }); +}); From 9848e73c7e64f24ef4f930d427c03b0c580600f5 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 24 Jul 2026 09:36:11 +0000 Subject: [PATCH 4/5] feat(engine): aggregate provider plugin catalogs --- .../src/__tests__/plugin-service.test.ts | 113 ++++++++++++++++++ packages/host/engine/src/deps.ts | 4 +- packages/host/engine/src/engine.ts | 9 +- packages/host/engine/src/plugin/service.ts | 48 ++++++++ packages/host/engine/src/service.ts | 9 +- 5 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 packages/host/engine/src/__tests__/plugin-service.test.ts create mode 100644 packages/host/engine/src/plugin/service.ts diff --git a/packages/host/engine/src/__tests__/plugin-service.test.ts b/packages/host/engine/src/__tests__/plugin-service.test.ts new file mode 100644 index 00000000..5021b13f --- /dev/null +++ b/packages/host/engine/src/__tests__/plugin-service.test.ts @@ -0,0 +1,113 @@ +import type { PluginDiscoveryOptions, PluginProviderAdapterFactory } from '@linkcode/agent-adapter'; +import type { PluginProvider } from '@linkcode/schema'; +import { PluginSchema } from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { Effect } from 'effect'; +import { noop } from 'foxts/noop'; +import { describe, expect, it } from 'vitest'; + +import { createEngineRuntime } from '../engine'; +import { PluginService } from '../plugin/service'; + +function plugin(provider: PluginProvider, id: string) { + return PluginSchema.parse({ + id, + name: id, + version: '1.0.0', + provider, + keywords: [], + source: { type: 'local', path: `/plugins/${id}` }, + availability: 'available', + installations: [], + components: [], + assets: [], + managementCapabilities: { + install: false, + uninstall: false, + update: false, + enable: false, + disable: false, + }, + }); +} + +describe('PluginService', () => { + it('aggregates provider catalogs in stable provider and id order', async () => { + const options = new Map(); + const factory: PluginProviderAdapterFactory = (provider) => ({ + provider, + list(opts) { + options.set(provider, opts); + + return Promise.resolve( + provider === 'claude-code' + ? [plugin(provider, 'zeta'), plugin(provider, 'alpha')] + : [plugin(provider, 'beta')], + ); + }, + }); + + const plugins = await Effect.runPromise(new PluginService(factory).list({ cwd: '/workspace' })); + + expect(plugins.map(({ provider, id }) => `${provider}:${id}`)).toEqual([ + 'claude-code:alpha', + 'claude-code:zeta', + 'codex:beta', + ]); + expect(options.get('claude-code')).toEqual({ cwd: '/workspace' }); + expect(options.get('codex')).toEqual({ cwd: '/workspace' }); + }); + + it('keeps healthy provider results when another provider fails', async () => { + const factory: PluginProviderAdapterFactory = (provider) => ({ + provider, + list: () => + provider === 'claude-code' + ? Promise.reject(new Error('native discovery failed')) + : Promise.resolve([plugin(provider, 'working')]), + }); + + const plugins = await Effect.runPromise(new PluginService(factory).list()); + + expect(plugins.map(({ provider, id }) => `${provider}:${id}`)).toEqual(['codex:working']); + }); + + it('returns an empty catalog when every provider fails', async () => { + const factory: PluginProviderAdapterFactory = (provider) => ({ + provider, + list: () => Promise.reject(new Error(`${provider} discovery failed`)), + }); + + const plugins = await Effect.runPromise(new PluginService(factory).list()); + + expect(plugins).toEqual([]); + }); + + it('exposes the injected provider aggregation through the Engine runtime', async () => { + const factory: PluginProviderAdapterFactory = (provider) => ({ + provider, + list: () => Promise.resolve([plugin(provider, 'runtime')]), + }); + const transport: Transport = { + connect: () => Promise.resolve(), + send: noop, + onMessage: () => noop, + onClose: () => noop, + close: noop, + }; + + const plugins = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const engine = yield* createEngineRuntime(transport, { pluginFactory: factory }); + return yield* engine.listPlugins({ cwd: '/workspace' }); + }), + ), + ); + + expect(plugins.map(({ provider, id }) => `${provider}:${id}`)).toEqual([ + 'claude-code:runtime', + 'codex:runtime', + ]); + }); +}); diff --git a/packages/host/engine/src/deps.ts b/packages/host/engine/src/deps.ts index 65b65d10..6e3eb8d5 100644 --- a/packages/host/engine/src/deps.ts +++ b/packages/host/engine/src/deps.ts @@ -1,4 +1,4 @@ -import type { AdapterFactory } from '@linkcode/agent-adapter'; +import type { AdapterFactory, PluginProviderAdapterFactory } from '@linkcode/agent-adapter'; import type { AgentRuntimes } from '@linkcode/schema'; import type { LoginBinaryResolver } from './agent/login-service'; import type { ProviderConfigStore } from './agent/provider-config'; @@ -17,6 +17,8 @@ import type { WorkspaceStore } from './workspace/workspace-store'; /** Optional collaborators the daemon injects; each defaults to an in-memory/no-op implementation. */ export interface EngineDeps { factory?: AdapterFactory; + /** Read-only native plugin providers aggregated by the Engine plugin service. */ + pluginFactory?: PluginProviderAdapterFactory; sessionStore?: SessionStore; ptyBackend?: PtyBackend; /** iOS Simulator policy service, daemon-constructed around the sidecar client so the daemon's diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index a5f808a9..431370c0 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -1,5 +1,6 @@ -import { createAdapter } from '@linkcode/agent-adapter'; -import type { WorkspaceRecord } from '@linkcode/schema'; +import type { PluginDiscoveryOptions } from '@linkcode/agent-adapter'; +import { createAdapter, createPluginProviderAdapter } from '@linkcode/agent-adapter'; +import type { Plugin, WorkspaceRecord } from '@linkcode/schema'; import type { Transport, Unsubscribe } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import type { Scope } from 'effect'; @@ -21,6 +22,7 @@ import type { EngineFailure, OperationSubsystem } from './failure'; import { toOperationFailure } from './failure'; import { GitService } from './git/git-service'; import { GitRequestHandler } from './git/request-handler'; +import { PluginService } from './plugin/service'; import { ArtifactHostService } from './preview/artifact-host-service'; import { FileHostService } from './preview/file-host-service'; import { ArtifactRequestHandler } from './preview/request-handler'; @@ -55,6 +57,7 @@ import { InMemoryWorkspaceStore } from './workspace/workspace-store'; export interface EngineRuntime { readonly start: Effect.Effect; readonly ensureChatWorkspace: (cwd: string) => Effect.Effect; + readonly listPlugins: (opts?: PluginDiscoveryOptions) => Effect.Effect; readonly stop: Effect.Effect; } @@ -71,6 +74,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( const providerStore = deps.providerStore ?? new InMemoryProviderConfigStore(); const records = new SessionRecordRegistry(deps.sessionStore ?? new InMemorySessionStore()); const history = new HistoryService(factory); + const plugins = new PluginService(deps.pluginFactory ?? createPluginProviderAdapter); const runtimes = yield* AgentRuntimeService.make( { initial: deps.agentRuntimes, @@ -269,6 +273,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( () => workspaces.ensureChatWorkspace(cwd), ); }), + listPlugins: (opts?: PluginDiscoveryOptions) => plugins.list(opts), stop: Effect.gen(function* () { // Close request admission before yielding so a transport callback already in flight cannot // launch work after the teardown sweep starts. Each step logs and continues so one broken diff --git a/packages/host/engine/src/plugin/service.ts b/packages/host/engine/src/plugin/service.ts new file mode 100644 index 00000000..2738db31 --- /dev/null +++ b/packages/host/engine/src/plugin/service.ts @@ -0,0 +1,48 @@ +import type { PluginDiscoveryOptions, PluginProviderAdapterFactory } from '@linkcode/agent-adapter'; +import type { Plugin, PluginProvider } from '@linkcode/schema'; +import { PluginProviderSchema } from '@linkcode/schema'; +import { Effect } from 'effect'; +import { OperationError } from '../failure'; + +/** Aggregates provider-native catalogs while keeping one unavailable CLI from hiding the rest. */ +export class PluginService { + constructor(private readonly factory: PluginProviderAdapterFactory) {} + + list(opts: PluginDiscoveryOptions = {}): Effect.Effect { + return Effect.all( + PluginProviderSchema.options.map((provider) => this.listProvider(provider, opts)), + { concurrency: 'unbounded' }, + ).pipe( + Effect.map((catalogs) => + catalogs.flat().sort((left, right) => { + const provider = left.provider.localeCompare(right.provider); + return provider === 0 ? left.id.localeCompare(right.id) : provider; + }), + ), + ); + } + + private listProvider( + provider: PluginProvider, + opts: PluginDiscoveryOptions, + ): Effect.Effect { + return Effect.tryPromise({ + try: () => this.factory(provider).list(opts), + catch: (cause) => + new OperationError({ + subsystem: 'agent', + operation: `plugin.list.${provider}`, + publicMessage: `Failed to discover ${provider} plugins`, + cause, + }), + }).pipe( + Effect.catch((error) => + Effect.logWarning( + error.publicMessage, + { operation: error.operation, provider, subsystem: error.subsystem }, + error.cause, + ).pipe(Effect.andThen(Effect.succeed(new Array()))), + ), + ); + } +} diff --git a/packages/host/engine/src/service.ts b/packages/host/engine/src/service.ts index 06e5b65a..dd88a91e 100644 --- a/packages/host/engine/src/service.ts +++ b/packages/host/engine/src/service.ts @@ -1,4 +1,5 @@ -import type { WorkspaceRecord } from '@linkcode/schema'; +import type { PluginDiscoveryOptions } from '@linkcode/agent-adapter'; +import type { Plugin, WorkspaceRecord } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { Context, Effect, Layer } from 'effect'; import type { EngineDeps } from './deps'; @@ -9,6 +10,7 @@ export class EngineService extends Context.Service< EngineService, { readonly ensureChatWorkspace: (cwd: string) => Effect.Effect; + readonly listPlugins: (opts?: PluginDiscoveryOptions) => Effect.Effect; } >()('@linkcode/engine/Engine') {} @@ -32,7 +34,10 @@ export const EngineLive: Layer.Layer runtime.stop, ); yield* engine.start; - return { ensureChatWorkspace: engine.ensureChatWorkspace }; + return { + ensureChatWorkspace: engine.ensureChatWorkspace, + listPlugins: engine.listPlugins, + }; }), ); From ed49732ba2487ac8e73944be46fc211e9eb297aa Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 25 Jul 2026 04:56:51 +0000 Subject: [PATCH 5/5] fix(agent-adapter): harden plugin discovery lifecycle --- .../src/__tests__/claude-code-plugins.test.ts | 7 +++++ .../src/__tests__/codex-plugins.test.ts | 27 +++++++++++++++- .../src/native/codex/app-server.ts | 3 ++ .../agent-adapter/src/plugins/claude-code.ts | 10 +++--- .../host/agent-adapter/src/plugins/codex.ts | 31 +++++++++++++------ 5 files changed, 63 insertions(+), 15 deletions(-) diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts index 3b3c2431..c317775c 100644 --- a/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts +++ b/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts @@ -114,6 +114,13 @@ describe('ClaudeCodePluginAdapter', () => { { kind: 'skill', name: 'latex' }, ], assets: [], + managementCapabilities: { + install: false, + uninstall: false, + update: false, + enable: false, + disable: false, + }, }), ]); expect(command).toHaveBeenCalledWith(['plugin', 'list', '--available', '--json'], { diff --git a/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts b/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts index f4e02500..62c537fb 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts @@ -133,6 +133,13 @@ describe('CodexPluginAdapter', () => { enabled: true, }, ], + managementCapabilities: { + install: false, + uninstall: false, + update: false, + enable: false, + disable: false, + }, }), expect.objectContaining({ id: 'review@cloud-tools', @@ -184,6 +191,24 @@ describe('CodexPluginAdapter', () => { await vi.advanceTimersByTimeAsync(30000); await rejection; - expect(close).toHaveBeenCalled(); + expect(close).toHaveBeenCalledOnce(); + }); + + it('applies the discovery deadline while the app-server is starting', async () => { + vi.useFakeTimers(); + const startServer: StartCodexPluginServer = (signal) => + new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => reject(new Error('codex: plugin discovery timed out')), + { once: true }, + ); + }); + + const discovery = new CodexPluginAdapter(startServer).list(); + const rejection = expect(discovery).rejects.toThrow('plugin discovery timed out'); + await vi.advanceTimersByTimeAsync(30000); + + await rejection; }); }); diff --git a/packages/host/agent-adapter/src/native/codex/app-server.ts b/packages/host/agent-adapter/src/native/codex/app-server.ts index 0329dfea..357bed3c 100644 --- a/packages/host/agent-adapter/src/native/codex/app-server.ts +++ b/packages/host/agent-adapter/src/native/codex/app-server.ts @@ -93,6 +93,8 @@ export interface CodexAppServerOptions { /** Absolute path of the `codex` binary to spawn — resolved by the caller (runtime prober * first, node_modules fallback) so this client stays free of resolution policy. */ binaryPath: string; + /** Abort the spawned process, including while the initialize handshake is still pending. */ + signal?: AbortSignal; /** Extra environment for the subprocess (e.g. CODEX_API_KEY); merged over the inherited env. */ env?: Record; onNotification: (method: string, params: unknown) => void; @@ -138,6 +140,7 @@ export class CodexAppServer { const child = spawn(opts.binaryPath, ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...processEnv, ...opts.env }, + signal: opts.signal, windowsHide: true, }); return CodexAppServer.attach(child, opts); diff --git a/packages/host/agent-adapter/src/plugins/claude-code.ts b/packages/host/agent-adapter/src/plugins/claude-code.ts index 0dfcf470..5fc09938 100644 --- a/packages/host/agent-adapter/src/plugins/claude-code.ts +++ b/packages/host/agent-adapter/src/plugins/claude-code.ts @@ -77,11 +77,11 @@ interface ClaudePackageMetadata { } const CLAUDE_MANAGEMENT_CAPABILITIES = { - install: true, - uninstall: true, - update: true, - enable: true, - disable: true, + install: false, + uninstall: false, + update: false, + enable: false, + disable: false, } as const; /** diff --git a/packages/host/agent-adapter/src/plugins/codex.ts b/packages/host/agent-adapter/src/plugins/codex.ts index 04585b51..4d5ad5b2 100644 --- a/packages/host/agent-adapter/src/plugins/codex.ts +++ b/packages/host/agent-adapter/src/plugins/codex.ts @@ -106,11 +106,11 @@ type CodexPluginSummary = z.infer; type CodexPluginDetail = z.infer; export type CodexPluginServer = Pick; -export type StartCodexPluginServer = () => Promise; +export type StartCodexPluginServer = (signal: AbortSignal) => Promise; const CODEX_MANAGEMENT_CAPABILITIES = { - install: true, - uninstall: true, + install: false, + uninstall: false, update: false, enable: false, disable: false, @@ -123,10 +123,22 @@ export class CodexPluginAdapter implements PluginProviderAdapter { constructor(private readonly startServer: StartCodexPluginServer = startCodexPluginServer) {} async list(opts: PluginDiscoveryOptions = {}): Promise { - const server = await this.startServer(); - const timeout = setTimeout(() => server.close(), DISCOVERY_TIMEOUT_MS); + const controller = new AbortController(); + let server: CodexPluginServer | undefined; + let closed = false; + const closeOnce = (): void => { + if (closed || !server) return; + closed = true; + server.close(); + }; + const timeout = setTimeout(() => { + controller.abort(new Error('codex: plugin discovery timed out')); + closeOnce(); + }, DISCOVERY_TIMEOUT_MS); try { - const value = await server.request('plugin/list', { + const activeServer = await this.startServer(controller.signal); + server = activeServer; + const value = await activeServer.request('plugin/list', { cwds: opts.cwd ? [opts.cwd] : undefined, }); const catalog = CodexPluginListSchema.parse(value); @@ -138,14 +150,14 @@ export class CodexPluginAdapter implements PluginProviderAdapter { normalizeCodexPlugin( marketplace, summary, - await readCodexPluginDetail(server, marketplace, summary), + await readCodexPluginDetail(activeServer, marketplace, summary), ), ), ); return plugins.sort((left, right) => left.id.localeCompare(right.id)); } finally { clearTimeout(timeout); - server.close(); + closeOnce(); } } } @@ -171,12 +183,13 @@ async function readCodexPluginDetail( } } -async function startCodexPluginServer(): Promise { +async function startCodexPluginServer(signal: AbortSignal): Promise { const binaryPath = agentRuntimeProber.resolveBinary('codex') ?? resolveCodexBinaryPath(); return CodexAppServer.start({ binaryPath, onNotification: noop, onExit: noop, + signal, }); }