Browse Source

feat: add ACP-backed agent bridge

alvinreal 1 month ago
parent
commit
18d9d9fa3d

+ 40 - 0
docs/configuration.md

@@ -105,6 +105,13 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `agents.<customAgent>.prompt` | string | — | Full execution prompt for a custom agent |
 | `agents.<customAgent>.orchestratorPrompt` | string | — | Exact `@agent` block injected into the orchestrator prompt; must start with `@<agent-name>` |
 | `agents.<agent>.displayName` | string | — | Custom user-facing alias for the agent in the active config |
+| `acpAgents.<name>.command` | string | — | Command for an external ACP-compatible agent; creates a wrapper subagent named `<name>` |
+| `acpAgents.<name>.args` | string[] | `[]` | Arguments for the ACP agent command |
+| `acpAgents.<name>.env` | object | `{}` | Extra environment variables for the ACP subprocess |
+| `acpAgents.<name>.description` | string | — | Description shown to OpenCode and injected into the orchestrator routing prompt |
+| `acpAgents.<name>.wrapperModel` | string | fixer default | Cheap OpenCode model used by the wrapper subagent that calls `acp_run` |
+| `acpAgents.<name>.permissionMode` | string | `ask` | How ACP permission requests are handled: `ask`, `allow`, or `reject` |
+| `acpAgents.<name>.timeoutMs` | integer | `300000` | Timeout for a single ACP run in milliseconds |
 | `disabled_agents` | string[] | `["observer"]` | Agent names to disable globally. Set to `[]` to enable Observer; this is global, not per-preset |
 | `autoUpdate` | boolean | `true` | Automatically install plugin updates in the background; set to `false` for notification-only mode |
 | `multiplexer.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, or `none` |
@@ -153,6 +160,39 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 > }
 > ```
 
+### ACP-connected agents
+
+Use `acpAgents` to expose external Agent Client Protocol servers as optional
+OpenCode subagents. The plugin creates a lightweight wrapper agent for each
+entry. The wrapper calls the built-in `acp_run` tool, which starts the ACP
+process, creates a session, sends the task, and returns the streamed result.
+
+```jsonc
+{
+  "acpAgents": {
+    "claude-research": {
+      "command": "claude-code-acp",
+      "args": [],
+      "description": "Claude Code subscription agent for deep research",
+      "wrapperModel": "openai/gpt-5.4-mini",
+      "permissionMode": "ask",
+      "timeoutMs": 300000
+    },
+    "gemini-acp": {
+      "command": "gemini",
+      "args": ["--experimental-acp"],
+      "description": "Gemini CLI through ACP"
+    }
+  }
+}
+```
+
+After restart, the orchestrator can delegate to `@claude-research` or
+`@gemini-acp`. Use safe names matching `^[a-z][a-z0-9_-]*$`; names cannot
+conflict with built-in or custom agents. `permissionMode` controls ACP
+permission requests, but the plugin still asks before launching the configured
+subprocess.
+
 ### Council configuration note
 
 - The **Council agent model** is configured like any other agent, for example in

+ 71 - 0
oh-my-opencode-slim.schema.json

@@ -434,6 +434,77 @@
           ]
         }
       }
+    },
+    "acpAgents": {
+      "type": "object",
+      "propertyNames": {
+        "type": "string"
+      },
+      "additionalProperties": {
+        "type": "object",
+        "properties": {
+          "command": {
+            "type": "string",
+            "minLength": 1
+          },
+          "args": {
+            "default": [],
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          },
+          "env": {
+            "default": {},
+            "type": "object",
+            "propertyNames": {
+              "type": "string"
+            },
+            "additionalProperties": {
+              "type": "string"
+            }
+          },
+          "cwd": {
+            "type": "string",
+            "minLength": 1
+          },
+          "description": {
+            "type": "string",
+            "minLength": 1
+          },
+          "prompt": {
+            "type": "string",
+            "minLength": 1
+          },
+          "orchestratorPrompt": {
+            "type": "string",
+            "minLength": 1
+          },
+          "wrapperModel": {
+            "type": "string",
+            "pattern": "^[^/\\s]+\\/[^\\s]+$"
+          },
+          "timeoutMs": {
+            "default": 300000,
+            "type": "integer",
+            "minimum": 1000,
+            "maximum": 900000
+          },
+          "permissionMode": {
+            "default": "ask",
+            "type": "string",
+            "enum": [
+              "ask",
+              "allow",
+              "reject"
+            ]
+          }
+        },
+        "required": [
+          "command"
+        ],
+        "additionalProperties": false
+      }
     }
   },
   "title": "oh-my-opencode-slim",

+ 47 - 0
src/agents/custom.test.ts

@@ -125,4 +125,51 @@ describe('custom-agent creation', () => {
       '@cleanup\n- Role: Cleanup specialist',
     );
   });
+
+  test('creates wrapper agents from acpAgents config', () => {
+    const config: PluginConfig = {
+      acpAgents: {
+        'claude-research': {
+          command: 'claude-code-acp',
+          args: [],
+          env: {},
+          timeoutMs: 300000,
+          permissionMode: 'ask',
+          description: 'Claude Code research via ACP',
+          wrapperModel: 'openai/gpt-5.4-mini',
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const wrapper = agents.find((agent) => agent.name === 'claude-research');
+    const orchestrator = agents.find((agent) => agent.name === 'orchestrator');
+
+    expect(wrapper).toBeDefined();
+    expect(wrapper?.description).toBe('Claude Code research via ACP');
+    expect(wrapper?.config.model).toBe('openai/gpt-5.4-mini');
+    expect(wrapper?.config.prompt).toContain('acp_run');
+    expect(orchestrator?.config.prompt).toContain('@claude-research');
+  });
+
+  test('rejects acpAgents that conflict with custom agents', () => {
+    const config: PluginConfig = {
+      agents: {
+        bridge: { model: 'openai/gpt-5.4-mini' },
+      },
+      acpAgents: {
+        bridge: {
+          command: 'bridge-acp',
+          args: [],
+          env: {},
+          timeoutMs: 300000,
+          permissionMode: 'ask',
+        },
+      },
+    };
+
+    expect(() => createAgents(config)).toThrow(
+      "ACP agent 'bridge' conflicts with a custom agent of the same name",
+    );
+  });
 });

+ 90 - 3
src/agents/index.ts

@@ -5,6 +5,7 @@ import {
   ALL_AGENT_NAMES,
   DEFAULT_DISABLED_AGENTS,
   DEFAULT_MODELS,
+  getAcpAgentNames,
   getAgentOverride,
   getCustomAgentNames,
   loadAgentPrompt,
@@ -45,6 +46,40 @@ function normalizeDisplayName(displayName: string): string {
   return trimmed.startsWith('@') ? trimmed.slice(1) : trimmed;
 }
 
+function buildAcpAgentDefinition(
+  name: string,
+  config: NonNullable<PluginConfig['acpAgents']>[string],
+): AgentDefinition {
+  const description =
+    config.description ?? `External ACP agent '${name}' via ${config.command}`;
+  const prompt =
+    config.prompt ??
+    [
+      `You are the ${name} ACP wrapper agent.`,
+      '',
+      'Your only job is to send the user task to the configured external ACP agent using the acp_run tool, then return the ACP agent result.',
+      `Always call acp_run with agent: ${JSON.stringify(name)} and pass the full user task as prompt.`,
+      'Do not edit files yourself unless the ACP result explicitly asks you to report a local follow-up to the orchestrator.',
+    ].join('\n');
+
+  return {
+    name,
+    description,
+    config: {
+      model:
+        config.wrapperModel ??
+        DEFAULT_MODELS.fixer ??
+        DEFAULT_MODELS.librarian ??
+        DEFAULT_MODELS.orchestrator,
+      temperature: 0,
+      prompt,
+      permission: {
+        acp_run: 'allow',
+      },
+    },
+  } as AgentDefinition;
+}
+
 function isSafeDisplayName(displayName: string): boolean {
   return SAFE_AGENT_ALIAS_RE.test(displayName);
 }
@@ -300,6 +335,27 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
     ];
   });
 
+  const acpAgentNames = getAcpAgentNames(config)
+    .map(normalizeCustomAgentName)
+    .filter((name) => name.length > 0)
+    .filter((name) => {
+      if (!isSafeCustomAgentName(name)) {
+        throw new Error(`Unsafe ACP agent name '${name}'`);
+      }
+      if (customAgentNames.includes(name)) {
+        throw new Error(
+          `ACP agent '${name}' conflicts with a custom agent of the same name`,
+        );
+      }
+      return !disabled.has(name);
+    });
+
+  const protoAcpAgents = acpAgentNames.map((name) => {
+    const acp = config?.acpAgents?.[name];
+    if (!acp) throw new Error(`ACP agent '${name}' is missing config`);
+    return buildAcpAgentDefinition(name, acp);
+  });
+
   // 2. Apply overrides and default permissions to built-in subagents
   const builtInSubAgents = protoSubAgents.map((agent) => {
     const override = getAgentOverride(config, agent.name);
@@ -334,7 +390,16 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
     return agent;
   });
 
-  const allSubAgents = [...builtInSubAgents, ...customSubAgents];
+  const acpSubAgents = protoAcpAgents.map((agent) => {
+    applyDefaultPermissions(agent);
+    return agent;
+  });
+
+  const allSubAgents = [
+    ...builtInSubAgents,
+    ...customSubAgents,
+    ...acpSubAgents,
+  ];
 
   // 3. Create Orchestrator (with its own overrides and custom prompts)
   // DEFAULT_MODELS.orchestrator is undefined; model is resolved via override or
@@ -373,6 +438,19 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
     })
     .filter((prompt): prompt is string => Boolean(prompt));
 
+  const acpOrchestratorPrompts = acpSubAgents.map((agent) => {
+    const acp = config?.acpAgents?.[agent.name];
+    if (acp?.orchestratorPrompt) return acp.orchestratorPrompt;
+    return [
+      `@${agent.name}`,
+      `- Lane: External ACP-connected agent (${acp?.command ?? 'unknown command'})`,
+      `- Role: ${agent.description ?? `External ACP agent ${agent.name}`}`,
+      '- **Delegate when:** The user explicitly asks for this ACP-backed agent, or the task matches its role and benefits from software/subscription-specific capabilities outside OpenCode.',
+      '- **Do not delegate when:** The built-in specialists can handle the task more directly or local file ownership would conflict with another writer lane.',
+      '- **Result handling:** Treat returned output as external-agent work. Reconcile any reported file changes before continuing.',
+    ].join('\n');
+  });
+
   // Validate display names
   const usedDisplayNames = new Set<string>();
   for (const [, displayName] of displayNameMap) {
@@ -403,8 +481,13 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   // Inject display names into orchestrator prompt (complete map)
   injectDisplayNames(orchestrator, displayNameMap);
 
-  if (customOrchestratorPrompts.length > 0) {
-    const rewrittenPrompts = customOrchestratorPrompts.map((promptText) => {
+  const extraOrchestratorPrompts = [
+    ...customOrchestratorPrompts,
+    ...acpOrchestratorPrompts,
+  ];
+
+  if (extraOrchestratorPrompts.length > 0) {
+    const rewrittenPrompts = extraOrchestratorPrompts.map((promptText) => {
       let text = promptText;
       for (const [internalName, displayName] of displayNameMap) {
         text = text.replace(
@@ -524,8 +607,12 @@ export function getEnabledAgentNames(config?: PluginConfig): string[] {
   const customAgentNames = getCustomAgentNames(config).filter(
     (name) => !disabled.has(name),
   );
+  const acpAgentNames = getAcpAgentNames(config).filter(
+    (name) => !disabled.has(name),
+  );
   return [
     ...ALL_AGENT_NAMES.filter((name) => !disabled.has(name)),
     ...customAgentNames,
+    ...acpAgentNames,
   ];
 }

+ 5 - 1
src/config/index.ts

@@ -6,4 +6,8 @@ export {
   loadPluginConfig,
 } from './loader';
 export * from './schema';
-export { getAgentOverride, getCustomAgentNames } from './utils';
+export {
+  getAcpAgentNames,
+  getAgentOverride,
+  getCustomAgentNames,
+} from './utils';

+ 1 - 0
src/config/loader.ts

@@ -203,6 +203,7 @@ export function mergePluginConfigs(
     backgroundJobs: deepMerge(base.backgroundJobs, override.backgroundJobs),
     fallback: deepMerge(base.fallback, override.fallback),
     council: deepMerge(base.council, override.council),
+    acpAgents: deepMerge(base.acpAgents, override.acpAgents),
     companion: deepMerge(
       base.companion as Record<string, unknown> | undefined,
       override.companion as Record<string, unknown> | undefined,

+ 26 - 0
src/config/schema.ts

@@ -197,6 +197,31 @@ export const CompanionConfigSchema = z.object({
 
 export type CompanionConfig = z.infer<typeof CompanionConfigSchema>;
 
+export const AcpAgentPermissionModeSchema = z.enum(['ask', 'allow', 'reject']);
+
+export const AcpAgentConfigSchema = z
+  .object({
+    command: z.string().min(1),
+    args: z.array(z.string()).default([]),
+    env: z.record(z.string(), z.string()).default({}),
+    cwd: z.string().min(1).optional(),
+    description: z.string().min(1).optional(),
+    prompt: z.string().min(1).optional(),
+    orchestratorPrompt: z.string().min(1).optional(),
+    wrapperModel: ProviderModelIdSchema.optional(),
+    timeoutMs: z.number().int().min(1000).max(900000).default(300000),
+    permissionMode: AcpAgentPermissionModeSchema.default('ask'),
+  })
+  .strict();
+
+export const AcpAgentsConfigSchema = z.record(z.string(), AcpAgentConfigSchema);
+
+export type AcpAgentPermissionMode = z.infer<
+  typeof AcpAgentPermissionModeSchema
+>;
+export type AcpAgentConfig = z.infer<typeof AcpAgentConfigSchema>;
+export type AcpAgentsConfig = z.infer<typeof AcpAgentsConfigSchema>;
+
 function validateCustomOnlyPromptFields(
   overrides: Record<string, z.infer<typeof AgentOverrideConfigSchema>>,
   ctx: z.RefinementCtx,
@@ -262,6 +287,7 @@ export const PluginConfigSchema = z
     fallback: FailoverConfigSchema.optional(),
     council: CouncilConfigSchema.optional(),
     companion: CompanionConfigSchema.optional(),
+    acpAgents: AcpAgentsConfigSchema.optional(),
   })
   .superRefine((value, ctx) => {
     if (value.agents) {

+ 4 - 0
src/config/utils.ts

@@ -40,3 +40,7 @@ export function getCustomAgentNames(
     return !(ALL_AGENT_NAMES as readonly string[]).includes(name);
   });
 }
+
+export function getAcpAgentNames(config: PluginConfig | undefined): string[] {
+  return Object.keys(config?.acpAgents ?? {});
+}

+ 5 - 0
src/index.ts

@@ -42,6 +42,7 @@ import {
 import {
   ast_grep_replace,
   ast_grep_search,
+  createAcpRunTool,
   createCancelTaskTool,
   createCouncilTool,
   createPresetManager,
@@ -145,6 +146,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let companionManager: CompanionManager;
   let councilTools: Record<string, unknown>;
   let cancelTaskTools: Record<string, unknown>;
+  let acpRunTool: ReturnType<typeof createAcpRunTool>;
   let webfetch: ReturnType<typeof createWebfetchTool>;
   let rewriteDisplayNameMentions: ReturnType<
     typeof createDisplayNameMentionRewriter
@@ -231,6 +233,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       : {};
 
     mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
+    acpRunTool = createAcpRunTool(config.acpAgents);
     webfetch = createWebfetchTool(ctx);
     backgroundJobBoard = new BackgroundJobBoard({
       maxReusablePerAgent: config.backgroundJobs?.maxSessionsPerAgent ?? 2,
@@ -310,6 +313,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     toolCount =
       Object.keys(councilTools).length +
       Object.keys(cancelTaskTools).length +
+      1 + // acp_run
       1 + // webfetch
       2; // ast_grep_search, ast_grep_replace
   } catch (err) {
@@ -378,6 +382,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     tool: {
       ...councilTools,
       ...cancelTaskTools,
+      acp_run: acpRunTool,
       webfetch,
       ast_grep_search,
       ast_grep_replace,

+ 318 - 0
src/tools/acp-run.ts

@@ -0,0 +1,318 @@
+import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
+import { createInterface } from 'node:readline';
+import { type ToolDefinition, tool } from '@opencode-ai/plugin';
+import type { AcpAgentConfig, AcpAgentsConfig } from '../config';
+
+const z = tool.schema;
+
+type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
+
+interface RpcResponse {
+  id: number;
+  result?: Json;
+  error?: { message?: string };
+}
+
+interface RpcRequest {
+  id: number;
+  method: string;
+  params?: Record<string, unknown>;
+}
+
+interface RpcNotification {
+  method: string;
+  params?: Record<string, unknown>;
+}
+
+type Pending = {
+  resolve: (value: Json | undefined) => void;
+  reject: (error: Error) => void;
+};
+
+class AcpClient {
+  private child: ChildProcessWithoutNullStreams;
+  private next = 1;
+  private pending = new Map<number, Pending>();
+  private chunks: string[] = [];
+  private errors: string[] = [];
+
+  constructor(
+    private name: string,
+    private config: AcpAgentConfig,
+    private cwd: string,
+    private ask: (
+      title: string,
+      metadata: Record<string, unknown>,
+    ) => Promise<void>,
+  ) {
+    this.child = spawn(config.command, config.args, {
+      cwd,
+      env: { ...process.env, ...config.env },
+      stdio: 'pipe',
+    });
+    this.child.stderr.on('data', (chunk) => {
+      this.errors.push(String(chunk));
+    });
+    this.child.on('error', (error) => {
+      for (const item of this.pending.values()) item.reject(error);
+      this.pending.clear();
+    });
+    this.child.on('exit', (code, signal) => {
+      if (this.pending.size === 0) return;
+      const error = new Error(
+        `ACP agent '${name}' exited before replying (code ${code ?? 'null'}, signal ${signal ?? 'null'})`,
+      );
+      for (const item of this.pending.values()) item.reject(error);
+      this.pending.clear();
+    });
+
+    createInterface({ input: this.child.stdout }).on('line', (line) => {
+      this.receive(line).catch((error) => {
+        this.errors.push(String(error));
+      });
+    });
+  }
+
+  async run(prompt: string): Promise<string> {
+    await this.request('initialize', {
+      protocolVersion: 1,
+      clientCapabilities: {},
+      clientInfo: {
+        name: 'oh-my-opencode-slim',
+        title: 'oh-my-opencode-slim ACP bridge',
+      },
+    });
+    const created = await this.request('session/new', {
+      cwd: this.cwd,
+      mcpServers: [],
+    });
+    const sessionId = readSessionId(created);
+    await this.request('session/prompt', {
+      sessionId,
+      prompt: [{ type: 'text', text: prompt }],
+    });
+    return this.output();
+  }
+
+  close(): void {
+    if (!this.child.killed) this.child.kill('SIGTERM');
+  }
+
+  private request(
+    method: string,
+    params: Record<string, unknown>,
+  ): Promise<Json | undefined> {
+    const id = this.next++;
+    const payload = { jsonrpc: '2.0', id, method, params };
+    return new Promise((resolve, reject) => {
+      this.pending.set(id, { resolve, reject });
+      this.child.stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
+        if (!error) return;
+        this.pending.delete(id);
+        reject(error);
+      });
+    });
+  }
+
+  private async receive(line: string): Promise<void> {
+    if (!line.trim()) return;
+    const message = JSON.parse(line) as
+      | RpcResponse
+      | RpcRequest
+      | RpcNotification;
+    if ('id' in message && ('result' in message || 'error' in message)) {
+      const pending = this.pending.get(message.id);
+      if (!pending) return;
+      this.pending.delete(message.id);
+      if (message.error) {
+        pending.reject(
+          new Error(message.error.message ?? 'ACP request failed'),
+        );
+        return;
+      }
+      pending.resolve(message.result);
+      return;
+    }
+    if ('id' in message && 'method' in message) {
+      await this.handleRequest(message);
+      return;
+    }
+    if ('method' in message) this.handleNotification(message);
+  }
+
+  private async handleRequest(message: RpcRequest): Promise<void> {
+    if (message.method === 'session/request_permission') {
+      const title = readPermissionTitle(message.params);
+      if (this.config.permissionMode === 'ask') {
+        await this.ask(title, message.params ?? {});
+      }
+      const optionId = selectPermissionOption(
+        message.params,
+        this.config.permissionMode,
+      );
+      this.reply(message.id, { outcome: { outcome: 'selected', optionId } });
+      return;
+    }
+    this.replyError(
+      message.id,
+      `Unsupported ACP client method: ${message.method}`,
+    );
+  }
+
+  private handleNotification(message: RpcNotification): void {
+    if (message.method !== 'session/update') return;
+    const update = message.params?.update;
+    if (!isRecord(update)) return;
+    collectText(update, this.chunks);
+  }
+
+  private reply(id: number, result: Json): void {
+    this.child.stdin.write(
+      `${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`,
+    );
+  }
+
+  private replyError(id: number, message: string): void {
+    this.child.stdin.write(
+      `${JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message } })}\n`,
+    );
+  }
+
+  private output(): string {
+    const text = this.chunks.join('').trim();
+    if (text) return text;
+    const err = this.errors.join('').trim();
+    return err
+      ? `ACP agent '${this.name}' completed without text output. stderr:\n${err}`
+      : `ACP agent '${this.name}' completed without text output.`;
+  }
+}
+
+export function createAcpRunTool(agents: AcpAgentsConfig = {}): ToolDefinition {
+  return tool({
+    description:
+      'Run a configured external ACP-compatible coding agent and return its streamed result. Use for configured ACP agents such as Claude Code ACP, Gemini ACP, or custom ACP servers.',
+    args: {
+      agent: z.string().describe('Configured ACP agent name'),
+      prompt: z.string().describe('Task or question to send to the ACP agent'),
+      cwd: z
+        .string()
+        .optional()
+        .describe('Optional absolute working directory override'),
+      timeout_ms: z
+        .number()
+        .int()
+        .min(1000)
+        .max(900000)
+        .optional()
+        .describe('Optional timeout override in milliseconds'),
+    },
+    async execute(args, ctx) {
+      const config = agents[args.agent];
+      if (!config) {
+        throw new Error(
+          `Unknown ACP agent '${args.agent}'. Configured agents: ${Object.keys(agents).join(', ') || '(none)'}`,
+        );
+      }
+      const cwd = args.cwd ?? config.cwd ?? ctx.directory;
+      if (!cwd) throw new Error('acp_run requires a working directory');
+
+      await ctx.ask({
+        permission: 'bash',
+        patterns: [`${config.command} ${config.args.join(' ')}`.trim()],
+        always: [],
+        metadata: {
+          agent: args.agent,
+          cwd,
+          command: config.command,
+          args: config.args,
+        },
+      });
+
+      const client = new AcpClient(
+        args.agent,
+        config,
+        cwd,
+        async (title, metadata) => {
+          if (config.permissionMode === 'reject') return;
+          await ctx.ask({
+            permission: 'bash',
+            patterns: [`acp:${args.agent}:${title}`],
+            always: [],
+            metadata,
+          });
+        },
+      );
+      const timeoutMs = args.timeout_ms ?? config.timeoutMs;
+      let timer: ReturnType<typeof setTimeout> | undefined;
+      const timeout = new Promise<string>(
+        (_, reject) =>
+          (timer = setTimeout(
+            () =>
+              reject(
+                new Error(
+                  `ACP agent '${args.agent}' timed out after ${timeoutMs}ms`,
+                ),
+              ),
+            timeoutMs,
+          )),
+      );
+      const abort = () => client.close();
+      ctx.abort.addEventListener('abort', abort, { once: true });
+      try {
+        return await Promise.race([client.run(args.prompt), timeout]);
+      } finally {
+        if (timer) clearTimeout(timer);
+        ctx.abort.removeEventListener('abort', abort);
+        client.close();
+      }
+    },
+  });
+}
+
+function readSessionId(value: Json | undefined): string {
+  if (!isRecord(value) || typeof value.sessionId !== 'string') {
+    throw new Error('ACP agent did not return a sessionId');
+  }
+  return value.sessionId;
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function readPermissionTitle(
+  params: Record<string, unknown> | undefined,
+): string {
+  const tool = isRecord(params?.toolCall) ? params.toolCall : undefined;
+  if (typeof tool?.title === 'string') return tool.title;
+  if (typeof params?.permission === 'string') return params.permission;
+  return 'ACP permission request';
+}
+
+function selectPermissionOption(
+  params: Record<string, unknown> | undefined,
+  mode: AcpAgentConfig['permissionMode'],
+): string {
+  const options = Array.isArray(params?.options) ? params.options : [];
+  const ids = options
+    .filter(isRecord)
+    .map((item) => item.optionId)
+    .filter((item): item is string => typeof item === 'string');
+  if (mode === 'reject')
+    return ids.find((id) => id.includes('reject')) ?? 'reject';
+  return (
+    ids.find((id) => id.includes('allow') || id === 'once') ??
+    ids.find((id) => !id.includes('reject')) ??
+    'allow'
+  );
+}
+
+function collectText(update: Record<string, unknown>, chunks: string[]): void {
+  for (const key of ['content', 'delta']) {
+    const value = update[key];
+    if (typeof value === 'string') chunks.push(value);
+    if (isRecord(value) && typeof value.text === 'string')
+      chunks.push(value.text);
+  }
+}

+ 1 - 0
src/tools/index.ts

@@ -1,4 +1,5 @@
 // AST-grep tools
+export { createAcpRunTool } from './acp-run';
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createCancelTaskTool } from './cancel-task';
 export { createCouncilTool } from './council';