From 50fb3bcd5390ab9f64e9816d324d6efa89c2c619 Mon Sep 17 00:00:00 2001 From: "Jarvis (OpenClaw)" Date: Mon, 9 Mar 2026 09:05:28 -0700 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20add=20ADR-001=20=E2=80=94=20adopt?= =?UTF-8?q?=20ACP=20as=20primary=20agent=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/adr-001-agentctl-adopts-acp.md | 85 +++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/adr/adr-001-agentctl-adopts-acp.md diff --git a/docs/adr/adr-001-agentctl-adopts-acp.md b/docs/adr/adr-001-agentctl-adopts-acp.md new file mode 100644 index 0000000..e07089b --- /dev/null +++ b/docs/adr/adr-001-agentctl-adopts-acp.md @@ -0,0 +1,85 @@ +--- +title: "ADR-001 — agentctl adopts ACP as primary agent interface" +date: 2026-03-09 +status: accepted +deciders: Charlie +tags: [type/adr, domain/agentctl] +--- + +# ADR-001: agentctl adopts ACP as primary agent interface + +## Status +**Accepted** — 2026-03-09 + +## Context + +agentctl (v1.6.0) currently maintains bespoke adapters for each coding agent runtime: Claude Code, Codex, OpenCode, Pi, Pi Rust. Each adapter handles launch, resume, PTY management, output parsing, and lifecycle detection independently. This approach has worked but creates a growing maintenance burden — every new runtime needs a new adapter, and each adapter re-solves the same problems (session management, structured output, permission mediation, crash recovery). + +Meanwhile, the **Agent Client Protocol (ACP)** has emerged as a standard for structured agent-to-agent communication, and **ACPx** (github.com/openclaw/acpx) provides a mature headless ACP client runtime with persistent sessions, cooperative cancel, filesystem/terminal callbacks, and a growing registry of ACP-capable agent bridges. + +Research ([acpx-vs-agentctl analysis](obsidian://open?vault=My%20Notes&file=Projects%2Fresearch%2Facpx-vs-agentctl)) confirms: +- ACPx handles the **transport/session/protocol layer** well (structured JSON-RPC, no PTY scraping) +- agentctl's differentiation is the **supervision/orchestration layer** (daemon, fleet discovery, worktrees, locks, fuses, metrics, event routing) +- There is meaningful overlap in launch/resume/lifecycle plumbing that should not be maintained in two places + +## Decision + +**agentctl will adopt ACP as its primary agent interface strategy.** + +### Principles + +1. **Ride on ACP, don't fight it.** agentctl's CLI and internal APIs should align with ACP primitives (sessions, prompts, cancel, permissions). Where ACP provides a clean abstraction, use it rather than reinventing. + +2. **Adapters become thin ACP integration layers.** For agents with existing ACP bridges (Claude Code, Codex, OpenCode, Pi), adapters should delegate launch/session/lifecycle to ACP (via ACPx or direct ACP client) and only add agentctl-specific concerns (locks, metrics, hooks, worktree management). + +3. **Build ACP clients for harnesses that lack them.** If a runtime doesn't have an ACP bridge, agentctl's contribution is to build one — potentially releasing it upstream or as a standalone package — rather than building yet another bespoke PTY-scraping adapter. + +4. **Supervision stays in agentctl.** ACP/ACPx handles single-session lifecycle. agentctl handles multi-session fleet supervision: daemon, discovery, worktree orchestration, locks, fuses, metrics, webhooks, and operator UX. This is the durable differentiation. + +5. **Contribute upstream where it benefits the ecosystem.** Generic improvements to ACP session management, error handling, or agent bridges should be contributed to ACPx or relevant ACP adapter repos rather than kept proprietary. + +### What changes + +| Area | Before | After | +|------|--------|-------| +| Agent launch/resume | Each adapter implements PTY spawn + output parsing | Delegate to ACP session (via ACPx or embedded ACP client) | +| Structured output | PTY scraping + regex | ACP JSON-RPC stream | +| Session persistence | agentctl-managed state files | ACP session model (ACPx handles persistence) | +| Permission mediation | N/A (PTY auto-approve or manual) | ACP permission callbacks with policy | +| New runtime support | Write a full adapter (~500-800 LOC) | Write or find an ACP bridge (~100-200 LOC), thin agentctl config | +| Crash recovery | Per-adapter process detection | ACP reconnect / session load | + +### What stays the same + +- Daemon supervision architecture +- Fleet discovery (`agentctl list`) +- Worktree management and sweeps +- Directory locks and fuses +- Prometheus metrics +- Webhook/callback hooks +- OrgLoop integration +- CLI UX and operator experience + +## Consequences + +**Positive:** +- Dramatically reduced adapter maintenance surface +- Structured agent output without PTY scraping +- New runtime support becomes trivial (find/build ACP bridge, add config) +- Better crash recovery via ACP session model +- Community alignment — contributing to ACP ecosystem instead of maintaining parallel infrastructure + +**Negative:** +- ACP/ACPx is still alpha — API surface may shift +- Temporary dual-path: existing adapters work today, ACP migration is incremental +- Dependency on external project for protocol layer (mitigated: ACPx is MIT, we can fork if abandoned) + +**Risks:** +- ACPx development could stall (mitigation: agentctl can embed ACP client directly) +- ACP protocol may not cover all edge cases bespoke adapters handle today (mitigation: contribute missing capabilities upstream) + +## References +- [ACPx vs agentctl analysis](obsidian://open?vault=My%20Notes&file=Projects%2Fresearch%2Facpx-vs-agentctl) +- [ACPx repo](https://github.com/openclaw/acpx) +- [ACP specification](https://github.com/AgenClientProtocol/acp) +- [agentctl repo](https://github.com/OrgLoop/agentctl) From 3114e97b04589afd2c9bc90a08a02968f31d4690 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 9 Mar 2026 09:14:21 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20Phase=201=20ACP=20adoption=20?= =?UTF-8?q?=E2=80=94=20Codex=20adapter=20via=20ACP=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a generic ACP adapter layer that delegates agent launch/session/lifecycle to ACP (Agent Client Protocol) instead of bespoke PTY scraping. This proves the architecture described in ADR-001 with Codex as the first adapter. New files: - src/adapters/acp/acp-client.ts — ACP client wrapping the SDK's ClientSideConnection (spawn, connect, prompt, cancel, disconnect) - src/adapters/acp/acp-adapter.ts — Generic AgentAdapter implementation backed by ACP (reusable for future Claude Code, OpenCode, Pi adapters) - src/adapters/codex-acp.ts — Thin Codex config layer using @zed-industries/codex-acp as the ACP bridge - 24 new tests (512 total, all passing) The existing Codex PTY adapter remains as fallback. Users can select the ACP transport with `--adapter codex-acp`. Closes #125 (Phase 1) Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 3 + package-lock.json | 20 ++ package.json | 1 + src/adapters/acp/acp-adapter.test.ts | 347 ++++++++++++++++++++++++++ src/adapters/acp/acp-adapter.ts | 246 +++++++++++++++++++ src/adapters/acp/acp-client.ts | 354 +++++++++++++++++++++++++++ src/adapters/acp/index.ts | 10 + src/adapters/codex-acp.test.ts | 28 +++ src/adapters/codex-acp.ts | 38 +++ src/cli.ts | 2 + 10 files changed, 1049 insertions(+) create mode 100644 src/adapters/acp/acp-adapter.test.ts create mode 100644 src/adapters/acp/acp-adapter.ts create mode 100644 src/adapters/acp/acp-client.ts create mode 100644 src/adapters/acp/index.ts create mode 100644 src/adapters/codex-acp.test.ts create mode 100644 src/adapters/codex-acp.ts diff --git a/AGENTS.md b/AGENTS.md index 7fc97e1..5f17ba5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,9 @@ npm run lint:fix # biome check --write - `src/core/types.ts` — Core interfaces (AgentAdapter, AgentSession, etc.) - `src/adapters/claude-code.ts` — Claude Code adapter (reads ~/.claude/, cross-refs PIDs) - `src/adapters/codex.ts` — Codex CLI adapter (reads ~/.codex/sessions/, cross-refs PIDs) +- `src/adapters/codex-acp.ts` — Codex via ACP transport (uses @zed-industries/codex-acp bridge) +- `src/adapters/acp/acp-client.ts` — Generic ACP client (spawns agent, manages connection via ACP SDK) +- `src/adapters/acp/acp-adapter.ts` — Generic ACP-backed AgentAdapter implementation - `src/adapters/openclaw.ts` — OpenClaw gateway adapter (WebSocket RPC) - `src/adapters/opencode.ts` — OpenCode adapter (reads ~/.local/share/opencode/storage/, cross-refs PIDs) - `src/adapters/pi.ts` — Pi coding agent adapter (reads ~/.pi/, cross-refs PIDs) diff --git a/package-lock.json b/package-lock.json index c5fc111..3872594 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.6.0", "license": "MIT", "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@types/node": "^25.2.3", "commander": "^14.0.3", "tsx": "^4.21.0", @@ -27,6 +28,15 @@ "node": ">=20" } }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.14.1.tgz", + "integrity": "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@biomejs/biome": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.2.tgz", @@ -1646,6 +1656,16 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index f36fb5c..48719e0 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "node": ">=20" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@types/node": "^25.2.3", "commander": "^14.0.3", "tsx": "^4.21.0", diff --git a/src/adapters/acp/acp-adapter.test.ts b/src/adapters/acp/acp-adapter.test.ts new file mode 100644 index 0000000..1afebc9 --- /dev/null +++ b/src/adapters/acp/acp-adapter.test.ts @@ -0,0 +1,347 @@ +import type { StopReason } from "@agentclientprotocol/sdk"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AcpAdapter } from "./acp-adapter.js"; +import type { AcpAgentConfig, AcpClient, AcpSession } from "./acp-client.js"; + +// --- Mock AcpClient --- + +function createMockSession(overrides?: Partial): AcpSession { + return { + sessionId: "test-session-1", + agentConfig: { command: "test-agent", name: "Test Agent" }, + cwd: "/test/project", + pid: 12345, + status: "idle", + output: { messages: [], toolCalls: [] }, + startedAt: new Date("2026-03-01T10:00:00Z"), + ...overrides, + }; +} + +function createMockClient(session?: AcpSession): AcpClient { + const mockSession = session ?? createMockSession(); + const sessions = new Map([ + [mockSession.sessionId, mockSession], + ]); + + return { + connect: vi.fn().mockResolvedValue(mockSession), + prompt: vi.fn().mockResolvedValue("end_turn" as StopReason), + promptDetached: vi.fn(), + cancel: vi.fn().mockResolvedValue(undefined), + getSession: vi.fn((id: string) => sessions.get(id)), + getAllSessions: vi.fn(() => [...sessions.values()]), + isAlive: vi.fn(() => true), + disconnect: vi.fn(() => { + for (const s of sessions.values()) { + s.status = "disconnected"; + s.stoppedAt = new Date(); + } + }), + forceKill: vi.fn(() => { + for (const s of sessions.values()) { + s.status = "disconnected"; + s.stoppedAt = new Date(); + } + }), + getOutput: vi.fn((id: string) => sessions.get(id)?.output), + } as unknown as AcpClient; +} + +const testConfig: AcpAgentConfig = { + command: "test-agent", + name: "Test Agent", +}; + +let adapter: AcpAdapter; +let mockClient: AcpClient; + +beforeEach(() => { + mockClient = createMockClient(); + adapter = new AcpAdapter("test-acp", { + agentConfig: testConfig, + createClient: () => mockClient, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("AcpAdapter", () => { + describe("launch", () => { + it("launches a session via ACP client", async () => { + const session = await adapter.launch({ + adapter: "test-acp", + prompt: "Fix the bug", + cwd: "/test/project", + }); + + expect(session.id).toBe("test-session-1"); + expect(session.adapter).toBe("test-acp"); + expect(session.status).toBe("running"); + expect(session.cwd).toBe("/test/project"); + expect(session.prompt).toBe("Fix the bug"); + expect(session.pid).toBe(12345); + expect(session.meta.transport).toBe("acp"); + }); + + it("connects the ACP client with correct options", async () => { + await adapter.launch({ + adapter: "test-acp", + prompt: "Fix the bug", + cwd: "/my/dir", + env: { FOO: "bar" }, + }); + + expect(mockClient.connect).toHaveBeenCalledWith({ + cwd: "/my/dir", + env: { FOO: "bar" }, + permissionPolicy: "auto-approve", + }); + }); + + it("fires prompt detached after launch", async () => { + await adapter.launch({ + adapter: "test-acp", + prompt: "Do the thing", + cwd: "/test", + }); + + expect(mockClient.promptDetached).toHaveBeenCalledWith( + "test-session-1", + "Do the thing", + ); + }); + + it("truncates prompt in session to 200 chars", async () => { + const longPrompt = "x".repeat(300); + const session = await adapter.launch({ + adapter: "test-acp", + prompt: longPrompt, + cwd: "/test", + }); + + expect(session.prompt).toHaveLength(200); + }); + }); + + describe("discover", () => { + it("returns empty when no sessions launched", async () => { + const freshAdapter = new AcpAdapter("test-acp", { + agentConfig: testConfig, + createClient: () => createMockClient(), + }); + const discovered = await freshAdapter.discover(); + expect(discovered).toEqual([]); + }); + + it("returns launched sessions", async () => { + await adapter.launch({ + adapter: "test-acp", + prompt: "Test", + cwd: "/test", + }); + + const discovered = await adapter.discover(); + expect(discovered).toHaveLength(1); + expect(discovered[0].id).toBe("test-session-1"); + expect(discovered[0].status).toBe("running"); + expect(discovered[0].adapter).toBe("test-acp"); + }); + + it("reports disconnected sessions as stopped", async () => { + const session = createMockSession({ status: "disconnected" }); + const client = createMockClient(session); + const adapterWithStopped = new AcpAdapter("test-acp", { + agentConfig: testConfig, + createClient: () => client, + }); + + await adapterWithStopped.launch({ + adapter: "test-acp", + prompt: "Test", + cwd: "/test", + }); + + // Manually mark as disconnected + session.status = "disconnected"; + session.stoppedAt = new Date(); + + const discovered = await adapterWithStopped.discover(); + expect(discovered[0].status).toBe("stopped"); + }); + }); + + describe("isAlive", () => { + it("returns false for unknown session", async () => { + expect(await adapter.isAlive("nonexistent")).toBe(false); + }); + + it("returns true for active session", async () => { + await adapter.launch({ + adapter: "test-acp", + prompt: "Test", + cwd: "/test", + }); + + expect(await adapter.isAlive("test-session-1")).toBe(true); + }); + }); + + describe("list", () => { + it("returns running sessions by default", async () => { + await adapter.launch({ + adapter: "test-acp", + prompt: "Test", + cwd: "/test", + }); + + const sessions = await adapter.list(); + expect(sessions).toHaveLength(1); + expect(sessions[0].status).toBe("running"); + }); + + it("filters by status", async () => { + await adapter.launch({ + adapter: "test-acp", + prompt: "Test", + cwd: "/test", + }); + + const stopped = await adapter.list({ status: "stopped" }); + expect(stopped).toHaveLength(0); + + const running = await adapter.list({ status: "running" }); + expect(running).toHaveLength(1); + }); + }); + + describe("peek", () => { + it("throws for unknown session", async () => { + await expect(adapter.peek("nonexistent")).rejects.toThrow( + "Session not found", + ); + }); + + it("returns collected messages", async () => { + const session = createMockSession({ + output: { + messages: ["Hello world", "I fixed the bug"], + toolCalls: [], + }, + }); + const client = createMockClient(session); + const a = new AcpAdapter("test-acp", { + agentConfig: testConfig, + createClient: () => client, + }); + + await a.launch({ + adapter: "test-acp", + prompt: "Test", + cwd: "/test", + }); + + const output = await a.peek("test-session-1"); + expect(output).toContain("Hello world"); + expect(output).toContain("I fixed the bug"); + }); + + it("respects lines limit", async () => { + const messages = Array.from({ length: 10 }, (_, i) => `Message ${i}`); + const session = createMockSession({ + output: { messages, toolCalls: [] }, + }); + const client = createMockClient(session); + const a = new AcpAdapter("test-acp", { + agentConfig: testConfig, + createClient: () => client, + }); + + await a.launch({ + adapter: "test-acp", + prompt: "Test", + cwd: "/test", + }); + + const output = await a.peek("test-session-1", { lines: 3 }); + expect(output).not.toContain("Message 0"); + expect(output).toContain("Message 9"); + }); + }); + + describe("status", () => { + it("throws for unknown session", async () => { + await expect(adapter.status("nonexistent")).rejects.toThrow( + "Session not found", + ); + }); + + it("returns session details", async () => { + await adapter.launch({ + adapter: "test-acp", + prompt: "Test", + cwd: "/test", + }); + + const s = await adapter.status("test-session-1"); + expect(s.id).toBe("test-session-1"); + expect(s.adapter).toBe("test-acp"); + expect(s.status).toBe("running"); + }); + }); + + describe("stop", () => { + it("throws for unknown session", async () => { + await expect(adapter.stop("nonexistent")).rejects.toThrow( + "No active session", + ); + }); + + it("cancels and disconnects the client", async () => { + await adapter.launch({ + adapter: "test-acp", + prompt: "Test", + cwd: "/test", + }); + + await adapter.stop("test-session-1"); + expect(mockClient.cancel).toHaveBeenCalledWith("test-session-1"); + expect(mockClient.disconnect).toHaveBeenCalled(); + }); + + it("force kills when force option is set", async () => { + await adapter.launch({ + adapter: "test-acp", + prompt: "Test", + cwd: "/test", + }); + + await adapter.stop("test-session-1", { force: true }); + expect(mockClient.forceKill).toHaveBeenCalled(); + }); + }); + + describe("resume", () => { + it("sends prompt detached to existing session", async () => { + await adapter.launch({ + adapter: "test-acp", + prompt: "Test", + cwd: "/test", + }); + + await adapter.resume("test-session-1", "Follow-up question"); + expect(mockClient.promptDetached).toHaveBeenCalledWith( + "test-session-1", + "Follow-up question", + ); + }); + + it("throws for unknown session", async () => { + await expect(adapter.resume("nonexistent", "msg")).rejects.toThrow( + "No active session", + ); + }); + }); +}); diff --git a/src/adapters/acp/acp-adapter.ts b/src/adapters/acp/acp-adapter.ts new file mode 100644 index 0000000..15bd23e --- /dev/null +++ b/src/adapters/acp/acp-adapter.ts @@ -0,0 +1,246 @@ +/** + * ACP Adapter — generic AgentAdapter implementation backed by ACP. + * + * This adapter delegates launch/resume/lifecycle to an ACP agent process + * (via AcpClient) instead of bespoke PTY scraping. It is designed to be + * reusable across agent runtimes — configure it with an AcpAgentConfig + * for Codex, Claude Code, OpenCode, etc. + */ +import type { + AgentAdapter, + AgentSession, + DiscoveredSession, + LaunchOpts, + LifecycleEvent, + ListOpts, + PeekOpts, + StopOpts, +} from "../../core/types.js"; +import { + type AcpAgentConfig, + AcpClient, + type AcpLaunchOpts, + type AcpSession, + type PermissionPolicy, +} from "./acp-client.js"; + +export interface AcpAdapterOpts { + /** ACP agent configuration (command, args, name) */ + agentConfig: AcpAgentConfig; + /** Default permission policy for headless mode */ + permissionPolicy?: PermissionPolicy; + /** Factory for creating AcpClient instances (injectable for testing) */ + createClient?: (config: AcpAgentConfig) => AcpClient; +} + +/** + * Generic ACP-backed adapter. Manages one AcpClient per active session. + * Multiple sessions can be running concurrently (each in its own process). + */ +export class AcpAdapter implements AgentAdapter { + readonly id: string; + private readonly agentConfig: AcpAgentConfig; + private readonly permissionPolicy: PermissionPolicy; + private readonly createClient: (config: AcpAgentConfig) => AcpClient; + + /** Active clients keyed by session ID */ + private clients = new Map(); + + constructor(adapterId: string, opts: AcpAdapterOpts) { + this.id = adapterId; + this.agentConfig = opts.agentConfig; + this.permissionPolicy = opts.permissionPolicy ?? "auto-approve"; + this.createClient = + opts.createClient ?? ((config) => new AcpClient(config)); + } + + async discover(): Promise { + const results: DiscoveredSession[] = []; + for (const [, client] of this.clients) { + for (const session of client.getAllSessions()) { + results.push(this.toDiscoveredSession(session)); + } + } + return results; + } + + async isAlive(sessionId: string): Promise { + const client = this.clients.get(sessionId); + if (!client) return false; + const session = client.getSession(sessionId); + return session?.status !== "disconnected"; + } + + async list(opts?: ListOpts): Promise { + const discovered = await this.discover(); + return discovered + .filter((d) => { + if (opts?.status) return d.status === opts.status; + if (opts?.all) return true; + return d.status === "running"; + }) + .map((d) => this.toAgentSession(d)); + } + + async peek(sessionId: string, opts?: PeekOpts): Promise { + const client = this.clients.get(sessionId); + if (!client) throw new Error(`Session not found: ${sessionId}`); + + const output = client.getOutput(sessionId); + if (!output) return ""; + + const lines = opts?.lines ?? 20; + const recent = output.messages.slice(-lines); + return recent.join("\n---\n"); + } + + async status(sessionId: string): Promise { + const client = this.clients.get(sessionId); + if (!client) throw new Error(`Session not found: ${sessionId}`); + + const session = client.getSession(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + + return this.toAgentSession(this.toDiscoveredSession(session)); + } + + async launch(opts: LaunchOpts): Promise { + const client = this.createClient(this.agentConfig); + const cwd = opts.cwd || process.cwd(); + + const launchOpts: AcpLaunchOpts = { + cwd, + env: opts.env, + permissionPolicy: this.permissionPolicy, + }; + + const session = await client.connect(launchOpts); + this.clients.set(session.sessionId, client); + + // Send the prompt without blocking (fire-and-forget) + client.promptDetached(session.sessionId, opts.prompt); + + return { + id: session.sessionId, + adapter: this.id, + status: "running", + startedAt: session.startedAt, + cwd, + model: opts.model, + prompt: opts.prompt.slice(0, 200), + pid: session.pid, + meta: { + transport: "acp", + agentCommand: this.agentConfig.command, + adapterOpts: opts.adapterOpts, + spec: opts.spec, + }, + }; + } + + async stop(sessionId: string, opts?: StopOpts): Promise { + const client = this.clients.get(sessionId); + if (!client) throw new Error(`No active session: ${sessionId}`); + + if (opts?.force) { + client.forceKill(); + } else { + // Try cooperative cancel first, then disconnect + try { + await client.cancel(sessionId); + } catch { + // Cancel may fail if already disconnected + } + client.disconnect(); + } + + this.clients.delete(sessionId); + } + + async resume(sessionId: string, message: string): Promise { + const client = this.clients.get(sessionId); + if (!client) throw new Error(`No active session: ${sessionId}`); + + client.promptDetached(sessionId, message); + } + + async *events(): AsyncIterable { + const knownStatuses = new Map(); + + while (true) { + await sleep(5000); + + for (const [sessionId, client] of this.clients) { + const session = client.getSession(sessionId); + if (!session) continue; + + const prevStatus = knownStatuses.get(sessionId); + const currentStatus = + session.status === "disconnected" ? "stopped" : "running"; + + if (prevStatus && prevStatus !== currentStatus) { + const discovered = this.toDiscoveredSession(session); + yield { + type: + currentStatus === "stopped" + ? "session.stopped" + : "session.started", + adapter: this.id, + sessionId, + session: this.toAgentSession(discovered), + timestamp: new Date(), + }; + } + + knownStatuses.set(sessionId, currentStatus); + } + + // Clean up dead sessions from tracking + for (const [id] of knownStatuses) { + if (!this.clients.has(id)) { + knownStatuses.delete(id); + } + } + } + } + + // --- Helpers --- + + private toDiscoveredSession(session: AcpSession): DiscoveredSession { + const isRunning = session.status !== "disconnected"; + return { + id: session.sessionId, + status: isRunning ? "running" : "stopped", + adapter: this.id, + cwd: session.cwd, + startedAt: session.startedAt, + stoppedAt: session.stoppedAt, + pid: session.pid, + nativeMetadata: { + transport: "acp", + agentCommand: session.agentConfig.command, + acpStatus: session.status, + lastStopReason: session.lastStopReason, + }, + }; + } + + private toAgentSession(discovered: DiscoveredSession): AgentSession { + return { + id: discovered.id, + adapter: this.id, + status: discovered.status === "running" ? "running" : "stopped", + startedAt: discovered.startedAt ?? new Date(), + stoppedAt: discovered.stoppedAt, + cwd: discovered.cwd, + pid: discovered.pid, + prompt: discovered.prompt, + tokens: discovered.tokens, + meta: discovered.nativeMetadata ?? {}, + }; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/adapters/acp/acp-client.ts b/src/adapters/acp/acp-client.ts new file mode 100644 index 0000000..845eb8d --- /dev/null +++ b/src/adapters/acp/acp-client.ts @@ -0,0 +1,354 @@ +/** + * ACP Client — wraps the ACP SDK's ClientSideConnection for agentctl's needs. + * + * Spawns an ACP-compatible agent binary as a child process, connects via + * stdio using ndjson, and provides launch/prompt/cancel/status operations. + */ +import { type ChildProcess, spawn } from "node:child_process"; +import type { Readable, Writable } from "node:stream"; +import { + type Agent, + type Client, + ClientSideConnection, + ndJsonStream, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type SessionUpdate, + type StopReason, +} from "@agentclientprotocol/sdk"; +import { buildSpawnEnv } from "../../utils/daemon-env.js"; +import { resolveBinaryPath } from "../../utils/resolve-binary.js"; + +/** Collected output from an ACP session */ +export interface AcpSessionOutput { + messages: string[]; + toolCalls: Array<{ name: string }>; +} + +/** Permission policy for headless operation */ +export type PermissionPolicy = "auto-approve" | "deny"; + +/** Configuration for spawning an ACP agent */ +export interface AcpAgentConfig { + /** Command to spawn (e.g. "codex-acp", "claude-code-acp") */ + command: string; + /** Arguments to pass to the command */ + args?: string[]; + /** Human-readable name for this agent type */ + name: string; +} + +/** Options for creating an ACP session */ +export interface AcpLaunchOpts { + cwd: string; + env?: Record; + permissionPolicy?: PermissionPolicy; +} + +/** Live ACP session state */ +export interface AcpSession { + sessionId: string; + agentConfig: AcpAgentConfig; + cwd: string; + pid?: number; + status: "connected" | "prompting" | "idle" | "disconnected"; + output: AcpSessionOutput; + startedAt: Date; + stoppedAt?: Date; + lastStopReason?: StopReason; +} + +/** + * AcpClient manages the lifecycle of an ACP agent process and connection. + * + * Usage: + * const client = new AcpClient(agentConfig); + * const session = await client.connect({ cwd: "/my/project" }); + * await client.prompt(session.sessionId, "Fix the bug in main.ts"); + * await client.cancel(session.sessionId); + * client.disconnect(); + */ +export class AcpClient { + private child: ChildProcess | null = null; + private connection: ClientSideConnection | null = null; + private sessions = new Map(); + private readonly agentConfig: AcpAgentConfig; + private permissionPolicy: PermissionPolicy = "auto-approve"; + + constructor(agentConfig: AcpAgentConfig) { + this.agentConfig = agentConfig; + } + + /** Spawn the agent process and establish the ACP connection. */ + async connect(opts: AcpLaunchOpts): Promise { + this.permissionPolicy = opts.permissionPolicy ?? "auto-approve"; + + const commandPath = await resolveBinaryPath(this.agentConfig.command); + const args = this.agentConfig.args ?? []; + const env = buildSpawnEnv(opts.env); + + this.child = spawn(commandPath, args, { + cwd: opts.cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + + if (!this.child.stdin || !this.child.stdout) { + throw new Error( + `Failed to spawn ACP agent: ${this.agentConfig.command} — no stdio`, + ); + } + + const pid = this.child.pid; + + // Convert Node streams to web streams for ACP SDK + const writable = nodeWritableToWeb(this.child.stdin); + const readable = nodeReadableToWeb(this.child.stdout); + const stream = ndJsonStream(writable, readable); + + // Create the client-side connection + this.connection = new ClientSideConnection( + (_agent: Agent) => this.createClient(), + stream, + ); + + // Initialize the ACP connection + await this.connection.initialize({ + clientCapabilities: { + terminal: true, + }, + clientInfo: { name: "agentctl", version: "1.0.0" }, + protocolVersion: 1, + }); + + // Create a new session + const response = await this.connection.newSession({ + cwd: opts.cwd, + mcpServers: [], + }); + + const session: AcpSession = { + sessionId: response.sessionId, + agentConfig: this.agentConfig, + cwd: opts.cwd, + pid, + status: "idle", + output: { messages: [], toolCalls: [] }, + startedAt: new Date(), + }; + + this.sessions.set(response.sessionId, session); + + // Track process exit + this.child.on("exit", () => { + for (const s of this.sessions.values()) { + if (s.status !== "disconnected") { + s.status = "disconnected"; + s.stoppedAt = new Date(); + } + } + }); + + return session; + } + + /** Send a prompt to an existing session. Returns when the turn completes. */ + async prompt(sessionId: string, text: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) throw new Error(`Unknown ACP session: ${sessionId}`); + if (!this.connection) throw new Error("ACP connection not established"); + + session.status = "prompting"; + + const response = await this.connection.prompt({ + sessionId, + prompt: [{ type: "text", text }], + }); + + session.status = "idle"; + session.lastStopReason = response.stopReason; + return response.stopReason; + } + + /** Send a prompt without waiting for completion (fire-and-forget for launch). */ + promptDetached(sessionId: string, text: string): void { + this.prompt(sessionId, text).catch(() => { + const session = this.sessions.get(sessionId); + if (session) { + session.status = "disconnected"; + session.stoppedAt = new Date(); + } + }); + } + + /** Cancel an ongoing prompt turn. */ + async cancel(sessionId: string): Promise { + if (!this.connection) return; + await this.connection.cancel({ sessionId }); + } + + /** Get session state. */ + getSession(sessionId: string): AcpSession | undefined { + return this.sessions.get(sessionId); + } + + /** Get all sessions managed by this client. */ + getAllSessions(): AcpSession[] { + return [...this.sessions.values()]; + } + + /** Check if the agent process is still running. */ + isAlive(): boolean { + if (!this.child?.pid) return false; + try { + process.kill(this.child.pid, 0); + return true; + } catch { + return false; + } + } + + /** Disconnect from the agent and kill the process. */ + disconnect(): void { + if (this.child) { + try { + this.child.kill("SIGTERM"); + } catch { + // Already dead + } + this.child = null; + } + this.connection = null; + + for (const session of this.sessions.values()) { + if (session.status !== "disconnected") { + session.status = "disconnected"; + session.stoppedAt = new Date(); + } + } + } + + /** Force kill the agent process. */ + forceKill(): void { + if (this.child) { + try { + this.child.kill("SIGKILL"); + } catch { + // Already dead + } + this.child = null; + } + this.connection = null; + + for (const session of this.sessions.values()) { + if (session.status !== "disconnected") { + session.status = "disconnected"; + session.stoppedAt = new Date(); + } + } + } + + /** Get the output collected from a session. */ + getOutput(sessionId: string): AcpSessionOutput | undefined { + return this.sessions.get(sessionId)?.output; + } + + /** Create the ACP Client handler for the connection. */ + private createClient(): Client { + return { + requestPermission: async ( + params: RequestPermissionRequest, + ): Promise => { + if (this.permissionPolicy === "auto-approve") { + // Auto-approve: select the first allow option + const approveOption = params.options.find( + (o) => o.kind === "allow_once" || o.kind === "allow_always", + ); + return { + outcome: { + outcome: "selected", + optionId: approveOption?.optionId ?? params.options[0].optionId, + }, + }; + } + // Deny policy: cancel permissions + return { outcome: { outcome: "cancelled" } }; + }, + + sessionUpdate: async (params: SessionNotification): Promise => { + const session = this.sessions.get(params.sessionId); + if (!session) return; + + const update = params.update as SessionUpdate; + if (!update) return; + + if ( + update.sessionUpdate === "agent_message_chunk" && + "text" in update + ) { + // Accumulate agent text output + const text = (update as unknown as { text: string }).text; + if (text) { + const msgs = session.output.messages; + if (msgs.length === 0) { + msgs.push(text); + } else { + // Append to last message (streaming chunks) + msgs[msgs.length - 1] += text; + } + } + } + + if (update.sessionUpdate === "tool_call" && "name" in update) { + session.output.toolCalls.push({ + name: (update as unknown as { name: string }).name, + }); + } + }, + }; + } +} + +// --- Node-to-Web stream adapters --- + +function nodeWritableToWeb(nodeStream: Writable): WritableStream { + return new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + const ok = nodeStream.write(chunk, (err) => { + if (err) reject(err); + }); + if (ok) resolve(); + else nodeStream.once("drain", resolve); + }); + }, + close() { + return new Promise((resolve) => { + nodeStream.end(resolve); + }); + }, + abort() { + nodeStream.destroy(); + }, + }); +} + +function nodeReadableToWeb(nodeStream: Readable): ReadableStream { + return new ReadableStream({ + start(controller) { + nodeStream.on("data", (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)); + }); + nodeStream.on("end", () => { + controller.close(); + }); + nodeStream.on("error", (err) => { + controller.error(err); + }); + }, + cancel() { + nodeStream.destroy(); + }, + }); +} diff --git a/src/adapters/acp/index.ts b/src/adapters/acp/index.ts new file mode 100644 index 0000000..ba9df4a --- /dev/null +++ b/src/adapters/acp/index.ts @@ -0,0 +1,10 @@ +export type { AcpAdapterOpts } from "./acp-adapter.js"; +export { AcpAdapter } from "./acp-adapter.js"; +export type { + AcpAgentConfig, + AcpLaunchOpts, + AcpSession, + AcpSessionOutput, + PermissionPolicy, +} from "./acp-client.js"; +export { AcpClient } from "./acp-client.js"; diff --git a/src/adapters/codex-acp.test.ts b/src/adapters/codex-acp.test.ts new file mode 100644 index 0000000..18d318a --- /dev/null +++ b/src/adapters/codex-acp.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { AcpAdapter } from "./acp/acp-adapter.js"; +import { codexAcpAgentConfig, createCodexAcpAdapter } from "./codex-acp.js"; + +describe("codex-acp", () => { + describe("codexAcpAgentConfig", () => { + it("uses codex-acp command", () => { + expect(codexAcpAgentConfig.command).toBe("codex-acp"); + expect(codexAcpAgentConfig.name).toBe("Codex (ACP)"); + }); + }); + + describe("createCodexAcpAdapter", () => { + it("creates an AcpAdapter with id codex-acp", () => { + const adapter = createCodexAcpAdapter(); + expect(adapter).toBeInstanceOf(AcpAdapter); + expect(adapter.id).toBe("codex-acp"); + }); + + it("allows overriding config", () => { + const adapter = createCodexAcpAdapter({ + permissionPolicy: "deny", + }); + expect(adapter).toBeInstanceOf(AcpAdapter); + expect(adapter.id).toBe("codex-acp"); + }); + }); +}); diff --git a/src/adapters/codex-acp.ts b/src/adapters/codex-acp.ts new file mode 100644 index 0000000..4b0436e --- /dev/null +++ b/src/adapters/codex-acp.ts @@ -0,0 +1,38 @@ +/** + * Codex ACP Adapter — runs Codex via ACP instead of PTY scraping. + * + * Uses @zed-industries/codex-acp as the ACP bridge for Codex CLI. + * This is a thin wrapper over the generic AcpAdapter with Codex-specific config. + * + * Falls back to the existing CodexAdapter (PTY) when the ACP bridge is unavailable. + */ +import { AcpAdapter, type AcpAdapterOpts } from "./acp/acp-adapter.js"; +import type { AcpAgentConfig } from "./acp/acp-client.js"; + +/** The ACP bridge command for Codex */ +const CODEX_ACP_COMMAND = "codex-acp"; + +/** Default args — runs headless with full approvals */ +const CODEX_ACP_ARGS: string[] = []; + +/** Default agent config for Codex via ACP */ +export const codexAcpAgentConfig: AcpAgentConfig = { + command: CODEX_ACP_COMMAND, + args: CODEX_ACP_ARGS, + name: "Codex (ACP)", +}; + +/** + * Create a Codex adapter that uses ACP transport. + * + * @param overrides — Optional config overrides (e.g. custom command path) + */ +export function createCodexAcpAdapter( + overrides?: Partial, +): AcpAdapter { + return new AcpAdapter("codex-acp", { + agentConfig: codexAcpAgentConfig, + permissionPolicy: "auto-approve", + ...overrides, + }); +} diff --git a/src/cli.ts b/src/cli.ts index 0f969ad..a94e583 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url"; import { Command } from "commander"; import { ClaudeCodeAdapter } from "./adapters/claude-code.js"; import { CodexAdapter } from "./adapters/codex.js"; +import { createCodexAcpAdapter } from "./adapters/codex-acp.js"; import { OpenClawAdapter } from "./adapters/openclaw.js"; import { OpenCodeAdapter } from "./adapters/opencode.js"; import { PiAdapter } from "./adapters/pi.js"; @@ -41,6 +42,7 @@ import { createWorktree, type WorktreeInfo } from "./worktree.js"; const adapters: Record = { "claude-code": new ClaudeCodeAdapter(), codex: new CodexAdapter(), + "codex-acp": createCodexAcpAdapter(), openclaw: new OpenClawAdapter(), opencode: new OpenCodeAdapter(), pi: new PiAdapter(),