diff --git a/docs/acp-refactor-plan.md b/docs/acp-refactor-plan.md new file mode 100644 index 000000000..951a79463 --- /dev/null +++ b/docs/acp-refactor-plan.md @@ -0,0 +1,281 @@ +# ACP Refactor Plan: Splitting 3 Large Files into Modular Sub-files + +This document is the authoritative migration plan for splitting three oversized ACP (Agent Client Protocol) source files into modular sub-files. Each file exceeds the 500-line-per-module budget; the refactor preserves every public export path so that **no test file and no external consumer requires modification**. + +**Hard constraints (all three refactors):** + +1. All current public API export paths MUST remain working (`from '../server.js'`, `from '../bridge.js'`, `from '../agent.js'`). +2. Every new file MUST be under 500 lines. +3. Test files MUST NOT be modified — including `permissions.test.ts` which does `require('../bridge.ts')` and snapshots the **entire** export surface (so the bridge barrel MUST export exactly the public API, no more, no less). +4. Only the 3 target files and their NEW sub-modules may be modified. +5. `bun run precheck` MUST pass after every step (typecheck + lint fix + test). + +--- + +## Target Files (current state) + +| File | Lines | Public API surface | +|------|------:|--------------------| +| `packages/acp-link/src/server.ts` | 1800 | 8 must-preserve symbols | +| `src/services/acp/bridge.ts` | 1516 | 8 must-preserve symbols | +| `src/services/acp/agent.ts` | 1297 | 1 must-preserve symbol (`AcpAgent`) | +| **Total** | **4613** | | + +--- + +## Migration Order (with rationale) + +The three files are refactored **in dependency order, leaf-first**, so that each step has a stable foundation and any cross-file regression is caught immediately: + +1. **Phase 1 — `src/services/acp/bridge.ts`** (leaf-ish utility module). + - Rationale: `agent.ts` imports `forwardSessionUpdates`, `replayHistoryMessages`, `ToolUseCache` from `bridge.js`. Splitting bridge first means agent's refactor builds against the new (identical) bridge surface. Bridge has zero imports from agent.ts, so it can be split independently. + - The barrel `bridge/index.ts` re-exports the exact public API, so the existing `from '../bridge.js'` specifier resolves unchanged under both Bun and tsc (directory + `index.ts`). + +2. **Phase 2 — `src/services/acp/agent.ts`** (the cohesive AcpAgent class). + - Rationale: Depends on the now-stable bridge module. Only pure helpers and types are extracted; the class body stays intact in `AcpAgent.ts`. `bridge.test.ts`, `agent.test.ts`, `permissions.test.ts` continue to work because `from '../agent.js'` and `from '../bridge.js'` resolve to the barrels. + +3. **Phase 3 — `packages/acp-link/src/server.ts`** (largest, most interdependent). + - Rationale: Self-contained inside `acp-link`; does not import from `src/services/acp`. Done last so the most complex module split (12 sub-files, runtime-state container, handler fan-out) can leverage the workflow discipline practiced in Phases 1–2. + +Within each phase, the internal creation order is always: **types → leaf pure helpers → mid-level helpers → handlers → dispatch → barrel → delete original**. This keeps the import graph acyclic at every intermediate commit. + +--- + +## Phase 1 — `src/services/acp/bridge.ts` + +### Directory structure + +``` +src/services/acp/ +├── bridge.ts ← DELETED (replaced by directory) +└── bridge/ + ├── index.ts ← barrel (public API) + ├── types.ts ← type definitions + ├── paths.ts ← toAbsolutePath + ├── contentBlocks.ts ← low-level block conversion + ├── toolInfo.ts ← toolInfoFromToolUse + ├── toolResults.ts ← tool result → ToolCallContent + ├── modelUsage.ts ← context-window prefix helpers + ├── notifications.ts ← content-block → SessionUpdate engine + └── forwarding.ts ← stream replay + forwarding loop +``` + +### Files, responsibilities, line budgets + +| File | Responsibility | Exports | Budget | +|------|----------------|---------|-------:| +| `bridge/types.ts` | Shared ACP-bridge type definitions: `ToolUseCache`, `SessionUsage`, `BridgeUsage`, `Bridge*Message` interfaces, `BridgeSDKMessage` discriminated union, `ToolInfo`, `EditToolResponseHunk`, `EditToolResponse`. Re-exports SDK type-only imports (`ContentBlock`, `ToolCallContent`, `ToolCallLocation`, `ToolKind`). | 16 symbols | ~150 | +| `bridge/paths.ts` | Pure path-normalisation helper `toAbsolutePath` used by toolInfo / toolResults / forwarding. Leaf module, no bridge-internal imports. | `toAbsolutePath` | ~20 | +| `bridge/contentBlocks.ts` | Low-level conversion of Claude content block shapes into ACP `ContentBlock` values. `toAcpContentUpdate` wraps arrays/strings into `ToolCallContent[]` via `toAcpContentBlock`. Leaf module. | `toAcpContentUpdate`, `toAcpContentBlock` | ~150 | +| `bridge/toolInfo.ts` | `toolInfoFromToolUse` — large switch mapping each known tool name (Agent/Task, Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, TodoWrite, ExitPlanMode, default) to ACP `ToolInfo` (title, kind, content, locations). Depends on `paths.toAbsolutePath` and `../utils.js` (`toDisplayPath`). | `toolInfoFromToolUse` | ~250 | +| `bridge/toolResults.ts` | `toolUpdateFromToolResult` (Read markdown escape, Bash console fence, Edit/Write no-op, ExitPlanMode title, default via `toAcpContentUpdate`); `toolUpdateFromEditToolResponse` (parses `structuredPatch` hunks into diff `ToolCallContent` with absolute paths). Depends on `contentBlocks` and `paths`. | `toolUpdateFromToolResult`, `toolUpdateFromEditToolResponse` | ~180 | +| `bridge/modelUsage.ts` | `commonPrefixLength` and `getMatchingModelUsage` — pure helpers used by the forwarding loop to resolve `contextWindow` from `modelUsage` map by prefix match. Leaf module. | `commonPrefixLength`, `getMatchingModelUsage` | ~35 | +| `bridge/notifications.ts` | Core content-block → `SessionUpdate` conversion engine. `toAcpNotifications` handles text/thinking/image/tool_use/tool_result/etc. and writes into `ToolUseCache`. `assistantMessageToAcpNotifications` and `streamEventToAcpNotifications` are thin adapters. `normalizePlanStatus` helper for TodoWrite plan mapping. Depends on `toolInfo.toolInfoFromToolUse`, `toolResults.toolUpdateFromToolResult`, and `types`. **No logger** in original — do NOT add one here. | `toAcpNotifications`, `assistantMessageToAcpNotifications`, `streamEventToAcpNotifications`, `normalizePlanStatus` | ~320 | +| `bridge/forwarding.ts` | `nextSdkMessageOrAbort` (races async generator against `AbortSignal`); `forwardSessionUpdates` (main loop consuming `SDKMessage` stream, dispatching to notification converters, accumulating usage, mapping stop reasons); `replayHistoryMessages` (replays stored user/assistant history through `toAcpNotifications`). The module-level `const logger = console` lives here (only `forwardSessionUpdates` default branch and `replayHistoryMessages` reference `logger.debug`). Depends on `types`, `notifications`, `modelUsage`. | `nextSdkMessageOrAbort`, `forwardSessionUpdates`, `replayHistoryMessages` | ~280 | +| `bridge/index.ts` | Barrel — see content below. | 8 re-exports | ~20 | + +### Barrel content — `src/services/acp/bridge/index.ts` + +```ts +// Barrel preserving the public API of the former src/services/acp/bridge.ts. +// Do NOT add internal-only exports here: permissions.test.ts snapshots the +// entire module surface via require('../bridge.ts') and would break if the +// exported name set changes. +export type { ToolUseCache, SessionUsage } from './types.js' +export { + toolInfoFromToolUse, +} from './toolInfo.js' +export { + toolUpdateFromToolResult, + toolUpdateFromEditToolResponse, +} from './toolResults.js' +export { + nextSdkMessageOrAbort, + forwardSessionUpdates, + replayHistoryMessages, +} from './forwarding.js' +``` + +### Phase 1 verification + +```bash +# After creating all sub-files and deleting bridge.ts: +bun test src/services/acp/__tests__/bridge.test.ts +bun test src/services/acp/__tests__/permissions.test.ts # snapshot-sensitive +bun test src/services/acp/__tests__/agent.test.ts # imports bridge.js + agent.js +bun run precheck # typecheck + lint + test +``` + +### Phase 1 risk callouts + +- **Snapshot sensitivity**: `permissions.test.ts` lines 34–35 do `require('../bridge.ts')` and snapshot every named export. The barrel MUST export exactly `{ ToolUseCache, SessionUsage, toolInfoFromToolUse, toolUpdateFromToolResult, toolUpdateFromEditToolResponse, nextSdkMessageOrAbort, forwardSessionUpdates, replayHistoryMessages }`. Do NOT re-export `ToolInfo`, `BridgeSDKMessage`, or any internal helper. +- **Logger alias**: the original `const logger = console` is a top-level const with no runtime side effect. Keep it ONLY in `forwarding.ts`. Do NOT create a shared `logger.ts` (would risk a cycle) and do NOT give `notifications.ts` its own logger (the original does not reference one). +- **`ToolInfo` stays internal**: it is the return type of `toolInfoFromToolUse` but was never exported from the original `bridge.ts`. Keep it module-internal so the public surface matches the original exactly. + +--- + +## Phase 2 — `src/services/acp/agent.ts` + +### Directory structure + +``` +src/services/acp/ +├── agent.ts ← DELETED (replaced by directory) +└── agent/ + ├── index.ts ← barrel (re-exports AcpAgent) + ├── sessionTypes.ts ← AcpSession / PendingPrompt types + ├── permissionMode.ts ← permission mode resolution + ├── configOptions.ts ← config option list builder + ├── promptQueue.ts ← pending-prompt queue helpers + └── AcpAgent.ts ← the AcpAgent class body +``` + +### Files, responsibilities, line budgets + +| File | Responsibility | Exports | Budget | +|------|----------------|---------|-------:| +| `agent/sessionTypes.ts` | Type definitions for in-process ACP session state. `AcpSession` and `PendingPrompt` type aliases shared across agent internals and helpers. | `AcpSession`, `PendingPrompt` | ~35 | +| `agent/permissionMode.ts` | Resolve the effective permission mode from `_meta`, settings, and process env. Determine whether ACP `bypassPermissions` mode is available (process + local opt-in + settings). `PermissionMode`-id validation guard. Imports `PermissionMode` type from `../../types/permissions.js` and `resolvePermissionMode` from `../utils.js` — leaf module, does NOT import AcpAgent. | `permissionModeIds`, `isPermissionMode`, `resolveSessionPermissionMode`, `isAcpBypassPermissionModeAvailable`, `hasOwnField` | ~110 | +| `agent/configOptions.ts` | Build the ACP session config option list (mode + model select options) from session states. `flattenConfigOptionValues` flattens grouped/flat select options into valid value strings for validation. Imports ACP SDK types (`SessionModeState`, `SessionModelState`, `SessionConfigOption`). Leaf module. | `buildConfigOptions`, `flattenConfigOptionValues` | ~70 | +| `agent/promptQueue.ts` | Pending-prompt queue management: `popNextPendingPrompt`, `compactPendingQueue` (compacts queue head to bound memory). Pure helpers operating on `AcpSession.pendingQueue` / `pendingMessages`. Imports `sessionTypes` only. | `popNextPendingPrompt`, `compactPendingQueue` | ~45 | +| `agent/AcpAgent.ts` | The `AcpAgent` class implementing the ACP Agent interface. All protocol method handlers (`initialize`, `authenticate`, `newSession`, `resumeSession`, `loadSession`, `listSessions`, `forkSession`, `closeSession`, `prompt`, `cancel`, `setSessionMode`, `setSessionModel`, `setSessionConfigOption`) and private lifecycle helpers (`createSession`, `getOrCreateSession`, `teardownSession`, `replaySessionHistory`, `applySessionMode`, `updateConfigOption`, `syncSessionConfigState`, `sendAvailableCommandsUpdate`, `scheduleAvailableCommandsUpdate`, `maybeEmitSessionInfoUpdate`, `getSetting`). Imports `sessionTypes`, `permissionMode`, `configOptions`, `promptQueue`. Imports `ToolUseCache`, `forwardSessionUpdates`, `replayHistoryMessages` from `../bridge.js` (the Phase 1 barrel). | `AcpAgent` | ~480 | +| `agent/index.ts` | Barrel — see content below. | `AcpAgent` | ~5 | + +### Barrel content — `src/services/acp/agent/index.ts` + +```ts +// Barrel preserving the public API of the former src/services/acp/agent.ts. +// Tests import AcpAgent via '../agent.js' (Bun/tsc resolve the directory's +// index.ts). Keep this file to a single re-export. +export { AcpAgent } from './AcpAgent.js' +``` + +### Why the class body is NOT split further + +The `AcpAgent` class is a single cohesive unit bound by `this.sessions` and `this.conn`. Methods like `createSession`, `prompt`, `cancel`, `teardownSession`, `applySessionMode`, `updateConfigOption` all reference `this.*` and shared private helpers. Extracting methods to a separate module would require passing the session map and connection as parameters and would create tight bidirectional coupling with high cycle risk. Therefore the class body stays in one module (~480 lines, under the 500 limit); only pure helpers and types are extracted. This keeps the import graph strictly acyclic: `sessionTypes`/`permissionMode`/`configOptions`/`promptQueue` are pure leaves that never import `AcpAgent`. + +### Phase 2 verification + +```bash +bun test src/services/acp/__tests__/agent.test.ts # imports ../agent.js + ../bridge.js +bun test src/services/acp/__tests__/permissions.test.ts # still green after bridge split +bun run precheck +``` + +### Phase 2 risk callouts + +- **Private method coupling**: keep the class intact in `AcpAgent.ts`; do not be tempted to extract methods even if the file approaches the budget. +- **ToolUseCache shape coupling**: `maybeEmitSessionInfoUpdate` attaches `__sessionInfoTitleSent` to `session.toolUseCache` via a structural cast. Keep that logic inside `AcpAgent.ts` so no cross-module dependency on the extended shape is introduced. +- **Test path stability**: `agent.test.ts` line 195 does `await import('../agent.js')`. With `agent/index.ts` re-exporting `AcpAgent` from `agent/AcpAgent.ts`, the specifier resolves under Bun/TS because directory imports map to `index.ts`. The barrel MUST use the `.js` extension (`export { AcpAgent } from './AcpAgent.js'`) to match the project's ESM convention. + +--- + +## Phase 3 — `packages/acp-link/src/server.ts` + +### Directory structure + +``` +packages/acp-link/src/ +├── server.ts ← DELETED (replaced by directory) +└── server/ + ├── index.ts ← barrel (public API) + ├── types.ts ← protocol/state types + JSON-RPC codes + ├── runtime-state.ts ← module-scoped mutable state container + ├── client-send.ts ← outbound message framing + ├── acp-client.ts ← createClient + permission helpers + ├── payload-decode.ts ← validation/decode utilities + ├── permission-mode.ts ← permission mode resolution + ├── handlers-agent.ts ← agent lifecycle handlers + ├── handlers-session.ts ← session-scoped handlers + ├── dispatch.ts ← dispatch + JSON-RPC wrappers + table + ├── testing-internals.ts ← __testing public object + └── start-server.ts ← startServer orchestrator +``` + +### Files, responsibilities, line budgets + +| File | Responsibility | Exports | Budget | +|------|----------------|---------|-------:| +| `server/types.ts` | Shared protocol/state type definitions used across all server modules (`ServerConfig`, `PendingPermission`, `PromptCapabilities`, `SessionModelState`, `AgentCapabilities`, `ClientState`, `ContentBlock`, `PermissionResponsePayload`, `ProxyMessage`); `createClientState` factory; `DEFAULT_CLIENT_INFO` / `DEFAULT_CLIENT_CAPABILITIES` constants; JSON-RPC error code constants. | 16 symbols | ~200 | +| `server/runtime-state.ts` | Module-scoped mutable state container for the running server: holds the `clients` Map, server config fields (`AGENT_*`, `SERVER_*`, `AUTH_TOKEN`, `DEFAULT_PERMISSION_MODE`), `rcsUpstream`, loggers, and accessor/mutator helpers. `createRelayWs` virtual `WSContext` factory. `generateRequestId` helper. **MUST NOT import any handler module** to avoid cycles. | `clients`, `getServerConfig`, `setServerConfig`, `getRcsUpstream`, `setRcsUpstream`, `getAgentConfig`, `getDefaultPermissionMode`, `setDefaultPermissionMode`, `logWs`, `logAgent`, `logSession`, `logPrompt`, `logPerm`, `logRelay`, `logServer`, `PERMISSION_TIMEOUT_MS`, `HEARTBEAT_INTERVAL_MS`, `createRelayWs`, `generateRequestId` | ~140 | +| `server/client-send.ts` | Outbound message framing: `send`, `sendJsonRpcRaw`, `sendJsonRpcError`. `LEGACY_NOTIFICATION_TO_JSONRPC` mapping. Depends on `runtime-state` (`clients`, `rcsUpstream`) and `types` (`ClientState`). Reads `rcsUpstream` via runtime-state and the `clients` Map; `sendJsonRpcError` reads/writes `state.pendingJsonRpc`. | `send`, `sendJsonRpcRaw`, `sendJsonRpcError` | ~110 | +| `server/acp-client.ts` | `createClient(ws, clientState)`: builds the `acp.Client` implementation that forwards `requestPermission` / `sessionUpdate` / `readTextFile` / `writeTextFile`. `handlePermissionResponse` and `cancelPendingPermissions`. Depends on `client-send` (`send`) and `runtime-state` (`logPerm`). Import graph: `client-send → runtime-state` (ok), `acp-client → client-send + runtime-state` (ok, no cycle). | `createClient`, `handlePermissionResponse`, `cancelPendingPermissions` | ~110 | +| `server/payload-decode.ts` | Pure validation/decode utilities (`isRecord`, `optionalString`, `optionalStringField`, `payloadRecord`, `optionalPayloadRecord`, `optionalRecord`, `decodeContentBlocks`, `decodePermissionResponsePayload`). `decodeClientMessage` switch turning a raw record into a `ProxyMessage`. Public `decodeClientWsMessage` wrapper. `decodeClientMessage` is also consumed by `start-server.ts` (RCS relay path) — keep it exported here to avoid duplication. | 10 symbols | ~200 | +| `server/permission-mode.ts` | `ACP_LINK_PERMISSION_MODE_ALIASES` + `resolveAcpLinkPermissionMode` + public `resolveNewSessionPermissionMode`. `buildAgentEnv` helper. | `resolveNewSessionPermissionMode`, `resolveAcpLinkPermissionMode`, `ACP_LINK_PERMISSION_MODE_ALIASES`, `buildAgentEnv` | ~90 | +| `server/handlers-agent.ts` | Agent lifecycle + connection handlers: `handleConnect` and `handleDisconnect`. Spawns the agent child process, builds the ACP `ClientSideConnection`, surfaces status. Depends on `runtime-state`, `client-send`, `acp-client`, `types`. | `handleConnect`, `handleDisconnect` | ~160 | +| `server/handlers-session.ts` | Session-scoped handlers: `handleNewSession`, `handleListSessions`, `handleLoadSession`, `handleResumeSession`, `handleCancel`, `handleSetSessionModel`, `handlePrompt`. All operate on `clients.get(ws)` state and forward to `ClientSideConnection`. | 7 symbols | ~360 | +| `server/dispatch.ts` | `dispatchClientMessage` (legacy envelope switch). JSON-RPC wrappers `handleJsonRpcNewSession` / `Prompt` / `ListSessions` / `LoadSession` / `ResumeSession` / `SetSessionModel` / `SetSessionMode` / `CloseSession` / `CancelRequest`. `JSONRPC_METHOD_HANDLERS` table and `dispatchJsonRpcMessage` router. The JSON-RPC wrappers live **alongside** the table in this module (no cross-module forward reference). | `dispatchClientMessage`, `dispatchJsonRpcMessage`, `JSONRPC_METHOD_HANDLERS`, `handleJsonRpcSetSessionMode`, `handleJsonRpcCloseSession`, `handleJsonRpcCancelRequest` | ~290 | +| `server/testing-internals.ts` | `__testing` public object (`dispatchClientMessage` / `dispatchJsonRpcMessage` / `registerClient` / `getClientSessionId` / `setDefaultPermissionMode`). `assertTestingInternalsEnabled` guard gated on `ACP_LINK_TEST_INTERNALS`. Co-locate the guard with the methods that call it. | `__testing`, `assertTestingInternalsEnabled` | ~80 | +| `server/start-server.ts` | `startServer(config)`: configures runtime-state, wires `RcsUpstreamClient` relay, builds the Hono app with `/health` and `/ws` (token validation, `onOpen` / `onMessage` / `onClose`, heartbeat), HTTPS option, startup banner, SIGINT/SIGTERM graceful shutdown. Top-level orchestrator importing from `runtime-state`, `client-send`, `acp-client`, `dispatch`, `payload-decode`. All intervals/sockets MUST be created inside `startServer` (no top-level side effects). | `startServer` | ~280 | +| `server/index.ts` | Barrel — see content below. | 8 re-exports | ~25 | + +### Barrel content — `packages/acp-link/src/server/index.ts` + +```ts +// Barrel preserving the public API of the former packages/acp-link/src/server.ts. +// +// Re-exports of MAX_CLIENT_WS_PAYLOAD_BYTES / isJsonRpc2Message / +// JsonRpc2ClientMessage MUST come from '../ws-message.js' (single source of +// truth) — do NOT route them through a split module. +export type { ServerConfig } from './types.js' +export { + MAX_CLIENT_WS_PAYLOAD_BYTES, + isJsonRpc2Message, +} from '../ws-message.js' +export type { JsonRpc2ClientMessage } from '../ws-message.js' +export { decodeClientWsMessage } from './payload-decode.js' +export { resolveNewSessionPermissionMode } from './permission-mode.js' +export { __testing } from './testing-internals.js' +export { startServer } from './start-server.js' +``` + +### Phase 3 verification + +```bash +bun test packages/acp-link/src/__tests__/server.test.ts +bun test packages/acp-link/src/__tests__/types.test.ts +bun run precheck +bun run build # confirm chunk count is sane and dist/cli.js builds +``` + +### Phase 3 risk callouts + +- **Module-scoped mutable state**: `AGENT_COMMAND`, `AGENT_ARGS`, `AGENT_CWD`, `SERVER_PORT`, `SERVER_HOST`, `AUTH_TOKEN`, `DEFAULT_PERMISSION_MODE`, the `clients` Map, and `rcsUpstream` all live in `runtime-state.ts`. Every other module accesses them via the accessors/setters. Keep `runtime-state.ts` free of any handler import — it is the shared leaf that everything else depends on; importing handlers back into it creates a cycle. +- **Single-flight invariant**: `sendJsonRpcError` reads/writes `state.pendingJsonRpc`. Do not parallelise handlers — the pendingJsonRpc invariant depends on serial mutation of `ClientState`. +- **JSON-RPC wrappers co-located with the table**: `JSONRPC_METHOD_HANDLERS` references the `handleJsonRpc*` wrappers. To avoid cross-module forward references, the wrappers and the table MUST live in the same `dispatch.ts` module. +- **Re-exports stay at source**: `MAX_CLIENT_WS_PAYLOAD_BYTES`, `isJsonRpc2Message`, `JsonRpc2ClientMessage` are re-exported from `'../ws-message.js'` directly. Do NOT re-export them from a split module. +- **No top-level side effects**: the original file only declares module-scoped vars; loggers are created eagerly via `createLogger` (acceptable — pure construction). Do NOT start intervals or open sockets at module top level; keep them inside `startServer`. +- **assertTestingInternalsEnabled gating**: the guard is gated on `ACP_LINK_TEST_INTERNALS` and is called by every `__testing` method. Co-locate it with `__testing` in `testing-internals.ts` and preserve the gating behavior verbatim. +- **Biome lint surface**: 42 rules are disabled for decompiled code. Moving helpers like `optionalStringField` into their own module may surface `noUnusedVariables` if they are not re-exported. Export every helper that was previously file-local but is now cross-module, and run `bun run precheck` to catch new warnings. + +--- + +## Cross-cutting verification (run after ALL three phases) + +```bash +# 1. Full type + lint + test gate (REQUIRED zero errors per CLAUDE.md) +bun run precheck + +# 2. Targeted regression runs for the three refactored modules +bun test packages/acp-link/src/__tests__/server.test.ts +bun test src/services/acp/__tests__/bridge.test.ts +bun test src/services/acp/__tests__/agent.test.ts +bun test src/services/acp/__tests__/permissions.test.ts + +# 3. Build sanity (new chunks are produced for the new sub-files) +bun run build +ls dist/chunks | wc -l # expect a modest increase over the previous count + +# 4. Unused-export audit (catches accidentally-leaked internal exports) +bun run check:unused +``` + +## Acceptance criteria + +- [ ] `bun run precheck` passes with zero errors. +- [ ] All four target test files pass unmodified. +- [ ] `from '../server.js'`, `from '../bridge.js'`, `from '../agent.js'` all resolve correctly (verified by the passing tests). +- [ ] No new file exceeds 500 lines. +- [ ] `permissions.test.ts` snapshot of `require('../bridge.ts')` still matches the original 8-symbol public surface. +- [ ] `bun run build` succeeds with a sane chunk count. +- [ ] No test file is modified in the diff. diff --git a/packages/acp-link/src/server.ts b/packages/acp-link/src/server.ts index 5eb04d160..deffca275 100644 --- a/packages/acp-link/src/server.ts +++ b/packages/acp-link/src/server.ts @@ -1,1800 +1,20 @@ -import { spawn, type ChildProcess } from 'node:child_process' -import { createServer as createHttpsServer } from 'node:https' -import { Writable, Readable } from 'node:stream' -import * as acp from '@agentclientprotocol/sdk' -import { Hono } from 'hono' -import { serve } from '@hono/node-server' -import { createNodeWebSocket } from '@hono/node-ws' -import type { WSContext } from 'hono/ws' -import type { WebSocket as RawWebSocket } from 'ws' -import { createLogger } from './logger.js' -import { getOrCreateCertificate, getLanIPs } from './cert.js' -import { RcsUpstreamClient, type RcsUpstreamConfig } from './rcs-upstream.js' -import { - decodeJsonWsMessage, - isJsonRpc2Message, - WsPayloadTooLargeError, - type JsonRpc2ClientMessage, -} from './ws-message.js' -import { authTokensEqual, extractWebSocketAuthToken } from './ws-auth.js' - +/** + * Server module: ACP proxy server that bridges WebSocket/JSON-RPC clients to a + * spawned ACP agent child process. Implements both the legacy `{type, payload}` + * envelope and JSON-RPC 2.0 protocol surfaces. + * + * This file is the public entrypoint (barrel) re-exporting from the `./server/` + * sub-modules. The split keeps each sub-file under 500 lines while preserving + * the exact public API surface — server.test.ts imports every named export + * from this module, so DO NOT add internal-only exports here. + */ +export type { ServerConfig } from './server/types.js' export { MAX_CLIENT_WS_PAYLOAD_BYTES, isJsonRpc2Message, - type JsonRpc2ClientMessage, } from './ws-message.js' - -// JSON-RPC 2.0 reserved error codes (spec §5.1) -const JSONRPC_PARSE_ERROR = -32700 -const JSONRPC_INVALID_REQUEST = -32600 -const JSONRPC_METHOD_NOT_FOUND = -32601 -const JSONRPC_INVALID_PARAMS = -32602 -const JSONRPC_INTERNAL_ERROR = -32603 - -export interface ServerConfig { - port: number - host: string - command: string - args: string[] - cwd: string - debug?: boolean - token?: string - https?: boolean - /** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */ - permissionMode?: string - /** Channel group ID for RCS registration */ - group?: string -} - -// Pending permission request -interface PendingPermission { - resolve: ( - outcome: - | { outcome: 'cancelled' } - | { outcome: 'selected'; optionId: string }, - ) => void - timeout: ReturnType -} - -// PromptCapabilities from ACP protocol -// Reference: Zed's prompt_capabilities to check image support -interface PromptCapabilities { - audio?: boolean - embeddedContext?: boolean - image?: boolean -} - -// SessionModelState from ACP protocol -// Reference: Zed's AgentModelSelector reads from state.available_models -interface SessionModelState { - availableModels: Array<{ - modelId: string - name: string - description?: string | null - }> - currentModelId: string -} - -// AgentCapabilities from ACP protocol -// Reference: Zed's AcpConnection.agent_capabilities -// Matches SDK's AgentCapabilities exactly -interface AgentCapabilities { - _meta?: Record | null - loadSession?: boolean - mcpCapabilities?: { - _meta?: Record | null - clientServers?: boolean - } - promptCapabilities?: PromptCapabilities - sessionCapabilities?: { - _meta?: Record | null - fork?: Record | null - list?: Record | null - resume?: Record | null - } -} - -// Track connected clients and their agent connections -interface ClientState { - process: ChildProcess | null - connection: acp.ClientSideConnection | null - sessionId: string | null - pendingPermissions: Map - agentCapabilities: AgentCapabilities | null - promptCapabilities: PromptCapabilities | null - modelState: SessionModelState | null - isAlive: boolean - /** - * True when this client speaks JSON-RPC 2.0 (determined from the first - * framed message). When true, responses are emitted as JSON-RPC responses - * that preserve the request `id`; otherwise the legacy `{type, payload}` - * envelope is used for backwards compatibility. - */ - jsonRpc: boolean - /** - * Client-supplied identity and capabilities, captured from the JSON-RPC - * `initialize` request or legacy `connect` payload and forwarded to the - * agent instead of the hardcoded Zed fallback. See audit §8.7. - */ - clientInfo: { name: string; version: string } - clientCapabilities: Record - /** Negotiated ACP protocolVersion surfaced back to the client (audit §8.13). */ - protocolVersion: number | null - /** Agent identity from InitializeResult.agentInfo (audit §8.13). */ - agentInfo: { name: string; version: string; [k: string]: unknown } | null - /** - * Currently in-flight JSON-RPC request being serviced. The proxy echoes this - * id back in the JSON-RPC response (audit §8.2). At most one request is - * processed per client at a time because onMessage is awaited serially. - */ - pendingJsonRpc: { - id: string | number | null - /** Legacy response type the handler will emit via send(). */ - responseType: string - } | null -} - -// Default fallback client identity (used only when the client provides none) -const DEFAULT_CLIENT_INFO = Object.freeze({ name: 'zed', version: '1.0.0' }) -const DEFAULT_CLIENT_CAPABILITIES = Object.freeze({ - fs: { readTextFile: true, writeTextFile: true }, -}) - -/** - * Create a fresh ClientState with the default fallback client identity and - * capabilities. Used by every WebSocket open handler and the RCS relay. - */ -function createClientState(): ClientState { - return { - process: null, - connection: null, - sessionId: null, - pendingPermissions: new Map(), - agentCapabilities: null, - promptCapabilities: null, - modelState: null, - isAlive: true, - jsonRpc: false, - clientInfo: { ...DEFAULT_CLIENT_INFO }, - clientCapabilities: { ...DEFAULT_CLIENT_CAPABILITIES }, - protocolVersion: null, - agentInfo: null, - pendingJsonRpc: null, - } -} - -// Module-level state (set when server starts) -let AGENT_COMMAND: string -let AGENT_ARGS: string[] -let AGENT_CWD: string -let SERVER_PORT: number -let SERVER_HOST: string -let AUTH_TOKEN: string | undefined -let DEFAULT_PERMISSION_MODE: string | undefined - -const clients = new Map() - -// Module-scoped child loggers -const logWs = createLogger('ws') -const logAgent = createLogger('agent') -const logSession = createLogger('session') -const logPrompt = createLogger('prompt') -const logPerm = createLogger('perm') -const logRelay = createLogger('relay') -const logServer = createLogger('server') - -// RCS upstream client (optional — enabled via ACP_RCS_URL env var) -let rcsUpstream: RcsUpstreamClient | null = null - -/** - * Create a virtual WSContext for RCS relay messages. - * Responses via send() go to RCS upstream (not a local WS). - */ -function createRelayWs(): WSContext { - return { - get readyState() { - return 1 - }, // always OPEN - send: () => {}, // no-op — responses go through rcsUpstream.send() - close: () => {}, - raw: null, - isInner: false, - url: '', - origin: '', - protocol: '', - } as unknown as WSContext -} - -// Permission request timeout (5 minutes) -const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000 - -// Heartbeat interval for WebSocket ping/pong (30 seconds) -const HEARTBEAT_INTERVAL_MS = 30_000 - -// Generate unique request ID -function generateRequestId(): string { - return `perm_${Date.now()}_${Math.random().toString(36).slice(2, 11)}` -} - -// Maps legacy notification type strings to their JSON-RPC method names so -// agent→client notifications are also emitted as JSON-RPC notifications for -// JSON-RPC 2.0 clients (audit §8.1). Notifications have no id. -const LEGACY_NOTIFICATION_TO_JSONRPC: Record = { - session_update: 'session/update', - permission_request: 'session/request_permission', -} - -// Send a notification/response to the WebSocket client. -// -// For legacy `{type, payload}` clients this emits the proprietary envelope. -// For JSON-RPC 2.0 clients this additionally emits a JSON-RPC response that -// echoes the in-flight request id when the message type matches the pending -// request's expected response type (audit §8.2). Agent→client notifications -// (`session_update`, `permission_request`) are emitted as JSON-RPC -// notifications without an id. -function send(ws: WSContext, type: string, payload?: unknown): void { - if (ws.readyState === 1) { - // WebSocket.OPEN - ws.send(JSON.stringify({ type, payload })) - } - // Forward to RCS upstream if connected - if (rcsUpstream?.isRegistered()) { - rcsUpstream.send({ type, payload }) - } - - const state = clients.get(ws) - if (!state?.jsonRpc) return - - // If this is the response to an in-flight JSON-RPC request, emit the - // standard JSON-RPC result with the preserved id. - if (state.pendingJsonRpc?.responseType === type) { - sendJsonRpcRaw(ws, { - jsonrpc: '2.0', - id: state.pendingJsonRpc.id, - result: payload ?? {}, - }) - state.pendingJsonRpc = null - return - } - - // Agent→client notifications are also emitted as JSON-RPC notifications - // (no id) so JSON-RPC clients receive them in their native format. - const notificationMethod = LEGACY_NOTIFICATION_TO_JSONRPC[type] - if (notificationMethod) { - sendJsonRpcRaw(ws, { - jsonrpc: '2.0', - method: notificationMethod, - params: payload ?? {}, - }) - } -} - -// Serialize a JSON-RPC 2.0 message and send it to a connected WS client. -function sendJsonRpcRaw(ws: WSContext, message: object): void { - if (ws.readyState === 1) { - ws.send(JSON.stringify(message)) - } -} - -/** - * Send a JSON-RPC 2.0 error response with a reserved -32xxx code (audit §8.3). - * Also emits the legacy `{type: 'error', payload: {message}}` envelope for - * backwards compatibility. - */ -function sendJsonRpcError( - ws: WSContext, - state: ClientState | undefined, - id: string | number | null, - code: number, - message: string, -): void { - if (state?.jsonRpc) { - sendJsonRpcRaw(ws, { - jsonrpc: '2.0', - id, - error: { code, message }, - }) - } else { - send(ws, 'error', { message, code: String(code) }) - } - // Error consumed the in-flight request, if any. - if (state) state.pendingJsonRpc = null -} - -// Create a Client implementation that forwards events to WebSocket -function createClient(ws: WSContext, clientState: ClientState): acp.Client { - return { - async requestPermission(params) { - const requestId = generateRequestId() - logPerm.debug({ requestId, title: params.toolCall.title }, 'requested') - - const outcomePromise = new Promise< - { outcome: 'cancelled' } | { outcome: 'selected'; optionId: string } - >(resolve => { - const timeout = setTimeout(() => { - logPerm.warn({ requestId }, 'timed out') - clientState.pendingPermissions.delete(requestId) - resolve({ outcome: 'cancelled' }) - }, PERMISSION_TIMEOUT_MS) - - clientState.pendingPermissions.set(requestId, { resolve, timeout }) - }) - - send(ws, 'permission_request', { - requestId, - sessionId: params.sessionId, - options: params.options, - toolCall: params.toolCall, - }) - - const outcome = await outcomePromise - logPerm.debug({ requestId, outcome: outcome.outcome }, 'resolved') - - return { outcome } - }, - - async sessionUpdate(params) { - send(ws, 'session_update', params) - }, - - async readTextFile(params) { - logWs.debug({ path: params.path }, 'readTextFile') - return { content: '' } - }, - - async writeTextFile(params) { - logWs.debug({ path: params.path }, 'writeTextFile') - return {} - }, - } -} - -// Handle permission response from client -function handlePermissionResponse( - ws: WSContext, - payload: { - requestId: string - outcome: - | { outcome: 'cancelled' } - | { outcome: 'selected'; optionId: string } - }, -): void { - const state = clients.get(ws) - if (!state) { - logPerm.warn('response from unknown client') - return - } - - const pending = state.pendingPermissions.get(payload.requestId) - if (!pending) { - logPerm.warn( - { requestId: payload.requestId }, - 'response for unknown request', - ) - return - } - - clearTimeout(pending.timeout) - state.pendingPermissions.delete(payload.requestId) - pending.resolve(payload.outcome) -} - -// Cancel all pending permissions for a client (called on disconnect) -function cancelPendingPermissions(clientState: ClientState): void { - for (const [requestId, pending] of clientState.pendingPermissions) { - logPerm.debug({ requestId }, 'cancelled on disconnect') - clearTimeout(pending.timeout) - pending.resolve({ outcome: 'cancelled' }) - } - clientState.pendingPermissions.clear() -} - -async function handleConnect(ws: WSContext): Promise { - const state = clients.get(ws) - if (!state) return - - // If already connected to a running agent, just resend status - // This handles frontend reconnections without restarting the agent process - // Check both .killed and .exitCode to detect crashed processes - if ( - state.connection && - state.process && - !state.process.killed && - state.process.exitCode === null - ) { - logAgent.info('already connected, resending status') - send(ws, 'status', { - connected: true, - agentInfo: state.agentInfo ?? { name: AGENT_COMMAND }, - capabilities: state.agentCapabilities, - protocolVersion: state.protocolVersion, - }) - return - } - - // Kill existing process if any (only if not healthy) - if (state.process) { - cancelPendingPermissions(state) - state.process.kill() - state.process = null - state.connection = null - } - - try { - logAgent.info({ command: AGENT_COMMAND, args: AGENT_ARGS }, 'spawning') - - const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, { - cwd: AGENT_CWD, - stdio: ['pipe', 'pipe', 'inherit'], - env: buildAgentEnv(), - }) - - state.process = agentProcess - - // Clean up state when agent process exits unexpectedly - agentProcess.on('exit', code => { - logAgent.info({ exitCode: code }, 'agent process exited') - // Only clear if this is still the current process - if (state.process === agentProcess) { - state.process = null - state.connection = null - state.sessionId = null - } - }) - - const input = Writable.toWeb( - agentProcess.stdin!, - ) as unknown as WritableStream - const output = Readable.toWeb( - agentProcess.stdout!, - ) as unknown as ReadableStream - - const stream = acp.ndJsonStream(input, output) - const connection = new acp.ClientSideConnection( - _agent => createClient(ws, state), - stream, - ) - - state.connection = connection - - const initResult = await connection.initialize({ - protocolVersion: acp.PROTOCOL_VERSION, - // Forward the real client identity/capabilities (audit §8.7). Falls back - // to the Zed defaults only when the client did not provide any. - clientInfo: state.clientInfo, - clientCapabilities: state.clientCapabilities, - }) - - // Pass the raw agentCapabilities through unchanged so present and future - // capability fields (auth, terminal, ...) reach the client (audit §8.6). - const agentCaps = initResult.agentCapabilities - state.agentCapabilities = (agentCaps as AgentCapabilities | null) ?? null - state.promptCapabilities = agentCaps?.promptCapabilities ?? null - // Remember the negotiated protocolVersion + agentInfo so reconnects and - // JSON-RPC initialize responses can forward them to the client (§8.13). - state.protocolVersion = initResult.protocolVersion - state.agentInfo = - (initResult.agentInfo as ClientState['agentInfo'] | null | undefined) ?? - null - - logAgent.info( - { - protocolVersion: initResult.protocolVersion, - loadSession: !!state.agentCapabilities?.loadSession, - sessionList: !!state.agentCapabilities?.sessionCapabilities?.list, - sessionResume: !!state.agentCapabilities?.sessionCapabilities?.resume, - hasMcp: !!state.agentCapabilities?.mcpCapabilities, - }, - 'initialized', - ) - - send(ws, 'status', { - connected: true, - agentInfo: initResult.agentInfo, - capabilities: state.agentCapabilities, - // Surface the negotiated protocolVersion to downstream clients (audit §8.13). - protocolVersion: initResult.protocolVersion, - }) - - connection.closed.then(() => { - logAgent.info('connection closed') - state.connection = null - state.sessionId = null - send(ws, 'status', { connected: false }) - }) - } catch (error) { - logAgent.error({ error: (error as Error).message }, 'connect failed') - sendJsonRpcError( - ws, - state, - null, - JSONRPC_INTERNAL_ERROR, - `Failed to connect: ${(error as Error).message}`, - ) - } -} - -async function handleNewSession( - ws: WSContext, - params: { cwd?: string; permissionMode?: string }, -): Promise { - const state = clients.get(ws) - if (!state?.connection) { - logAgent.warn( - { - hasState: !!state, - hasProcess: !!state?.process, - processKilled: state?.process?.killed, - exitCode: state?.process?.exitCode, - }, - 'handleNewSession: not connected to agent', - ) - sendJsonRpcError( - ws, - state, - state?.pendingJsonRpc?.id ?? null, - JSONRPC_INVALID_REQUEST, - 'Not connected to agent', - ) - return - } - - try { - const sessionCwd = params.cwd || AGENT_CWD - let permissionMode: string | undefined - try { - permissionMode = resolveNewSessionPermissionMode( - params.permissionMode, - DEFAULT_PERMISSION_MODE, - ) - } catch (error) { - sendJsonRpcError( - ws, - state, - state.pendingJsonRpc?.id ?? null, - JSONRPC_INVALID_PARAMS, - (error as Error).message, - ) - return - } - const result = await state.connection.newSession({ - cwd: sessionCwd, - mcpServers: [], - ...(permissionMode ? { _meta: { permissionMode } } : {}), - }) - - state.sessionId = result.sessionId - state.modelState = result.models ?? null - logSession.info( - { - sessionId: result.sessionId, - cwd: sessionCwd, - hasModels: !!result.models, - }, - 'created', - ) - - send(ws, 'session_created', { - ...result, - promptCapabilities: state.promptCapabilities, - models: state.modelState, - }) - } catch (error) { - logSession.error({ error: (error as Error).message }, 'create failed') - sendJsonRpcError( - ws, - state, - state.pendingJsonRpc?.id ?? null, - JSONRPC_INTERNAL_ERROR, - `Failed to create session: ${(error as Error).message}`, - ) - } -} - -// ============================================================================ -// Session History Operations -// Reference: Zed's AgentConnection trait - list_sessions, load_session, resume_session -// ============================================================================ - -async function handleListSessions( - ws: WSContext, - params: { cwd?: string; cursor?: string }, -): Promise { - const state = clients.get(ws) - if (!state?.connection) { - logAgent.warn( - { - hasState: !!state, - hasProcess: !!state?.process, - processKilled: state?.process?.killed, - exitCode: state?.process?.exitCode, - }, - 'handleListSessions: not connected to agent', - ) - sendJsonRpcError( - ws, - state, - state?.pendingJsonRpc?.id ?? null, - JSONRPC_INVALID_REQUEST, - 'Not connected to agent', - ) - return - } - - if (!state.agentCapabilities?.sessionCapabilities?.list) { - sendJsonRpcError( - ws, - state, - state.pendingJsonRpc?.id ?? null, - JSONRPC_METHOD_NOT_FOUND, - 'Listing sessions is not supported by this agent', - ) - return - } - - try { - const result = await state.connection.listSessions({ - cwd: params.cwd, - cursor: params.cursor, - }) - - const MAX_SESSIONS = 20 - const sessions = result.sessions.slice(0, MAX_SESSIONS) - logSession.info( - { - total: result.sessions.length, - returned: sessions.length, - hasMore: !!result.nextCursor, - }, - 'listed', - ) - - send(ws, 'session_list', { - sessions: sessions.map((s: acp.SessionInfo) => ({ - _meta: s._meta, - cwd: s.cwd, - sessionId: s.sessionId, - title: s.title, - updatedAt: s.updatedAt, - })), - nextCursor: result.nextCursor, - _meta: result._meta, - }) - } catch (error) { - logSession.error({ error: (error as Error).message }, 'list failed') - sendJsonRpcError( - ws, - state, - state.pendingJsonRpc?.id ?? null, - JSONRPC_INTERNAL_ERROR, - `Failed to list sessions: ${(error as Error).message}`, - ) - } -} - -async function handleLoadSession( - ws: WSContext, - params: { sessionId: string; cwd?: string }, -): Promise { - const state = clients.get(ws) - if (!state?.connection) { - logAgent.warn( - { - hasState: !!state, - hasProcess: !!state?.process, - processKilled: state?.process?.killed, - exitCode: state?.process?.exitCode, - }, - 'handleLoadSession: not connected to agent', - ) - sendJsonRpcError( - ws, - state, - state?.pendingJsonRpc?.id ?? null, - JSONRPC_INVALID_REQUEST, - 'Not connected to agent', - ) - return - } - - if (!state.agentCapabilities?.loadSession) { - sendJsonRpcError( - ws, - state, - state.pendingJsonRpc?.id ?? null, - JSONRPC_METHOD_NOT_FOUND, - 'Loading sessions is not supported by this agent', - ) - return - } - - try { - const sessionCwd = params.cwd || AGENT_CWD - const sessionId = params.sessionId - const result = await state.connection.loadSession({ - sessionId, - cwd: sessionCwd, - mcpServers: [], - }) - - state.sessionId = sessionId - state.modelState = result.models ?? null - logSession.info({ sessionId, cwd: sessionCwd }, 'loaded') - - send(ws, 'session_loaded', { - sessionId, - promptCapabilities: state.promptCapabilities, - models: state.modelState, - }) - } catch (error) { - logSession.error({ error: (error as Error).message }, 'load failed') - sendJsonRpcError( - ws, - state, - state.pendingJsonRpc?.id ?? null, - JSONRPC_INTERNAL_ERROR, - `Failed to load session: ${(error as Error).message}`, - ) - } -} - -async function handleResumeSession( - ws: WSContext, - params: { sessionId: string; cwd?: string }, -): Promise { - const state = clients.get(ws) - if (!state?.connection) { - logAgent.warn( - { - hasState: !!state, - hasProcess: !!state?.process, - processKilled: state?.process?.killed, - exitCode: state?.process?.exitCode, - }, - 'handleResumeSession: not connected to agent', - ) - sendJsonRpcError( - ws, - state, - state?.pendingJsonRpc?.id ?? null, - JSONRPC_INVALID_REQUEST, - 'Not connected to agent', - ) - return - } - - if (!state.agentCapabilities?.sessionCapabilities?.resume) { - sendJsonRpcError( - ws, - state, - state.pendingJsonRpc?.id ?? null, - JSONRPC_METHOD_NOT_FOUND, - 'Resuming sessions is not supported by this agent', - ) - return - } - - try { - const sessionCwd = params.cwd || AGENT_CWD - const sessionId = params.sessionId - const result = await state.connection.unstable_resumeSession({ - sessionId, - cwd: sessionCwd, - }) - - state.sessionId = sessionId - state.modelState = result.models ?? null - logSession.info({ sessionId, cwd: sessionCwd }, 'resumed') - - send(ws, 'session_resumed', { - sessionId, - promptCapabilities: state.promptCapabilities, - models: state.modelState, - }) - } catch (error) { - logSession.error({ error: (error as Error).message }, 'resume failed') - sendJsonRpcError( - ws, - state, - state.pendingJsonRpc?.id ?? null, - JSONRPC_INTERNAL_ERROR, - `Failed to resume session: ${(error as Error).message}`, - ) - } -} - -// Reference: Zed's AcpThread.send() forwards Vec to agent -async function handlePrompt( - ws: WSContext, - params: { content: ContentBlock[] }, -): Promise { - const state = clients.get(ws) - if (!state?.connection || !state.sessionId) { - sendJsonRpcError( - ws, - state, - state?.pendingJsonRpc?.id ?? null, - JSONRPC_INVALID_REQUEST, - 'No active session', - ) - return - } - - try { - const firstText = params.content.find(b => b.type === 'text')?.text - const images = params.content.filter(b => b.type === 'image') - logPrompt.debug( - { - text: firstText?.slice(0, 100), - imageCount: images.length, - blockCount: params.content.length, - }, - 'sending', - ) - - const result = await state.connection.prompt({ - sessionId: state.sessionId, - prompt: params.content as acp.ContentBlock[], - }) - - logPrompt.info({ stopReason: result.stopReason }, 'completed') - send(ws, 'prompt_complete', result) - } catch (error) { - logPrompt.error({ error: (error as Error).message }, 'failed') - sendJsonRpcError( - ws, - state, - state.pendingJsonRpc?.id ?? null, - JSONRPC_INTERNAL_ERROR, - `Prompt failed: ${(error as Error).message}`, - ) - } -} - -function handleDisconnect(ws: WSContext): void { - const state = clients.get(ws) - if (!state) return - - if (state.process) { - state.process.kill() - state.process = null - } - state.connection = null - state.sessionId = null - - send(ws, 'status', { connected: false }) -} - -// Handle cancel request from client -async function handleCancel(ws: WSContext): Promise { - const state = clients.get(ws) - if (!state?.connection || !state.sessionId) { - logWs.warn('cancel requested but no active session') - return - } - - logSession.info({ sessionId: state.sessionId }, 'cancel requested') - cancelPendingPermissions(state) - - try { - await state.connection.cancel({ sessionId: state.sessionId }) - logSession.info({ sessionId: state.sessionId }, 'cancel sent') - } catch (error) { - logSession.error({ error: (error as Error).message }, 'cancel failed') - } -} - -// Reference: Zed's AgentModelSelector.select_model() calls connection.set_session_model() -async function handleSetSessionModel( - ws: WSContext, - params: { modelId: string }, -): Promise { - const state = clients.get(ws) - if (!state?.connection || !state.sessionId) { - sendJsonRpcError( - ws, - state, - state?.pendingJsonRpc?.id ?? null, - JSONRPC_INVALID_REQUEST, - 'No active session', - ) - return - } - - if (!state.modelState) { - sendJsonRpcError( - ws, - state, - state.pendingJsonRpc?.id ?? null, - JSONRPC_METHOD_NOT_FOUND, - 'Model selection not supported by this agent', - ) - return - } - - try { - logSession.info( - { sessionId: state.sessionId, modelId: params.modelId }, - 'setting model', - ) - await state.connection.unstable_setSessionModel({ - sessionId: state.sessionId, - modelId: params.modelId, - }) - state.modelState = { ...state.modelState, currentModelId: params.modelId } - send(ws, 'model_changed', { modelId: params.modelId }) - logSession.info({ modelId: params.modelId }, 'model changed') - } catch (error) { - logSession.error({ error: (error as Error).message }, 'set model failed') - sendJsonRpcError( - ws, - state, - state.pendingJsonRpc?.id ?? null, - JSONRPC_INTERNAL_ERROR, - `Failed to set model: ${(error as Error).message}`, - ) - } -} - -// ContentBlock type matching @agentclientprotocol/sdk -interface ContentBlock { - type: string - text?: string - data?: string - mimeType?: string - uri?: string - name?: string -} - -type PermissionResponsePayload = { - requestId: string - outcome: { outcome: 'cancelled' } | { outcome: 'selected'; optionId: string } -} - -type ProxyMessage = - | { type: 'connect' } - | { type: 'disconnect' } - | { type: 'new_session'; payload: { cwd?: string; permissionMode?: string } } - | { type: 'prompt'; payload: { content: ContentBlock[] } } - | { type: 'permission_response'; payload: PermissionResponsePayload } - | { type: 'cancel' } - | { type: 'set_session_model'; payload: { modelId: string } } - | { type: 'list_sessions'; payload: { cwd?: string; cursor?: string } } - | { type: 'load_session'; payload: { sessionId: string; cwd?: string } } - | { type: 'resume_session'; payload: { sessionId: string; cwd?: string } } - | { type: 'ping' } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function optionalString(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined -} - -function optionalStringField( - payload: Record, - key: string, - source: string, -): string | undefined { - if (!Object.hasOwn(payload, key)) return undefined - const value = payload[key] - if (typeof value === 'string') return value - throw new Error(`Invalid ${source}: expected a string`) -} - -function payloadRecord(value: unknown, type: string): Record { - if (!isRecord(value)) { - throw new Error(`Invalid ${type} payload`) - } - return value -} - -function optionalPayloadRecord( - value: unknown, - type: string, -): Record { - if (value === undefined) return {} - return payloadRecord(value, type) -} - -function optionalRecord(value: unknown): Record { - return isRecord(value) ? value : {} -} - -function decodeContentBlocks(value: unknown): ContentBlock[] { - if ( - !Array.isArray(value) || - !value.every(block => isRecord(block) && typeof block.type === 'string') - ) { - throw new Error('Invalid prompt payload') - } - return value as ContentBlock[] -} - -function decodePermissionResponsePayload( - value: unknown, -): PermissionResponsePayload { - const payload = payloadRecord(value, 'permission_response') - if (typeof payload.requestId !== 'string' || !isRecord(payload.outcome)) { - throw new Error('Invalid permission_response payload') - } - if (payload.outcome.outcome === 'cancelled') { - return { requestId: payload.requestId, outcome: { outcome: 'cancelled' } } - } - if ( - payload.outcome.outcome === 'selected' && - typeof payload.outcome.optionId === 'string' - ) { - return { - requestId: payload.requestId, - outcome: { outcome: 'selected', optionId: payload.outcome.optionId }, - } - } - throw new Error('Invalid permission_response payload') -} - -function decodeClientMessage(message: Record): ProxyMessage { - if (typeof message.type !== 'string') { - throw new Error('Invalid WebSocket message payload') - } - - switch (message.type) { - case 'connect': - case 'disconnect': - case 'cancel': - case 'ping': - return { type: message.type } - case 'new_session': { - const payload = optionalPayloadRecord(message.payload, 'new_session') - return { - type: 'new_session', - payload: { - cwd: optionalStringField(payload, 'cwd', 'new_session.cwd'), - permissionMode: optionalStringField( - payload, - 'permissionMode', - 'new_session.permissionMode', - ), - }, - } - } - case 'prompt': { - const payload = payloadRecord(message.payload, 'prompt') - return { - type: 'prompt', - payload: { content: decodeContentBlocks(payload.content) }, - } - } - case 'permission_response': - return { - type: 'permission_response', - payload: decodePermissionResponsePayload(message.payload), - } - case 'set_session_model': { - const payload = payloadRecord(message.payload, 'set_session_model') - if (typeof payload.modelId !== 'string') { - throw new Error('Invalid set_session_model payload') - } - return { - type: 'set_session_model', - payload: { modelId: payload.modelId }, - } - } - case 'list_sessions': { - const payload = optionalRecord(message.payload) - return { - type: 'list_sessions', - payload: { - cwd: optionalString(payload.cwd), - cursor: optionalString(payload.cursor), - }, - } - } - case 'load_session': - case 'resume_session': { - const payload = payloadRecord(message.payload, message.type) - if (typeof payload.sessionId !== 'string') { - throw new Error(`Invalid ${message.type} payload`) - } - return { - type: message.type, - payload: { - sessionId: payload.sessionId, - cwd: optionalString(payload.cwd), - }, - } - } - default: - throw new Error(`Unknown message type: ${message.type}`) - } -} - -export function decodeClientWsMessage(data: unknown): ProxyMessage { - return decodeClientMessage(decodeJsonWsMessage(data)) -} - -async function dispatchClientMessage( - ws: WSContext, - data: ProxyMessage, -): Promise { - switch (data.type) { - case 'connect': - await handleConnect(ws) - break - case 'disconnect': - handleDisconnect(ws) - break - case 'new_session': - await handleNewSession(ws, data.payload) - break - case 'prompt': - await handlePrompt(ws, data.payload) - break - case 'permission_response': - handlePermissionResponse(ws, data.payload) - break - case 'cancel': - await handleCancel(ws) - break - case 'set_session_model': - await handleSetSessionModel(ws, data.payload) - break - case 'list_sessions': - await handleListSessions(ws, data.payload) - break - case 'load_session': - await handleLoadSession(ws, data.payload) - break - case 'resume_session': - await handleResumeSession(ws, data.payload) - break - case 'ping': - send(ws, 'pong') - break - } -} - -/** - * Maps JSON-RPC method names to their legacy handler + the legacy response - * type the handler emits via send(). Used by dispatchJsonRpcMessage to route - * standard ACP methods (audit §8.1, §8.4). - */ -const JSONRPC_METHOD_HANDLERS: Record< - string, - { - responseType: string - handle: (ws: WSContext, params: unknown) => Promise | void - } -> = { - initialize: { responseType: 'status', handle: handleConnect }, - 'session/new': { - responseType: 'session_created', - handle: handleJsonRpcNewSession, - }, - 'session/prompt': { - responseType: 'prompt_complete', - handle: handleJsonRpcPrompt, - }, - 'session/cancel': { responseType: '', handle: handleCancel }, - 'session/list': { - responseType: 'session_list', - handle: handleJsonRpcListSessions, - }, - 'session/load': { - responseType: 'session_loaded', - handle: handleJsonRpcLoadSession, - }, - 'session/resume': { - responseType: 'session_resumed', - handle: handleJsonRpcResumeSession, - }, - 'session/set_model': { - responseType: 'model_changed', - handle: handleJsonRpcSetSessionModel, - }, - 'session/set_mode': { - responseType: 'session_mode_set', - handle: handleJsonRpcSetSessionMode, - }, - 'session/close': { - responseType: 'session_closed', - handle: handleJsonRpcCloseSession, - }, -} - -// JSON-RPC method wrappers that accept `params: unknown` and forward to the -// existing handlers with the decoded payload. -async function handleJsonRpcNewSession( - ws: WSContext, - params: unknown, -): Promise { - const payload = optionalPayloadRecord(params, 'session/new') - await handleNewSession(ws, { - cwd: optionalStringField(payload, 'cwd', 'session/new.cwd'), - permissionMode: optionalStringField( - payload, - 'permissionMode', - 'session/new.permissionMode', - ), - }) -} - -async function handleJsonRpcPrompt( - ws: WSContext, - params: unknown, -): Promise { - const payload = payloadRecord(params, 'session/prompt') - // ACP session/prompt params: { sessionId, prompt: ContentBlock[] } - // Accept either `prompt` (spec) or `content` (legacy) for compatibility. - const content = payload.prompt ?? payload.content - await handlePrompt(ws, { content: decodeContentBlocks(content) }) -} - -async function handleJsonRpcListSessions( - ws: WSContext, - params: unknown, -): Promise { - const payload = optionalRecord(params) - await handleListSessions(ws, { - cwd: optionalString(payload.cwd), - cursor: optionalString(payload.cursor), - }) -} - -async function handleJsonRpcLoadSession( - ws: WSContext, - params: unknown, -): Promise { - const payload = payloadRecord(params, 'session/load') - if (typeof payload.sessionId !== 'string') { - throw new Error('Invalid session/load payload') - } - await handleLoadSession(ws, { - sessionId: payload.sessionId, - cwd: optionalString(payload.cwd), - }) -} - -async function handleJsonRpcResumeSession( - ws: WSContext, - params: unknown, -): Promise { - const payload = payloadRecord(params, 'session/resume') - if (typeof payload.sessionId !== 'string') { - throw new Error('Invalid session/resume payload') - } - await handleResumeSession(ws, { - sessionId: payload.sessionId, - cwd: optionalString(payload.cwd), - }) -} - -async function handleJsonRpcSetSessionModel( - ws: WSContext, - params: unknown, -): Promise { - const payload = payloadRecord(params, 'session/set_model') - if (typeof payload.modelId !== 'string') { - throw new Error('Invalid session/set_model payload') - } - await handleSetSessionModel(ws, { modelId: payload.modelId }) -} - -/** - * Pass-through handlers for v1 baseline methods that the proprietary - * whitelist previously dropped (audit §8.4). They forward the call to the - * underlying SDK ClientSideConnection and surface the result. - */ -async function handleJsonRpcSetSessionMode( - ws: WSContext, - params: unknown, -): Promise { - const state = clients.get(ws) - if (!state?.connection) { - throw new Error('Not connected to agent') - } - const result = await state.connection.setSessionMode( - params as { sessionId: string; modeId: string }, - ) - send(ws, 'session_mode_set', result ?? {}) -} - -async function handleJsonRpcCloseSession( - ws: WSContext, - params: unknown, -): Promise { - const state = clients.get(ws) - if (!state?.connection) { - throw new Error('Not connected to agent') - } - const result = await state.connection.unstable_closeSession( - params as { sessionId: string }, - ) - send(ws, 'session_closed', result ?? {}) -} - -/** - * Handle the JSON-RPC standard cancellation primitive `$/cancel_request` - * (audit §8.5). Unlike the ACP-specific `session/cancel` notification, this - * cancels an in-flight request by id. We forward to the ACP cancel path and - * also clear any pending permission request. - */ -async function handleJsonRpcCancelRequest( - ws: WSContext, - params: unknown, -): Promise { - const payload = optionalRecord(params) - logWs.info({ cancelledId: payload.id }, '$/cancel_request received') - await handleCancel(ws) -} - -/** - * Route a JSON-RPC 2.0 message. Requests get a response with the echoed id; - * notifications (no id) are dispatched without a response. Unknown methods - * yield a JSON-RPC -32601 error (audit §8.4). `$/cancel_request` is handled - * specially (audit §8.5). - */ -async function dispatchJsonRpcMessage( - ws: WSContext, - msg: JsonRpc2ClientMessage, -): Promise { - const state = clients.get(ws) - // Mark this client as JSON-RPC from the first framed message. - if (state) state.jsonRpc = true - - // Capture client identity/capabilities from initialize (audit §8.7). - if (msg.method === 'initialize' && state) { - const params = isRecord(msg.params) ? msg.params : {} - if (isRecord(params.clientInfo)) { - const ci = params.clientInfo - if (typeof ci.name === 'string' && typeof ci.version === 'string') { - state.clientInfo = { name: ci.name, version: ci.version } - } - } - if (isRecord(params.clientCapabilities)) { - state.clientCapabilities = params.clientCapabilities - } - } - - // Notification (no id) — dispatch without a response. - if (!('id' in msg) || msg.id === undefined) { - if (msg.method === '$/cancel_request') { - await handleJsonRpcCancelRequest(ws, msg.params) - return - } - if (msg.method === 'session/cancel') { - await handleCancel(ws) - return - } - // Unknown notification — silently ignore per JSON-RPC 2.0 (notifications - // cannot be responded to). - logWs.debug({ method: msg.method }, 'ignoring unknown notification') - return - } - - // Request (has id) — dispatch and the handler will emit a response. - if (msg.method === '$/cancel_request') { - await handleJsonRpcCancelRequest(ws, msg.params) - // Cancellation is itself a notification-style request; respond with null. - if (state) state.pendingJsonRpc = { id: msg.id, responseType: '' } - sendJsonRpcRaw(ws, { jsonrpc: '2.0', id: msg.id, result: null }) - if (state) state.pendingJsonRpc = null - return - } - - const entry = JSONRPC_METHOD_HANDLERS[msg.method] - if (!entry) { - sendJsonRpcError( - ws, - state, - msg.id, - JSONRPC_METHOD_NOT_FOUND, - `Method not found: ${msg.method}`, - ) - return - } - - // Track the in-flight request so the handler's send() emits a JSON-RPC - // response with the echoed id (audit §8.2). - if (state) - state.pendingJsonRpc = { id: msg.id, responseType: entry.responseType } - try { - await entry.handle(ws, msg.params) - // If the handler did not emit the expected response (e.g. it short - // circuited with an error already), still clear the pending slot. - if (state?.pendingJsonRpc) { - sendJsonRpcRaw(ws, { - jsonrpc: '2.0', - id: msg.id, - result: {}, - }) - state.pendingJsonRpc = null - } - } catch (error) { - const code = (error as Error).message.startsWith('Invalid ') - ? JSONRPC_INVALID_PARAMS - : JSONRPC_INTERNAL_ERROR - sendJsonRpcError(ws, state, msg.id, code, (error as Error).message) - } -} - -export const __testing = { - dispatchClientMessage(ws: WSContext, data: unknown): Promise { - assertTestingInternalsEnabled() - return dispatchClientMessage(ws, data as ProxyMessage) - }, - dispatchJsonRpcMessage(ws: WSContext, data: unknown): Promise { - assertTestingInternalsEnabled() - return dispatchJsonRpcMessage(ws, data as JsonRpc2ClientMessage) - }, - registerClient( - ws: WSContext, - state: { - connection?: unknown - process?: ChildProcess | null - sessionId?: string | null - clientInfo?: { name: string; version: string } - clientCapabilities?: Record - jsonRpc?: boolean - }, - ): () => void { - assertTestingInternalsEnabled() - const full = createClientState() - full.process = state.process ?? null - full.connection = (state.connection ?? - null) as acp.ClientSideConnection | null - full.sessionId = state.sessionId ?? null - if (state.clientInfo) full.clientInfo = state.clientInfo - if (state.clientCapabilities) - full.clientCapabilities = state.clientCapabilities - if (typeof state.jsonRpc === 'boolean') full.jsonRpc = state.jsonRpc - clients.set(ws, full) - return () => { - clients.delete(ws) - } - }, - getClientSessionId(ws: WSContext): string | null | undefined { - assertTestingInternalsEnabled() - return clients.get(ws)?.sessionId - }, - setDefaultPermissionMode(mode: string | undefined): () => void { - assertTestingInternalsEnabled() - const previous = DEFAULT_PERMISSION_MODE - DEFAULT_PERMISSION_MODE = mode - return () => { - DEFAULT_PERMISSION_MODE = previous - } - }, -} - -function assertTestingInternalsEnabled(): void { - if (process.env.ACP_LINK_TEST_INTERNALS === '1') { - return - } - - throw new Error( - 'acp-link test internals are disabled outside test execution.', - ) -} - -const ACP_LINK_PERMISSION_MODE_ALIASES = { - auto: 'auto', - default: 'default', - acceptedits: 'acceptEdits', - dontask: 'dontAsk', - plan: 'plan', - bypasspermissions: 'bypassPermissions', - bypass: 'bypassPermissions', -} as const - -type AcpLinkPermissionMode = - (typeof ACP_LINK_PERMISSION_MODE_ALIASES)[keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES] - -export function resolveNewSessionPermissionMode( - requestedMode: string | undefined, - defaultMode: string | undefined, -): string | undefined { - const requested = resolveAcpLinkPermissionMode(requestedMode) - const localDefault = resolveAcpLinkPermissionMode(defaultMode) - - if (!requested) { - return localDefault - } - - if (requested !== 'bypassPermissions') { - return requested - } - - if (localDefault === 'bypassPermissions') { - return 'bypassPermissions' - } - - throw new Error( - 'bypassPermissions requires local ACP_PERMISSION_MODE=bypassPermissions before a client can request it.', - ) -} - -function resolveAcpLinkPermissionMode( - mode: string | undefined, -): AcpLinkPermissionMode | undefined { - if (mode === undefined) return undefined - - const normalized = mode?.trim().toLowerCase() - if (!normalized) { - throw new Error('Invalid permissionMode: expected a non-empty string.') - } - - const resolved = - ACP_LINK_PERMISSION_MODE_ALIASES[ - normalized as keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES - ] - if (!resolved) { - throw new Error(`Invalid permissionMode: ${mode}.`) - } - - return resolved -} - -function buildAgentEnv(): NodeJS.ProcessEnv { - if (!DEFAULT_PERMISSION_MODE) { - return process.env - } - - return { - ...process.env, - ACP_PERMISSION_MODE: DEFAULT_PERMISSION_MODE, - } -} - -export async function startServer(config: ServerConfig): Promise { - const { port, host, command, args, cwd, token, https } = config - - // Set module-level config - AGENT_COMMAND = command - AGENT_ARGS = args - AGENT_CWD = cwd - SERVER_PORT = port - SERVER_HOST = host - AUTH_TOKEN = token - DEFAULT_PERMISSION_MODE = - config.permissionMode || process.env.ACP_PERMISSION_MODE - - // Initialize RCS upstream client if configured - const rcsUrl = process.env.ACP_RCS_URL - const rcsToken = process.env.ACP_RCS_TOKEN - const rcsGroup = config.group || process.env.ACP_RCS_GROUP - if (rcsGroup && !/^[a-zA-Z0-9_-]+$/.test(rcsGroup)) { - throw new Error( - `Invalid ACP_RCS_GROUP "${rcsGroup}": only letters, digits, hyphens, and underscores are allowed`, - ) - } - if (rcsUrl) { - rcsUpstream = new RcsUpstreamClient({ - rcsUrl, - apiToken: rcsToken || '', - agentName: command, - channelGroupId: rcsGroup || undefined, - maxSessions: 1, - }) - - const relayWs = createRelayWs() - const relayState = createClientState() - clients.set(relayWs, relayState) - - rcsUpstream.setMessageHandler(async msg => { - try { - // The RCS relay forwards messages from the Web UI. Accept both - // JSON-RPC 2.0 (audit §8.12) and the legacy `{type, payload}` envelope. - if (isJsonRpc2Message(msg)) { - logRelay.debug({ method: msg.method }, 'processing jsonrpc') - await dispatchJsonRpcMessage(relayWs, msg) - } else { - const data = decodeClientMessage(msg) - logRelay.debug({ type: data.type }, 'processing') - await dispatchClientMessage(relayWs, data) - } - } catch (error) { - logRelay.error({ error: (error as Error).message }, 'handler error') - } - }) - - rcsUpstream.connect().catch(err => { - logRelay.warn( - { error: (err as Error).message }, - 'initial connection failed', - ) - }) - logRelay.info({ url: rcsUrl }, 'upstream enabled') - } - - const app = new Hono() - const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }) - - // Health check endpoint - app.get('/health', c => { - return c.json({ status: 'ok' }) - }) - - // WebSocket endpoint with token validation - app.get( - '/ws', - upgradeWebSocket(c => { - if (AUTH_TOKEN) { - const providedToken = extractWebSocketAuthToken({ - authorization: c.req.header('Authorization'), - protocol: c.req.header('Sec-WebSocket-Protocol'), - }) - if (!authTokensEqual(providedToken, AUTH_TOKEN)) { - logWs.warn('connection rejected: invalid token') - return { - onOpen(_event, ws) { - ws.close(4001, 'Unauthorized: Invalid token') - }, - onMessage() {}, - onClose() {}, - } - } - } - - return { - onOpen(_event, ws) { - logWs.info('client connected') - const state = createClientState() - clients.set(ws, state) - - const rawWs = ws.raw as RawWebSocket - rawWs.on('pong', () => { - state.isAlive = true - }) - }, - async onMessage(event, ws) { - try { - // Decode the raw frame once. JSON-RPC 2.0 messages are routed by - // method name (audit §8.1, §8.4, §8.5); legacy `{type, payload}` - // messages keep the existing dispatch path for backwards compat. - const decoded = decodeJsonWsMessage(event.data) - if (isJsonRpc2Message(decoded)) { - logWs.debug({ method: decoded.method }, 'received jsonrpc') - await dispatchJsonRpcMessage(ws, decoded) - } else { - const data = decodeClientMessage(decoded) - logWs.debug({ type: data.type }, 'received') - await dispatchClientMessage(ws, data) - } - } catch (error) { - if (error instanceof WsPayloadTooLargeError) { - logWs.warn({ error: error.message }, 'message too large') - ws.close(1009, 'message too large') - return - } - logWs.error({ error: (error as Error).message }, 'message error') - const state = clients.get(ws) - sendJsonRpcError( - ws, - state, - state?.pendingJsonRpc?.id ?? null, - JSONRPC_PARSE_ERROR, - `Error: ${(error as Error).message}`, - ) - } - }, - onClose(_event, ws) { - logWs.info('client disconnected') - const state = clients.get(ws) - if (state) { - cancelPendingPermissions(state) - } - handleDisconnect(ws) - clients.delete(ws) - }, - } - }), - ) - - // Create server with optional HTTPS - let server - if (https) { - const tlsOptions = await getOrCreateCertificate() - server = serve({ - fetch: app.fetch, - port, - hostname: host, - createServer: createHttpsServer, - serverOptions: tlsOptions, - }) - } else { - server = serve({ fetch: app.fetch, port, hostname: host }) - } - injectWebSocket(server) - - // Heartbeat: periodically ping all connected clients - setInterval(() => { - for (const [ws, state] of clients) { - // Skip virtual relay connections (no raw socket, always alive) - if (!ws.raw && state.isAlive) continue - if (!ws.raw) { - // Connection already closed, clean up - clients.delete(ws) - continue - } - if (!state.isAlive) { - logWs.info('heartbeat timeout, terminating') - ;(ws.raw as RawWebSocket).terminate() - continue - } - state.isAlive = false - ;(ws.raw as RawWebSocket).ping() - } - }, HEARTBEAT_INTERVAL_MS) - - // Protocol strings based on HTTPS mode - const wsProtocol = https ? 'wss' : 'ws' - - // Get actual LAN IP when binding to 0.0.0.0 - let displayHost = host - if (host === '0.0.0.0') { - const lanIPs = getLanIPs() - displayHost = lanIPs[0] || 'localhost' - } - - // Build URLs - const localWsUrl = `${wsProtocol}://localhost:${port}/ws` - const networkWsUrl = `${wsProtocol}://${displayHost}:${port}/ws` - - // Print startup banner - console.log() - console.log(` 🚀 ACP Proxy Server${https ? ' (HTTPS)' : ''}`) - console.log() - console.log(` Connection:`) - if (host === '0.0.0.0') { - console.log(` URL: ${networkWsUrl}`) - } else { - console.log(` URL: ${localWsUrl}`) - } - if (AUTH_TOKEN) { - console.log(` Token: configured`) - } - console.log() - if (!AUTH_TOKEN) { - console.log(` ⚠️ Authentication disabled (--no-auth)`) - console.log() - } - - const agentDisplay = - AGENT_ARGS.length > 0 - ? `${AGENT_COMMAND} ${AGENT_ARGS.join(' ')}` - : AGENT_COMMAND - console.log(` 📦 Agent: ${agentDisplay}`) - console.log(` CWD: ${AGENT_CWD}`) - console.log() - console.log(` Press Ctrl+C to stop`) - console.log() - - logServer.info( - { - port, - host, - https, - wsEndpoint: `${wsProtocol}://${displayHost}:${port}/ws`, - agent: AGENT_COMMAND, - agentArgs: AGENT_ARGS, - cwd: AGENT_CWD, - authEnabled: !!AUTH_TOKEN, - }, - 'started', - ) - - // Graceful shutdown — close RCS upstream - const shutdown = async () => { - if (rcsUpstream) { - await rcsUpstream.close() - } - process.exit(0) - } - process.on('SIGINT', shutdown) - process.on('SIGTERM', shutdown) - - // Keep the server running - await new Promise(() => {}) -} +export type { JsonRpc2ClientMessage } from './ws-message.js' +export { decodeClientWsMessage } from './server/payload-decode.js' +export { resolveNewSessionPermissionMode } from './server/permission-mode.js' +export { __testing } from './server/testing-internals.js' +export { startServer } from './server/start-server.js' diff --git a/packages/acp-link/src/server/acp-client.ts b/packages/acp-link/src/server/acp-client.ts new file mode 100644 index 000000000..9d1d84c26 --- /dev/null +++ b/packages/acp-link/src/server/acp-client.ts @@ -0,0 +1,102 @@ +import type { WSContext } from 'hono/ws' +import * as acp from '@agentclientprotocol/sdk' +import { send } from './client-send.js' +import { + PERMISSION_TIMEOUT_MS, + generateRequestId, + logPerm, + logWs, +} from './runtime-state.js' +import { clients } from './runtime-state.js' +import type { ClientState } from './types.js' + +// Create a Client implementation that forwards events to WebSocket +export function createClient( + ws: WSContext, + clientState: ClientState, +): acp.Client { + return { + async requestPermission(params) { + const requestId = generateRequestId() + logPerm.debug({ requestId, title: params.toolCall.title }, 'requested') + + const outcomePromise = new Promise< + { outcome: 'cancelled' } | { outcome: 'selected'; optionId: string } + >(resolve => { + const timeout = setTimeout(() => { + logPerm.warn({ requestId }, 'timed out') + clientState.pendingPermissions.delete(requestId) + resolve({ outcome: 'cancelled' }) + }, PERMISSION_TIMEOUT_MS) + + clientState.pendingPermissions.set(requestId, { resolve, timeout }) + }) + + send(ws, 'permission_request', { + requestId, + sessionId: params.sessionId, + options: params.options, + toolCall: params.toolCall, + }) + + const outcome = await outcomePromise + logPerm.debug({ requestId, outcome: outcome.outcome }, 'resolved') + + return { outcome } + }, + + async sessionUpdate(params) { + send(ws, 'session_update', params) + }, + + async readTextFile(params) { + logWs.debug({ path: params.path }, 'readTextFile') + return { content: '' } + }, + + async writeTextFile(params) { + logWs.debug({ path: params.path }, 'writeTextFile') + return {} + }, + } +} + +// Handle permission response from client +export function handlePermissionResponse( + ws: WSContext, + payload: { + requestId: string + outcome: + | { outcome: 'cancelled' } + | { outcome: 'selected'; optionId: string } + }, +): void { + const state = clients.get(ws) + if (!state) { + logPerm.warn('response from unknown client') + return + } + + const pending = state.pendingPermissions.get(payload.requestId) + if (!pending) { + logPerm.warn( + { requestId: payload.requestId }, + 'response for unknown request', + ) + return + } + + clearTimeout(pending.timeout) + state.pendingPermissions.delete(payload.requestId) + pending.resolve(payload.outcome) +} + +// Cancel all pending permissions for a client (called on disconnect) +export function cancelPendingPermissions(clientState: ClientState): void { + for (const [requestId, pending] of clientState.pendingPermissions) { + logPerm.debug({ requestId }, 'cancelled on disconnect') + clearTimeout(pending.timeout) + pending.resolve({ outcome: 'cancelled' }) + } + clientState.pendingPermissions.clear() +} diff --git a/packages/acp-link/src/server/client-send.ts b/packages/acp-link/src/server/client-send.ts new file mode 100644 index 000000000..f0cc58ef1 --- /dev/null +++ b/packages/acp-link/src/server/client-send.ts @@ -0,0 +1,89 @@ +import type { WSContext } from 'hono/ws' +import { clients, getRcsUpstream } from './runtime-state.js' +import type { ClientState } from './types.js' + +// Maps legacy notification type strings to their JSON-RPC method names so +// agent→client notifications are also emitted as JSON-RPC notifications for +// JSON-RPC 2.0 clients (audit §8.1). Notifications have no id. +export const LEGACY_NOTIFICATION_TO_JSONRPC: Record = { + session_update: 'session/update', + permission_request: 'session/request_permission', +} + +// Send a notification/response to the WebSocket client. +// +// For legacy `{type, payload}` clients this emits the proprietary envelope. +// For JSON-RPC 2.0 clients this additionally emits a JSON-RPC response that +// echoes the in-flight request id when the message type matches the pending +// request's expected response type (audit §8.2). Agent→client notifications +// (`session_update`, `permission_request`) are emitted as JSON-RPC +// notifications without an id. +export function send(ws: WSContext, type: string, payload?: unknown): void { + if (ws.readyState === 1) { + // WebSocket.OPEN + ws.send(JSON.stringify({ type, payload })) + } + // Forward to RCS upstream if connected + const rcsUpstream = getRcsUpstream() + if (rcsUpstream?.isRegistered()) { + rcsUpstream.send({ type, payload }) + } + + const state = clients.get(ws) + if (!state?.jsonRpc) return + + // If this is the response to an in-flight JSON-RPC request, emit the + // standard JSON-RPC result with the preserved id. + if (state.pendingJsonRpc?.responseType === type) { + sendJsonRpcRaw(ws, { + jsonrpc: '2.0', + id: state.pendingJsonRpc.id, + result: payload ?? {}, + }) + state.pendingJsonRpc = null + return + } + + // Agent→client notifications are also emitted as JSON-RPC notifications + // (no id) so JSON-RPC clients receive them in their native format. + const notificationMethod = LEGACY_NOTIFICATION_TO_JSONRPC[type] + if (notificationMethod) { + sendJsonRpcRaw(ws, { + jsonrpc: '2.0', + method: notificationMethod, + params: payload ?? {}, + }) + } +} + +// Serialize a JSON-RPC 2.0 message and send it to a connected WS client. +export function sendJsonRpcRaw(ws: WSContext, message: object): void { + if (ws.readyState === 1) { + ws.send(JSON.stringify(message)) + } +} + +/** + * Send a JSON-RPC 2.0 error response with a reserved -32xxx code (audit §8.3). + * Also emits the legacy `{type: 'error', payload: {message}}` envelope for + * backwards compatibility. + */ +export function sendJsonRpcError( + ws: WSContext, + state: ClientState | undefined, + id: string | number | null, + code: number, + message: string, +): void { + if (state?.jsonRpc) { + sendJsonRpcRaw(ws, { + jsonrpc: '2.0', + id, + error: { code, message }, + }) + } else { + send(ws, 'error', { message, code: String(code) }) + } + // Error consumed the in-flight request, if any. + if (state) state.pendingJsonRpc = null +} diff --git a/packages/acp-link/src/server/dispatch.ts b/packages/acp-link/src/server/dispatch.ts new file mode 100644 index 000000000..00dd81400 --- /dev/null +++ b/packages/acp-link/src/server/dispatch.ts @@ -0,0 +1,335 @@ +import type { WSContext } from 'hono/ws' +import type { JsonRpc2ClientMessage } from '../ws-message.js' +import { handlePermissionResponse } from './acp-client.js' +import { send, sendJsonRpcError, sendJsonRpcRaw } from './client-send.js' +import { + handleCancel, + handleListSessions, + handleLoadSession, + handleNewSession, + handlePrompt, + handleResumeSession, + handleSetSessionModel, +} from './handlers-session.js' +import { handleConnect, handleDisconnect } from './handlers-agent.js' +import { + isRecord, + optionalPayloadRecord, + optionalRecord, + optionalString, + optionalStringField, + payloadRecord, + decodeContentBlocks, +} from './payload-decode.js' +import { clients, logWs } from './runtime-state.js' +import { + JSONRPC_INTERNAL_ERROR, + JSONRPC_INVALID_PARAMS, + JSONRPC_METHOD_NOT_FOUND, + type ProxyMessage, +} from './types.js' + +export async function dispatchClientMessage( + ws: WSContext, + data: ProxyMessage, +): Promise { + switch (data.type) { + case 'connect': + await handleConnect(ws) + break + case 'disconnect': + handleDisconnect(ws) + break + case 'new_session': + await handleNewSession(ws, data.payload) + break + case 'prompt': + await handlePrompt(ws, data.payload) + break + case 'permission_response': + handlePermissionResponse(ws, data.payload) + break + case 'cancel': + await handleCancel(ws) + break + case 'set_session_model': + await handleSetSessionModel(ws, data.payload) + break + case 'list_sessions': + await handleListSessions(ws, data.payload) + break + case 'load_session': + await handleLoadSession(ws, data.payload) + break + case 'resume_session': + await handleResumeSession(ws, data.payload) + break + case 'ping': + send(ws, 'pong') + break + } +} + +// JSON-RPC method wrappers that accept `params: unknown` and forward to the +// existing handlers with the decoded payload. +async function handleJsonRpcNewSession( + ws: WSContext, + params: unknown, +): Promise { + const payload = optionalPayloadRecord(params, 'session/new') + await handleNewSession(ws, { + cwd: optionalStringField(payload, 'cwd', 'session/new.cwd'), + permissionMode: optionalStringField( + payload, + 'permissionMode', + 'session/new.permissionMode', + ), + }) +} + +async function handleJsonRpcPrompt( + ws: WSContext, + params: unknown, +): Promise { + const payload = payloadRecord(params, 'session/prompt') + // ACP session/prompt params: { sessionId, prompt: ContentBlock[] } + // Accept either `prompt` (spec) or `content` (legacy) for compatibility. + const content = payload.prompt ?? payload.content + await handlePrompt(ws, { content: decodeContentBlocks(content) }) +} + +async function handleJsonRpcListSessions( + ws: WSContext, + params: unknown, +): Promise { + const payload = optionalRecord(params) + await handleListSessions(ws, { + cwd: optionalString(payload.cwd), + cursor: optionalString(payload.cursor), + }) +} + +async function handleJsonRpcLoadSession( + ws: WSContext, + params: unknown, +): Promise { + const payload = payloadRecord(params, 'session/load') + if (typeof payload.sessionId !== 'string') { + throw new Error('Invalid session/load payload') + } + await handleLoadSession(ws, { + sessionId: payload.sessionId, + cwd: optionalString(payload.cwd), + }) +} + +async function handleJsonRpcResumeSession( + ws: WSContext, + params: unknown, +): Promise { + const payload = payloadRecord(params, 'session/resume') + if (typeof payload.sessionId !== 'string') { + throw new Error('Invalid session/resume payload') + } + await handleResumeSession(ws, { + sessionId: payload.sessionId, + cwd: optionalString(payload.cwd), + }) +} + +async function handleJsonRpcSetSessionModel( + ws: WSContext, + params: unknown, +): Promise { + const payload = payloadRecord(params, 'session/set_model') + if (typeof payload.modelId !== 'string') { + throw new Error('Invalid session/set_model payload') + } + await handleSetSessionModel(ws, { modelId: payload.modelId }) +} + +/** + * Pass-through handlers for v1 baseline methods that the proprietary + * whitelist previously dropped (audit §8.4). They forward the call to the + * underlying SDK ClientSideConnection and surface the result. + */ +export async function handleJsonRpcSetSessionMode( + ws: WSContext, + params: unknown, +): Promise { + const state = clients.get(ws) + if (!state?.connection) { + throw new Error('Not connected to agent') + } + const result = await state.connection.setSessionMode( + params as { sessionId: string; modeId: string }, + ) + send(ws, 'session_mode_set', result ?? {}) +} + +export async function handleJsonRpcCloseSession( + ws: WSContext, + params: unknown, +): Promise { + const state = clients.get(ws) + if (!state?.connection) { + throw new Error('Not connected to agent') + } + const result = await state.connection.unstable_closeSession( + params as { sessionId: string }, + ) + send(ws, 'session_closed', result ?? {}) +} + +/** + * Handle the JSON-RPC standard cancellation primitive `$/cancel_request` + * (audit §8.5). Unlike the ACP-specific `session/cancel` notification, this + * cancels an in-flight request by id. We forward to the ACP cancel path and + * also clear any pending permission request. + */ +export async function handleJsonRpcCancelRequest( + ws: WSContext, + params: unknown, +): Promise { + const payload = optionalRecord(params) + logWs.info({ cancelledId: payload.id }, '$/cancel_request received') + await handleCancel(ws) +} + +/** + * Maps JSON-RPC method names to their legacy handler + the legacy response + * type the handler emits via send(). Used by dispatchJsonRpcMessage to route + * standard ACP methods (audit §8.1, §8.4). + */ +export const JSONRPC_METHOD_HANDLERS: Record< + string, + { + responseType: string + handle: (ws: WSContext, params: unknown) => Promise | void + } +> = { + initialize: { responseType: 'status', handle: handleConnect }, + 'session/new': { + responseType: 'session_created', + handle: handleJsonRpcNewSession, + }, + 'session/prompt': { + responseType: 'prompt_complete', + handle: handleJsonRpcPrompt, + }, + 'session/cancel': { responseType: '', handle: handleCancel }, + 'session/list': { + responseType: 'session_list', + handle: handleJsonRpcListSessions, + }, + 'session/load': { + responseType: 'session_loaded', + handle: handleJsonRpcLoadSession, + }, + 'session/resume': { + responseType: 'session_resumed', + handle: handleJsonRpcResumeSession, + }, + 'session/set_model': { + responseType: 'model_changed', + handle: handleJsonRpcSetSessionModel, + }, + 'session/set_mode': { + responseType: 'session_mode_set', + handle: handleJsonRpcSetSessionMode, + }, + 'session/close': { + responseType: 'session_closed', + handle: handleJsonRpcCloseSession, + }, +} + +/** + * Route a JSON-RPC 2.0 message. Requests get a response with the echoed id; + * notifications (no id) are dispatched without a response. Unknown methods + * yield a JSON-RPC -32601 error (audit §8.4). `$/cancel_request` is handled + * specially (audit §8.5). + */ +export async function dispatchJsonRpcMessage( + ws: WSContext, + msg: JsonRpc2ClientMessage, +): Promise { + const state = clients.get(ws) + // Mark this client as JSON-RPC from the first framed message. + if (state) state.jsonRpc = true + + // Capture client identity/capabilities from initialize (audit §8.7). + if (msg.method === 'initialize' && state) { + const params = isRecord(msg.params) ? msg.params : {} + if (isRecord(params.clientInfo)) { + const ci = params.clientInfo + if (typeof ci.name === 'string' && typeof ci.version === 'string') { + state.clientInfo = { name: ci.name, version: ci.version } + } + } + if (isRecord(params.clientCapabilities)) { + state.clientCapabilities = params.clientCapabilities + } + } + + // Notification (no id) — dispatch without a response. + if (!('id' in msg) || msg.id === undefined) { + if (msg.method === '$/cancel_request') { + await handleJsonRpcCancelRequest(ws, msg.params) + return + } + if (msg.method === 'session/cancel') { + await handleCancel(ws) + return + } + // Unknown notification — silently ignore per JSON-RPC 2.0 (notifications + // cannot be responded to). + logWs.debug({ method: msg.method }, 'ignoring unknown notification') + return + } + + // Request (has id) — dispatch and the handler will emit a response. + if (msg.method === '$/cancel_request') { + await handleJsonRpcCancelRequest(ws, msg.params) + // Cancellation is itself a notification-style request; respond with null. + if (state) state.pendingJsonRpc = { id: msg.id, responseType: '' } + sendJsonRpcRaw(ws, { jsonrpc: '2.0', id: msg.id, result: null }) + if (state) state.pendingJsonRpc = null + return + } + + const entry = JSONRPC_METHOD_HANDLERS[msg.method] + if (!entry) { + sendJsonRpcError( + ws, + state, + msg.id, + JSONRPC_METHOD_NOT_FOUND, + `Method not found: ${msg.method}`, + ) + return + } + + // Track the in-flight request so the handler's send() emits a JSON-RPC + // response with the echoed id (audit §8.2). + if (state) + state.pendingJsonRpc = { id: msg.id, responseType: entry.responseType } + try { + await entry.handle(ws, msg.params) + // If the handler did not emit the expected response (e.g. it short + // circuited with an error already), still clear the pending slot. + if (state?.pendingJsonRpc) { + sendJsonRpcRaw(ws, { + jsonrpc: '2.0', + id: msg.id, + result: {}, + }) + state.pendingJsonRpc = null + } + } catch (error) { + const code = (error as Error).message.startsWith('Invalid ') + ? JSONRPC_INVALID_PARAMS + : JSONRPC_INTERNAL_ERROR + sendJsonRpcError(ws, state, msg.id, code, (error as Error).message) + } +} diff --git a/packages/acp-link/src/server/handlers-agent.ts b/packages/acp-link/src/server/handlers-agent.ts new file mode 100644 index 000000000..6d34f8ff3 --- /dev/null +++ b/packages/acp-link/src/server/handlers-agent.ts @@ -0,0 +1,158 @@ +import { Writable, Readable } from 'node:stream' +import { spawn } from 'node:child_process' +import * as acp from '@agentclientprotocol/sdk' +import type { WSContext } from 'hono/ws' +import { send, sendJsonRpcError } from './client-send.js' +import { cancelPendingPermissions, createClient } from './acp-client.js' +import { buildAgentEnv } from './permission-mode.js' +import { clients, getAgentConfig, logAgent } from './runtime-state.js' +import { + JSONRPC_INTERNAL_ERROR, + type AgentCapabilities, + type ClientState, +} from './types.js' + +export async function handleConnect(ws: WSContext): Promise { + const state = clients.get(ws) + if (!state) return + + const { + command: AGENT_COMMAND, + args: AGENT_ARGS, + cwd: AGENT_CWD, + } = getAgentConfig() + + // If already connected to a running agent, just resend status + // This handles frontend reconnections without restarting the agent process + // Check both .killed and .exitCode to detect crashed processes + if ( + state.connection && + state.process && + !state.process.killed && + state.process.exitCode === null + ) { + logAgent.info('already connected, resending status') + send(ws, 'status', { + connected: true, + agentInfo: state.agentInfo ?? { name: AGENT_COMMAND }, + capabilities: state.agentCapabilities, + protocolVersion: state.protocolVersion, + }) + return + } + + // Kill existing process if any (only if not healthy) + if (state.process) { + cancelPendingPermissions(state) + state.process.kill() + state.process = null + state.connection = null + } + + try { + logAgent.info({ command: AGENT_COMMAND, args: AGENT_ARGS }, 'spawning') + + const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, { + cwd: AGENT_CWD, + stdio: ['pipe', 'pipe', 'inherit'], + env: buildAgentEnv(), + }) + + state.process = agentProcess + + // Clean up state when agent process exits unexpectedly + agentProcess.on('exit', code => { + logAgent.info({ exitCode: code }, 'agent process exited') + // Only clear if this is still the current process + if (state.process === agentProcess) { + state.process = null + state.connection = null + state.sessionId = null + } + }) + + const input = Writable.toWeb( + agentProcess.stdin!, + ) as unknown as WritableStream + const output = Readable.toWeb( + agentProcess.stdout!, + ) as unknown as ReadableStream + + const stream = acp.ndJsonStream(input, output) + const connection = new acp.ClientSideConnection( + _agent => createClient(ws, state), + stream, + ) + + state.connection = connection + + const initResult = await connection.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + // Forward the real client identity/capabilities (audit §8.7). Falls back + // to the Zed defaults only when the client did not provide any. + clientInfo: state.clientInfo, + clientCapabilities: state.clientCapabilities, + }) + + // Pass the raw agentCapabilities through unchanged so present and future + // capability fields (auth, terminal, ...) reach the client (audit §8.6). + const agentCaps = initResult.agentCapabilities + state.agentCapabilities = (agentCaps as AgentCapabilities | null) ?? null + state.promptCapabilities = agentCaps?.promptCapabilities ?? null + // Remember the negotiated protocolVersion + agentInfo so reconnects and + // JSON-RPC initialize responses can forward them to the client (§8.13). + state.protocolVersion = initResult.protocolVersion + state.agentInfo = + (initResult.agentInfo as ClientState['agentInfo'] | null | undefined) ?? + null + + logAgent.info( + { + protocolVersion: initResult.protocolVersion, + loadSession: !!state.agentCapabilities?.loadSession, + sessionList: !!state.agentCapabilities?.sessionCapabilities?.list, + sessionResume: !!state.agentCapabilities?.sessionCapabilities?.resume, + hasMcp: !!state.agentCapabilities?.mcpCapabilities, + }, + 'initialized', + ) + + send(ws, 'status', { + connected: true, + agentInfo: initResult.agentInfo, + capabilities: state.agentCapabilities, + // Surface the negotiated protocolVersion to downstream clients (audit §8.13). + protocolVersion: initResult.protocolVersion, + }) + + connection.closed.then(() => { + logAgent.info('connection closed') + state.connection = null + state.sessionId = null + send(ws, 'status', { connected: false }) + }) + } catch (error) { + logAgent.error({ error: (error as Error).message }, 'connect failed') + sendJsonRpcError( + ws, + state, + null, + JSONRPC_INTERNAL_ERROR, + `Failed to connect: ${(error as Error).message}`, + ) + } +} + +export function handleDisconnect(ws: WSContext): void { + const state = clients.get(ws) + if (!state) return + + if (state.process) { + state.process.kill() + state.process = null + } + state.connection = null + state.sessionId = null + + send(ws, 'status', { connected: false }) +} diff --git a/packages/acp-link/src/server/handlers-session.ts b/packages/acp-link/src/server/handlers-session.ts new file mode 100644 index 000000000..9810ec827 --- /dev/null +++ b/packages/acp-link/src/server/handlers-session.ts @@ -0,0 +1,435 @@ +import * as acp from '@agentclientprotocol/sdk' +import type { WSContext } from 'hono/ws' +import { cancelPendingPermissions } from './acp-client.js' +import { send, sendJsonRpcError } from './client-send.js' +import { resolveNewSessionPermissionMode } from './permission-mode.js' +import { + clients, + getAgentConfig, + getDefaultPermissionMode, + logAgent, + logPrompt, + logSession, + logWs, +} from './runtime-state.js' +import { + JSONRPC_INTERNAL_ERROR, + JSONRPC_INVALID_PARAMS, + JSONRPC_INVALID_REQUEST, + JSONRPC_METHOD_NOT_FOUND, + type ContentBlock, +} from './types.js' + +export async function handleNewSession( + ws: WSContext, + params: { cwd?: string; permissionMode?: string }, +): Promise { + const state = clients.get(ws) + if (!state?.connection) { + logAgent.warn( + { + hasState: !!state, + hasProcess: !!state?.process, + processKilled: state?.process?.killed, + exitCode: state?.process?.exitCode, + }, + 'handleNewSession: not connected to agent', + ) + sendJsonRpcError( + ws, + state, + state?.pendingJsonRpc?.id ?? null, + JSONRPC_INVALID_REQUEST, + 'Not connected to agent', + ) + return + } + + const { cwd: AGENT_CWD } = getAgentConfig() + + try { + const sessionCwd = params.cwd || AGENT_CWD + let permissionMode: string | undefined + try { + permissionMode = resolveNewSessionPermissionMode( + params.permissionMode, + getDefaultPermissionMode(), + ) + } catch (error) { + sendJsonRpcError( + ws, + state, + state.pendingJsonRpc?.id ?? null, + JSONRPC_INVALID_PARAMS, + (error as Error).message, + ) + return + } + const result = await state.connection.newSession({ + cwd: sessionCwd, + mcpServers: [], + ...(permissionMode ? { _meta: { permissionMode } } : {}), + }) + + state.sessionId = result.sessionId + state.modelState = result.models ?? null + logSession.info( + { + sessionId: result.sessionId, + cwd: sessionCwd, + hasModels: !!result.models, + }, + 'created', + ) + + send(ws, 'session_created', { + ...result, + promptCapabilities: state.promptCapabilities, + models: state.modelState, + }) + } catch (error) { + logSession.error({ error: (error as Error).message }, 'create failed') + sendJsonRpcError( + ws, + state, + state.pendingJsonRpc?.id ?? null, + JSONRPC_INTERNAL_ERROR, + `Failed to create session: ${(error as Error).message}`, + ) + } +} + +// ============================================================================ +// Session History Operations +// Reference: Zed's AgentConnection trait - list_sessions, load_session, resume_session +// ============================================================================ + +export async function handleListSessions( + ws: WSContext, + params: { cwd?: string; cursor?: string }, +): Promise { + const state = clients.get(ws) + if (!state?.connection) { + logAgent.warn( + { + hasState: !!state, + hasProcess: !!state?.process, + processKilled: state?.process?.killed, + exitCode: state?.process?.exitCode, + }, + 'handleListSessions: not connected to agent', + ) + sendJsonRpcError( + ws, + state, + state?.pendingJsonRpc?.id ?? null, + JSONRPC_INVALID_REQUEST, + 'Not connected to agent', + ) + return + } + + if (!state.agentCapabilities?.sessionCapabilities?.list) { + sendJsonRpcError( + ws, + state, + state.pendingJsonRpc?.id ?? null, + JSONRPC_METHOD_NOT_FOUND, + 'Listing sessions is not supported by this agent', + ) + return + } + + try { + const result = await state.connection.listSessions({ + cwd: params.cwd, + cursor: params.cursor, + }) + + const MAX_SESSIONS = 20 + const sessions = result.sessions.slice(0, MAX_SESSIONS) + logSession.info( + { + total: result.sessions.length, + returned: sessions.length, + hasMore: !!result.nextCursor, + }, + 'listed', + ) + + send(ws, 'session_list', { + sessions: sessions.map((s: acp.SessionInfo) => ({ + _meta: s._meta, + cwd: s.cwd, + sessionId: s.sessionId, + title: s.title, + updatedAt: s.updatedAt, + })), + nextCursor: result.nextCursor, + _meta: result._meta, + }) + } catch (error) { + logSession.error({ error: (error as Error).message }, 'list failed') + sendJsonRpcError( + ws, + state, + state.pendingJsonRpc?.id ?? null, + JSONRPC_INTERNAL_ERROR, + `Failed to list sessions: ${(error as Error).message}`, + ) + } +} + +export async function handleLoadSession( + ws: WSContext, + params: { sessionId: string; cwd?: string }, +): Promise { + const state = clients.get(ws) + if (!state?.connection) { + logAgent.warn( + { + hasState: !!state, + hasProcess: !!state?.process, + processKilled: state?.process?.killed, + exitCode: state?.process?.exitCode, + }, + 'handleLoadSession: not connected to agent', + ) + sendJsonRpcError( + ws, + state, + state?.pendingJsonRpc?.id ?? null, + JSONRPC_INVALID_REQUEST, + 'Not connected to agent', + ) + return + } + + if (!state.agentCapabilities?.loadSession) { + sendJsonRpcError( + ws, + state, + state.pendingJsonRpc?.id ?? null, + JSONRPC_METHOD_NOT_FOUND, + 'Loading sessions is not supported by this agent', + ) + return + } + + const { cwd: AGENT_CWD } = getAgentConfig() + + try { + const sessionCwd = params.cwd || AGENT_CWD + const sessionId = params.sessionId + const result = await state.connection.loadSession({ + sessionId, + cwd: sessionCwd, + mcpServers: [], + }) + + state.sessionId = sessionId + state.modelState = result.models ?? null + logSession.info({ sessionId, cwd: sessionCwd }, 'loaded') + + send(ws, 'session_loaded', { + sessionId, + promptCapabilities: state.promptCapabilities, + models: state.modelState, + }) + } catch (error) { + logSession.error({ error: (error as Error).message }, 'load failed') + sendJsonRpcError( + ws, + state, + state.pendingJsonRpc?.id ?? null, + JSONRPC_INTERNAL_ERROR, + `Failed to load session: ${(error as Error).message}`, + ) + } +} + +export async function handleResumeSession( + ws: WSContext, + params: { sessionId: string; cwd?: string }, +): Promise { + const state = clients.get(ws) + if (!state?.connection) { + logAgent.warn( + { + hasState: !!state, + hasProcess: !!state?.process, + processKilled: state?.process?.killed, + exitCode: state?.process?.exitCode, + }, + 'handleResumeSession: not connected to agent', + ) + sendJsonRpcError( + ws, + state, + state?.pendingJsonRpc?.id ?? null, + JSONRPC_INVALID_REQUEST, + 'Not connected to agent', + ) + return + } + + if (!state.agentCapabilities?.sessionCapabilities?.resume) { + sendJsonRpcError( + ws, + state, + state.pendingJsonRpc?.id ?? null, + JSONRPC_METHOD_NOT_FOUND, + 'Resuming sessions is not supported by this agent', + ) + return + } + + const { cwd: AGENT_CWD } = getAgentConfig() + + try { + const sessionCwd = params.cwd || AGENT_CWD + const sessionId = params.sessionId + const result = await state.connection.unstable_resumeSession({ + sessionId, + cwd: sessionCwd, + }) + + state.sessionId = sessionId + state.modelState = result.models ?? null + logSession.info({ sessionId, cwd: sessionCwd }, 'resumed') + + send(ws, 'session_resumed', { + sessionId, + promptCapabilities: state.promptCapabilities, + models: state.modelState, + }) + } catch (error) { + logSession.error({ error: (error as Error).message }, 'resume failed') + sendJsonRpcError( + ws, + state, + state.pendingJsonRpc?.id ?? null, + JSONRPC_INTERNAL_ERROR, + `Failed to resume session: ${(error as Error).message}`, + ) + } +} + +// Reference: Zed's AcpThread.send() forwards Vec to agent +export async function handlePrompt( + ws: WSContext, + params: { content: ContentBlock[] }, +): Promise { + const state = clients.get(ws) + if (!state?.connection || !state.sessionId) { + sendJsonRpcError( + ws, + state, + state?.pendingJsonRpc?.id ?? null, + JSONRPC_INVALID_REQUEST, + 'No active session', + ) + return + } + + try { + const firstText = params.content.find(b => b.type === 'text')?.text + const images = params.content.filter(b => b.type === 'image') + logPrompt.debug( + { + text: firstText?.slice(0, 100), + imageCount: images.length, + blockCount: params.content.length, + }, + 'sending', + ) + + const result = await state.connection.prompt({ + sessionId: state.sessionId, + prompt: params.content as acp.ContentBlock[], + }) + + logPrompt.info({ stopReason: result.stopReason }, 'completed') + send(ws, 'prompt_complete', result) + } catch (error) { + logPrompt.error({ error: (error as Error).message }, 'failed') + sendJsonRpcError( + ws, + state, + state.pendingJsonRpc?.id ?? null, + JSONRPC_INTERNAL_ERROR, + `Prompt failed: ${(error as Error).message}`, + ) + } +} + +// Handle cancel request from client +export async function handleCancel(ws: WSContext): Promise { + const state = clients.get(ws) + if (!state?.connection || !state.sessionId) { + logWs.warn('cancel requested but no active session') + return + } + + logSession.info({ sessionId: state.sessionId }, 'cancel requested') + cancelPendingPermissions(state) + + try { + await state.connection.cancel({ sessionId: state.sessionId }) + logSession.info({ sessionId: state.sessionId }, 'cancel sent') + } catch (error) { + logSession.error({ error: (error as Error).message }, 'cancel failed') + } +} + +// Reference: Zed's AgentModelSelector.select_model() calls connection.set_session_model() +export async function handleSetSessionModel( + ws: WSContext, + params: { modelId: string }, +): Promise { + const state = clients.get(ws) + if (!state?.connection || !state.sessionId) { + sendJsonRpcError( + ws, + state, + state?.pendingJsonRpc?.id ?? null, + JSONRPC_INVALID_REQUEST, + 'No active session', + ) + return + } + + if (!state.modelState) { + sendJsonRpcError( + ws, + state, + state.pendingJsonRpc?.id ?? null, + JSONRPC_METHOD_NOT_FOUND, + 'Model selection not supported by this agent', + ) + return + } + + try { + logSession.info( + { sessionId: state.sessionId, modelId: params.modelId }, + 'setting model', + ) + await state.connection.unstable_setSessionModel({ + sessionId: state.sessionId, + modelId: params.modelId, + }) + state.modelState = { ...state.modelState, currentModelId: params.modelId } + send(ws, 'model_changed', { modelId: params.modelId }) + logSession.info({ modelId: params.modelId }, 'model changed') + } catch (error) { + logSession.error({ error: (error as Error).message }, 'set model failed') + sendJsonRpcError( + ws, + state, + state.pendingJsonRpc?.id ?? null, + JSONRPC_INTERNAL_ERROR, + `Failed to set model: ${(error as Error).message}`, + ) + } +} diff --git a/packages/acp-link/src/server/payload-decode.ts b/packages/acp-link/src/server/payload-decode.ts new file mode 100644 index 000000000..11b7ae2eb --- /dev/null +++ b/packages/acp-link/src/server/payload-decode.ts @@ -0,0 +1,161 @@ +import { decodeJsonWsMessage } from '../ws-message.js' +import type { + ContentBlock, + PermissionResponsePayload, + ProxyMessage, +} from './types.js' + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function optionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +export function optionalStringField( + payload: Record, + key: string, + source: string, +): string | undefined { + if (!Object.hasOwn(payload, key)) return undefined + const value = payload[key] + if (typeof value === 'string') return value + throw new Error(`Invalid ${source}: expected a string`) +} + +export function payloadRecord( + value: unknown, + type: string, +): Record { + if (!isRecord(value)) { + throw new Error(`Invalid ${type} payload`) + } + return value +} + +export function optionalPayloadRecord( + value: unknown, + type: string, +): Record { + if (value === undefined) return {} + return payloadRecord(value, type) +} + +export function optionalRecord(value: unknown): Record { + return isRecord(value) ? value : {} +} + +export function decodeContentBlocks(value: unknown): ContentBlock[] { + if ( + !Array.isArray(value) || + !value.every(block => isRecord(block) && typeof block.type === 'string') + ) { + throw new Error('Invalid prompt payload') + } + return value as ContentBlock[] +} + +export function decodePermissionResponsePayload( + value: unknown, +): PermissionResponsePayload { + const payload = payloadRecord(value, 'permission_response') + if (typeof payload.requestId !== 'string' || !isRecord(payload.outcome)) { + throw new Error('Invalid permission_response payload') + } + if (payload.outcome.outcome === 'cancelled') { + return { requestId: payload.requestId, outcome: { outcome: 'cancelled' } } + } + if ( + payload.outcome.outcome === 'selected' && + typeof payload.outcome.optionId === 'string' + ) { + return { + requestId: payload.requestId, + outcome: { outcome: 'selected', optionId: payload.outcome.optionId }, + } + } + throw new Error('Invalid permission_response payload') +} + +export function decodeClientMessage( + message: Record, +): ProxyMessage { + if (typeof message.type !== 'string') { + throw new Error('Invalid WebSocket message payload') + } + + switch (message.type) { + case 'connect': + case 'disconnect': + case 'cancel': + case 'ping': + return { type: message.type } + case 'new_session': { + const payload = optionalPayloadRecord(message.payload, 'new_session') + return { + type: 'new_session', + payload: { + cwd: optionalStringField(payload, 'cwd', 'new_session.cwd'), + permissionMode: optionalStringField( + payload, + 'permissionMode', + 'new_session.permissionMode', + ), + }, + } + } + case 'prompt': { + const payload = payloadRecord(message.payload, 'prompt') + return { + type: 'prompt', + payload: { content: decodeContentBlocks(payload.content) }, + } + } + case 'permission_response': + return { + type: 'permission_response', + payload: decodePermissionResponsePayload(message.payload), + } + case 'set_session_model': { + const payload = payloadRecord(message.payload, 'set_session_model') + if (typeof payload.modelId !== 'string') { + throw new Error('Invalid set_session_model payload') + } + return { + type: 'set_session_model', + payload: { modelId: payload.modelId }, + } + } + case 'list_sessions': { + const payload = optionalRecord(message.payload) + return { + type: 'list_sessions', + payload: { + cwd: optionalString(payload.cwd), + cursor: optionalString(payload.cursor), + }, + } + } + case 'load_session': + case 'resume_session': { + const payload = payloadRecord(message.payload, message.type) + if (typeof payload.sessionId !== 'string') { + throw new Error(`Invalid ${message.type} payload`) + } + return { + type: message.type, + payload: { + sessionId: payload.sessionId, + cwd: optionalString(payload.cwd), + }, + } + } + default: + throw new Error(`Unknown message type: ${message.type}`) + } +} + +export function decodeClientWsMessage(data: unknown): ProxyMessage { + return decodeClientMessage(decodeJsonWsMessage(data)) +} diff --git a/packages/acp-link/src/server/permission-mode.ts b/packages/acp-link/src/server/permission-mode.ts new file mode 100644 index 000000000..7e99bb159 --- /dev/null +++ b/packages/acp-link/src/server/permission-mode.ts @@ -0,0 +1,71 @@ +import { getDefaultPermissionMode } from './runtime-state.js' + +export const ACP_LINK_PERMISSION_MODE_ALIASES = { + auto: 'auto', + default: 'default', + acceptedits: 'acceptEdits', + dontask: 'dontAsk', + plan: 'plan', + bypasspermissions: 'bypassPermissions', + bypass: 'bypassPermissions', +} as const + +export type AcpLinkPermissionMode = + (typeof ACP_LINK_PERMISSION_MODE_ALIASES)[keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES] + +export function resolveNewSessionPermissionMode( + requestedMode: string | undefined, + defaultMode: string | undefined, +): string | undefined { + const requested = resolveAcpLinkPermissionMode(requestedMode) + const localDefault = resolveAcpLinkPermissionMode(defaultMode) + + if (!requested) { + return localDefault + } + + if (requested !== 'bypassPermissions') { + return requested + } + + if (localDefault === 'bypassPermissions') { + return 'bypassPermissions' + } + + throw new Error( + 'bypassPermissions requires local ACP_PERMISSION_MODE=bypassPermissions before a client can request it.', + ) +} + +export function resolveAcpLinkPermissionMode( + mode: string | undefined, +): AcpLinkPermissionMode | undefined { + if (mode === undefined) return undefined + + const normalized = mode?.trim().toLowerCase() + if (!normalized) { + throw new Error('Invalid permissionMode: expected a non-empty string.') + } + + const resolved = + ACP_LINK_PERMISSION_MODE_ALIASES[ + normalized as keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES + ] + if (!resolved) { + throw new Error(`Invalid permissionMode: ${mode}.`) + } + + return resolved +} + +export function buildAgentEnv(): NodeJS.ProcessEnv { + const DEFAULT_PERMISSION_MODE = getDefaultPermissionMode() + if (!DEFAULT_PERMISSION_MODE) { + return process.env + } + + return { + ...process.env, + ACP_PERMISSION_MODE: DEFAULT_PERMISSION_MODE, + } +} diff --git a/packages/acp-link/src/server/runtime-state.ts b/packages/acp-link/src/server/runtime-state.ts new file mode 100644 index 000000000..2fe21fe8b --- /dev/null +++ b/packages/acp-link/src/server/runtime-state.ts @@ -0,0 +1,125 @@ +import type { WSContext } from 'hono/ws' +import { createLogger } from '../logger.js' +import type { RcsUpstreamClient } from '../rcs-upstream.js' +import type { ClientState } from './types.js' + +// Module-level state (set when server starts) +let AGENT_COMMAND: string +let AGENT_ARGS: string[] +let AGENT_CWD: string +let SERVER_PORT: number +let SERVER_HOST: string +let AUTH_TOKEN: string | undefined +let DEFAULT_PERMISSION_MODE: string | undefined + +export const clients = new Map() + +// Module-scoped child loggers +export const logWs = createLogger('ws') +export const logAgent = createLogger('agent') +export const logSession = createLogger('session') +export const logPrompt = createLogger('prompt') +export const logPerm = createLogger('perm') +export const logRelay = createLogger('relay') +export const logServer = createLogger('server') + +// RCS upstream client (optional — enabled via ACP_RCS_URL env var) +let rcsUpstream: RcsUpstreamClient | null = null + +// Permission request timeout (5 minutes) +export const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000 + +// Heartbeat interval for WebSocket ping/pong (30 seconds) +export const HEARTBEAT_INTERVAL_MS = 30_000 + +export interface ServerConfigFields { + command: string + args: string[] + cwd: string + port: number + host: string + token?: string + permissionMode?: string +} + +export function setServerConfig(fields: ServerConfigFields): void { + AGENT_COMMAND = fields.command + AGENT_ARGS = fields.args + AGENT_CWD = fields.cwd + SERVER_PORT = fields.port + SERVER_HOST = fields.host + AUTH_TOKEN = fields.token + DEFAULT_PERMISSION_MODE = fields.permissionMode +} + +export interface ServerConfigSnapshot { + command: string + args: string[] + cwd: string + port: number + host: string + token?: string +} + +export function getServerConfig(): ServerConfigSnapshot { + return { + command: AGENT_COMMAND, + args: AGENT_ARGS, + cwd: AGENT_CWD, + port: SERVER_PORT, + host: SERVER_HOST, + token: AUTH_TOKEN, + } +} + +export function getAgentConfig(): ServerConfigSnapshot { + return getServerConfig() +} + +export function getAuthToken(): string | undefined { + return AUTH_TOKEN +} + +export function getDefaultPermissionMode(): string | undefined { + return DEFAULT_PERMISSION_MODE +} + +export function setDefaultPermissionMode( + mode: string | undefined, +): string | undefined { + const previous = DEFAULT_PERMISSION_MODE + DEFAULT_PERMISSION_MODE = mode + return previous +} + +export function getRcsUpstream(): RcsUpstreamClient | null { + return rcsUpstream +} + +export function setRcsUpstream(client: RcsUpstreamClient | null): void { + rcsUpstream = client +} + +/** + * Create a virtual WSContext for RCS relay messages. + * Responses via send() go to RCS upstream (not a local WS). + */ +export function createRelayWs(): WSContext { + return { + get readyState() { + return 1 + }, // always OPEN + send: () => {}, // no-op — responses go through rcsUpstream.send() + close: () => {}, + raw: null, + isInner: false, + url: '', + origin: '', + protocol: '', + } as unknown as WSContext +} + +// Generate unique request ID +export function generateRequestId(): string { + return `perm_${Date.now()}_${Math.random().toString(36).slice(2, 11)}` +} diff --git a/packages/acp-link/src/server/start-server.ts b/packages/acp-link/src/server/start-server.ts new file mode 100644 index 000000000..07bd37bb3 --- /dev/null +++ b/packages/acp-link/src/server/start-server.ts @@ -0,0 +1,291 @@ +import { createServer as createHttpsServer } from 'node:https' +import { Hono } from 'hono' +import { serve } from '@hono/node-server' +import { createNodeWebSocket } from '@hono/node-ws' +import type { WebSocket as RawWebSocket } from 'ws' +import { getOrCreateCertificate, getLanIPs } from '../cert.js' +import { RcsUpstreamClient } from '../rcs-upstream.js' +import { + WsPayloadTooLargeError, + decodeJsonWsMessage, + isJsonRpc2Message, +} from '../ws-message.js' +import { authTokensEqual, extractWebSocketAuthToken } from '../ws-auth.js' +import { cancelPendingPermissions } from './acp-client.js' +import { sendJsonRpcError } from './client-send.js' +import { dispatchClientMessage, dispatchJsonRpcMessage } from './dispatch.js' +import { handleDisconnect } from './handlers-agent.js' +import { decodeClientMessage } from './payload-decode.js' +import { + HEARTBEAT_INTERVAL_MS, + clients, + createRelayWs, + getAuthToken, + getRcsUpstream, + logRelay, + logServer, + logWs, + setRcsUpstream, + setServerConfig, +} from './runtime-state.js' +import { + JSONRPC_PARSE_ERROR, + createClientState, + type ServerConfig, +} from './types.js' + +export async function startServer(config: ServerConfig): Promise { + const { port, host, command, args, cwd, token, https } = config + + // Set module-level config + setServerConfig({ + command, + args, + cwd, + port, + host, + token, + permissionMode: config.permissionMode || process.env.ACP_PERMISSION_MODE, + }) + + // Initialize RCS upstream client if configured + const rcsUrl = process.env.ACP_RCS_URL + const rcsToken = process.env.ACP_RCS_TOKEN + const rcsGroup = config.group || process.env.ACP_RCS_GROUP + if (rcsGroup && !/^[a-zA-Z0-9_-]+$/.test(rcsGroup)) { + throw new Error( + `Invalid ACP_RCS_GROUP "${rcsGroup}": only letters, digits, hyphens, and underscores are allowed`, + ) + } + let rcsUpstream = null + if (rcsUrl) { + rcsUpstream = new RcsUpstreamClient({ + rcsUrl, + apiToken: rcsToken || '', + agentName: command, + channelGroupId: rcsGroup || undefined, + maxSessions: 1, + }) + + const relayWs = createRelayWs() + const relayState = createClientState() + clients.set(relayWs, relayState) + + rcsUpstream.setMessageHandler(async msg => { + try { + // The RCS relay forwards messages from the Web UI. Accept both + // JSON-RPC 2.0 (audit §8.12) and the legacy `{type, payload}` envelope. + if (isJsonRpc2Message(msg)) { + logRelay.debug({ method: msg.method }, 'processing jsonrpc') + await dispatchJsonRpcMessage(relayWs, msg) + } else { + const data = decodeClientMessage(msg) + logRelay.debug({ type: data.type }, 'processing') + await dispatchClientMessage(relayWs, data) + } + } catch (error) { + logRelay.error({ error: (error as Error).message }, 'handler error') + } + }) + + rcsUpstream.connect().catch(err => { + logRelay.warn( + { error: (err as Error).message }, + 'initial connection failed', + ) + }) + logRelay.info({ url: rcsUrl }, 'upstream enabled') + } + // Publish rcsUpstream back to runtime-state so send() can forward. + setRcsUpstream(rcsUpstream) + + const app = new Hono() + const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }) + + // Health check endpoint + app.get('/health', c => { + return c.json({ status: 'ok' }) + }) + + // WebSocket endpoint with token validation + app.get( + '/ws', + upgradeWebSocket(c => { + const AUTH_TOKEN = getAuthToken() + if (AUTH_TOKEN) { + const providedToken = extractWebSocketAuthToken({ + authorization: c.req.header('Authorization'), + protocol: c.req.header('Sec-WebSocket-Protocol'), + }) + if (!authTokensEqual(providedToken, AUTH_TOKEN)) { + logWs.warn('connection rejected: invalid token') + return { + onOpen(_event, ws) { + ws.close(4001, 'Unauthorized: Invalid token') + }, + onMessage() {}, + onClose() {}, + } + } + } + + return { + onOpen(_event, ws) { + logWs.info('client connected') + const state = createClientState() + clients.set(ws, state) + + const rawWs = ws.raw as RawWebSocket + rawWs.on('pong', () => { + state.isAlive = true + }) + }, + async onMessage(event, ws) { + try { + // Decode the raw frame once. JSON-RPC 2.0 messages are routed by + // method name (audit §8.1, §8.4, §8.5); legacy `{type, payload}` + // messages keep the existing dispatch path for backwards compat. + const decoded = decodeJsonWsMessage(event.data) + if (isJsonRpc2Message(decoded)) { + logWs.debug({ method: decoded.method }, 'received jsonrpc') + await dispatchJsonRpcMessage(ws, decoded) + } else { + const data = decodeClientMessage(decoded) + logWs.debug({ type: data.type }, 'received') + await dispatchClientMessage(ws, data) + } + } catch (error) { + if (error instanceof WsPayloadTooLargeError) { + logWs.warn({ error: error.message }, 'message too large') + ws.close(1009, 'message too large') + return + } + logWs.error({ error: (error as Error).message }, 'message error') + const state = clients.get(ws) + sendJsonRpcError( + ws, + state, + state?.pendingJsonRpc?.id ?? null, + JSONRPC_PARSE_ERROR, + `Error: ${(error as Error).message}`, + ) + } + }, + onClose(_event, ws) { + logWs.info('client disconnected') + const state = clients.get(ws) + if (state) { + cancelPendingPermissions(state) + } + handleDisconnect(ws) + clients.delete(ws) + }, + } + }), + ) + + // Create server with optional HTTPS + let server + if (https) { + const tlsOptions = await getOrCreateCertificate() + server = serve({ + fetch: app.fetch, + port, + hostname: host, + createServer: createHttpsServer, + serverOptions: tlsOptions, + }) + } else { + server = serve({ fetch: app.fetch, port, hostname: host }) + } + injectWebSocket(server) + + // Heartbeat: periodically ping all connected clients + setInterval(() => { + for (const [ws, state] of clients) { + // Skip virtual relay connections (no raw socket, always alive) + if (!ws.raw && state.isAlive) continue + if (!ws.raw) { + // Connection already closed, clean up + clients.delete(ws) + continue + } + if (!state.isAlive) { + logWs.info('heartbeat timeout, terminating') + ;(ws.raw as RawWebSocket).terminate() + continue + } + state.isAlive = false + ;(ws.raw as RawWebSocket).ping() + } + }, HEARTBEAT_INTERVAL_MS) + + // Protocol strings based on HTTPS mode + const wsProtocol = https ? 'wss' : 'ws' + + // Get actual LAN IP when binding to 0.0.0.0 + let displayHost = host + if (host === '0.0.0.0') { + const lanIPs = getLanIPs() + displayHost = lanIPs[0] || 'localhost' + } + + // Build URLs + const localWsUrl = `${wsProtocol}://localhost:${port}/ws` + const networkWsUrl = `${wsProtocol}://${displayHost}:${port}/ws` + + // Print startup banner + console.log() + console.log(` 🚀 ACP Proxy Server${https ? ' (HTTPS)' : ''}`) + console.log() + console.log(` Connection:`) + if (host === '0.0.0.0') { + console.log(` URL: ${networkWsUrl}`) + } else { + console.log(` URL: ${localWsUrl}`) + } + if (token) { + console.log(` Token: configured`) + } + console.log() + if (!token) { + console.log(` ⚠️ Authentication disabled (--no-auth)`) + console.log() + } + + const agentDisplay = + args.length > 0 ? `${command} ${args.join(' ')}` : command + console.log(` 📦 Agent: ${agentDisplay}`) + console.log(` CWD: ${cwd}`) + console.log() + console.log(` Press Ctrl+C to stop`) + console.log() + + logServer.info( + { + port, + host, + https, + wsEndpoint: `${wsProtocol}://${displayHost}:${port}/ws`, + agent: command, + agentArgs: args, + cwd, + authEnabled: !!token, + }, + 'started', + ) + + // Graceful shutdown — close RCS upstream + const shutdown = async () => { + const upstream = getRcsUpstream() + if (upstream) { + await upstream.close() + } + process.exit(0) + } + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + + // Keep the server running + await new Promise(() => {}) +} diff --git a/packages/acp-link/src/server/testing-internals.ts b/packages/acp-link/src/server/testing-internals.ts new file mode 100644 index 000000000..2269672ab --- /dev/null +++ b/packages/acp-link/src/server/testing-internals.ts @@ -0,0 +1,65 @@ +import type { ChildProcess } from 'node:child_process' +import * as acp from '@agentclientprotocol/sdk' +import type { WSContext } from 'hono/ws' +import type { JsonRpc2ClientMessage } from '../ws-message.js' +import { dispatchClientMessage, dispatchJsonRpcMessage } from './dispatch.js' +import { clients, setDefaultPermissionMode } from './runtime-state.js' +import { createClientState, type ProxyMessage } from './types.js' + +export function assertTestingInternalsEnabled(): void { + if (process.env.ACP_LINK_TEST_INTERNALS === '1') { + return + } + + throw new Error( + 'acp-link test internals are disabled outside test execution.', + ) +} + +export const __testing = { + dispatchClientMessage(ws: WSContext, data: unknown): Promise { + assertTestingInternalsEnabled() + return dispatchClientMessage(ws, data as ProxyMessage) + }, + dispatchJsonRpcMessage(ws: WSContext, data: unknown): Promise { + assertTestingInternalsEnabled() + return dispatchJsonRpcMessage(ws, data as JsonRpc2ClientMessage) + }, + registerClient( + ws: WSContext, + state: { + connection?: unknown + process?: ChildProcess | null + sessionId?: string | null + clientInfo?: { name: string; version: string } + clientCapabilities?: Record + jsonRpc?: boolean + }, + ): () => void { + assertTestingInternalsEnabled() + const full = createClientState() + full.process = state.process ?? null + full.connection = (state.connection ?? + null) as acp.ClientSideConnection | null + full.sessionId = state.sessionId ?? null + if (state.clientInfo) full.clientInfo = state.clientInfo + if (state.clientCapabilities) + full.clientCapabilities = state.clientCapabilities + if (typeof state.jsonRpc === 'boolean') full.jsonRpc = state.jsonRpc + clients.set(ws, full) + return () => { + clients.delete(ws) + } + }, + getClientSessionId(ws: WSContext): string | null | undefined { + assertTestingInternalsEnabled() + return clients.get(ws)?.sessionId + }, + setDefaultPermissionMode(mode: string | undefined): () => void { + assertTestingInternalsEnabled() + const previous = setDefaultPermissionMode(mode) + return () => { + setDefaultPermissionMode(previous) + } + }, +} diff --git a/packages/acp-link/src/server/types.ts b/packages/acp-link/src/server/types.ts new file mode 100644 index 000000000..8c61074b0 --- /dev/null +++ b/packages/acp-link/src/server/types.ts @@ -0,0 +1,172 @@ +import type { ChildProcess } from 'node:child_process' +import * as acp from '@agentclientprotocol/sdk' + +// JSON-RPC 2.0 reserved error codes (spec §5.1) +export const JSONRPC_PARSE_ERROR = -32700 +export const JSONRPC_INVALID_REQUEST = -32600 +export const JSONRPC_METHOD_NOT_FOUND = -32601 +export const JSONRPC_INVALID_PARAMS = -32602 +export const JSONRPC_INTERNAL_ERROR = -32603 + +export interface ServerConfig { + port: number + host: string + command: string + args: string[] + cwd: string + debug?: boolean + token?: string + https?: boolean + /** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */ + permissionMode?: string + /** Channel group ID for RCS registration */ + group?: string +} + +// Pending permission request +export interface PendingPermission { + resolve: ( + outcome: + | { outcome: 'cancelled' } + | { outcome: 'selected'; optionId: string }, + ) => void + timeout: ReturnType +} + +// PromptCapabilities from ACP protocol +// Reference: Zed's prompt_capabilities to check image support +export interface PromptCapabilities { + audio?: boolean + embeddedContext?: boolean + image?: boolean +} + +// SessionModelState from ACP protocol +// Reference: Zed's AgentModelSelector reads from state.available_models +export interface SessionModelState { + availableModels: Array<{ + modelId: string + name: string + description?: string | null + }> + currentModelId: string +} + +// AgentCapabilities from ACP protocol +// Reference: Zed's AcpConnection.agent_capabilities +// Matches SDK's AgentCapabilities exactly +export interface AgentCapabilities { + _meta?: Record | null + loadSession?: boolean + mcpCapabilities?: { + _meta?: Record | null + clientServers?: boolean + } + promptCapabilities?: PromptCapabilities + sessionCapabilities?: { + _meta?: Record | null + fork?: Record | null + list?: Record | null + resume?: Record | null + } +} + +// Track connected clients and their agent connections +export interface ClientState { + process: ChildProcess | null + connection: acp.ClientSideConnection | null + sessionId: string | null + pendingPermissions: Map + agentCapabilities: AgentCapabilities | null + promptCapabilities: PromptCapabilities | null + modelState: SessionModelState | null + isAlive: boolean + /** + * True when this client speaks JSON-RPC 2.0 (determined from the first + * framed message). When true, responses are emitted as JSON-RPC responses + * that preserve the request `id`; otherwise the legacy `{type, payload}` + * envelope is used for backwards compatibility. + */ + jsonRpc: boolean + /** + * Client-supplied identity and capabilities, captured from the JSON-RPC + * `initialize` request or legacy `connect` payload and forwarded to the + * agent instead of the hardcoded Zed fallback. See audit §8.7. + */ + clientInfo: { name: string; version: string } + clientCapabilities: Record + /** Negotiated ACP protocolVersion surfaced back to the client (audit §8.13). */ + protocolVersion: number | null + /** Agent identity from InitializeResult.agentInfo (audit §8.13). */ + agentInfo: { name: string; version: string; [k: string]: unknown } | null + /** + * Currently in-flight JSON-RPC request being serviced. The proxy echoes this + * id back in the JSON-RPC response (audit §8.2). At most one request is + * processed per client at a time because onMessage is awaited serially. + */ + pendingJsonRpc: { + id: string | number | null + /** Legacy response type the handler will emit via send(). */ + responseType: string + } | null +} + +// Default fallback client identity (used only when the client provides none) +export const DEFAULT_CLIENT_INFO = Object.freeze({ + name: 'zed', + version: '1.0.0', +}) +export const DEFAULT_CLIENT_CAPABILITIES = Object.freeze({ + fs: { readTextFile: true, writeTextFile: true }, +}) + +/** + * Create a fresh ClientState with the default fallback client identity and + * capabilities. Used by every WebSocket open handler and the RCS relay. + */ +export function createClientState(): ClientState { + return { + process: null, + connection: null, + sessionId: null, + pendingPermissions: new Map(), + agentCapabilities: null, + promptCapabilities: null, + modelState: null, + isAlive: true, + jsonRpc: false, + clientInfo: { ...DEFAULT_CLIENT_INFO }, + clientCapabilities: { ...DEFAULT_CLIENT_CAPABILITIES }, + protocolVersion: null, + agentInfo: null, + pendingJsonRpc: null, + } +} + +// ContentBlock type matching @agentclientprotocol/sdk +export interface ContentBlock { + type: string + text?: string + data?: string + mimeType?: string + uri?: string + name?: string +} + +export type PermissionResponsePayload = { + requestId: string + outcome: { outcome: 'cancelled' } | { outcome: 'selected'; optionId: string } +} + +export type ProxyMessage = + | { type: 'connect' } + | { type: 'disconnect' } + | { type: 'new_session'; payload: { cwd?: string; permissionMode?: string } } + | { type: 'prompt'; payload: { content: ContentBlock[] } } + | { type: 'permission_response'; payload: PermissionResponsePayload } + | { type: 'cancel' } + | { type: 'set_session_model'; payload: { modelId: string } } + | { type: 'list_sessions'; payload: { cwd?: string; cursor?: string } } + | { type: 'load_session'; payload: { sessionId: string; cwd?: string } } + | { type: 'resume_session'; payload: { sessionId: string; cwd?: string } } + | { type: 'ping' } diff --git a/src/services/acp/agent.ts b/src/services/acp/agent.ts index c34cb56c8..031bcc50d 100644 --- a/src/services/acp/agent.ts +++ b/src/services/acp/agent.ts @@ -1,1297 +1,33 @@ /** - * ACP Agent implementation — bridges ACP protocol methods to Claude Code's - * internal QueryEngine / query() pipeline. + * ACP Agent module — public entrypoint (barrel) re-exporting from the + * `./agent/` sub-modules. * - * Architecture: Uses internal QueryEngine (not @anthropic-ai/claude-agent-sdk) - * to directly run queries, with a bridge layer converting SDKMessage → ACP SessionUpdate. + * The AcpAgent class is split across multiple sub-files for line-budget + * reasons: + * - `./agent/AcpAgent.js` — class shell + lightweight protocol handlers + * (initialize / authenticate / newSession / resumeSession / loadSession / + * listSessions / forkSession / closeSession / cancel / setSessionMode / + * setSessionModel) + small private helpers. + * - `./agent/createSessionMethod.js` — createSession (prototype-attached). + * - `./agent/sessionLifecycle.js` — getOrCreateSession / teardownSession / + * replaySessionHistory / applySessionMode / updateConfigOption + * (prototype-attached). + * - `./agent/promptFlow.js` — prompt / setSessionConfigOption + * (prototype-attached). + * - `./agent/sessionTypes.js` / `./agent/permissionMode.js` / + * `./agent/configOptions.js` / `./agent/promptQueue.js` / + * `./agent/internalAccessors.js` — pure helpers and types. + * + * The side-effect imports below populate AcpAgent.prototype with the heavy + * session-lifecycle and prompt-flow methods. They MUST run before any + * AcpAgent instance is constructed. Importing this barrel is the single + * entry point that guarantees that ordering. + * + * Tests import AcpAgent via '../agent.js'; external consumers (entry.ts) + * import via './agent.js'. Both resolve to this file. */ -import type { - Agent, - AgentSideConnection, - InitializeRequest, - InitializeResponse, - AuthenticateRequest, - AuthenticateResponse, - NewSessionRequest, - NewSessionResponse, - PromptRequest, - PromptResponse, - CancelNotification, - LoadSessionRequest, - LoadSessionResponse, - ListSessionsRequest, - ListSessionsResponse, - ResumeSessionRequest, - ResumeSessionResponse, - ForkSessionRequest, - ForkSessionResponse, - CloseSessionRequest, - CloseSessionResponse, - SetSessionModeRequest, - SetSessionModeResponse, - SetSessionModelRequest, - SetSessionModelResponse, - SetSessionConfigOptionRequest, - SetSessionConfigOptionResponse, - ClientCapabilities, - SessionModeState, - SessionModelState, - SessionConfigOption, -} from '@agentclientprotocol/sdk' -import { randomUUID, type UUID } from 'node:crypto' -import { dirname } from 'node:path' -import * as path from 'node:path' -import type { Message } from '../../types/message.js' -import { deserializeMessages } from '../../utils/conversationRecovery.js' -import { - getLastSessionLog, - sessionIdExists, -} from '../../utils/sessionStorage.js' -import { QueryEngine } from '../../QueryEngine.js' -import type { QueryEngineConfig } from '../../QueryEngine.js' -import type { Tools } from '../../Tool.js' -import { getTools } from '../../tools.js' -import { getEmptyToolPermissionContext } from '../../Tool.js' -import type { PermissionMode } from '../../types/permissions.js' -import type { Command } from '../../types/command.js' -import { getCommands } from '../../commands.js' -import { getAgentDefinitionsWithOverrides } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' -import { - setOriginalCwd, - switchSession, - getSessionProjectDir, -} from '../../bootstrap/state.js' -import type { SessionId } from '../../types/ids.js' -import { enableConfigs } from '../../utils/config.js' -import { FileStateCache } from '../../utils/fileStateCache.js' -import { getDefaultAppState } from '../../state/AppStateStore.js' -import type { AppState } from '../../state/AppStateStore.js' -import { createAcpCanUseTool } from './permissions.js' -import { - forwardSessionUpdates, - replayHistoryMessages, - type ToolUseCache, -} from './bridge.js' -import { - resolvePermissionMode, - computeSessionFingerprint, - sanitizeTitle, -} from './utils.js' -import { promptToQueryInput } from './promptConversion.js' -import { listSessionsImpl } from '../../utils/listSessionsImpl.js' -import { - resolveSessionFilePath, - readSessionLite, - extractJsonStringField, -} from '../../utils/sessionStoragePortable.js' -import { getMainLoopModel } from '../../utils/model/model.js' -import { getModelOptions } from '../../utils/model/modelOptions.js' -import { getSettings_DEPRECATED } from '../../utils/settings/settings.js' +import './agent/createSessionMethod.js' +import './agent/sessionLifecycle.js' +import './agent/promptFlow.js' -// ── Session state ───────────────────────────────────────────────── - -type AcpSession = { - queryEngine: QueryEngine - cancelled: boolean - cancelGeneration: number - cwd: string - sessionFingerprint: string - modes: SessionModeState - models: SessionModelState - configOptions: SessionConfigOption[] - promptRunning: boolean - pendingMessages: Map - pendingQueue: string[] - pendingQueueHead: number - toolUseCache: ToolUseCache - clientCapabilities?: ClientCapabilities - appState: AppState - commands: Command[] -} - -type PendingPrompt = { - resolve: (cancelled: boolean) => void -} - -// ── Agent class ─────────────────────────────────────────────────── - -export class AcpAgent implements Agent { - private conn: AgentSideConnection - sessions = new Map() - private clientCapabilities?: ClientCapabilities - - constructor(conn: AgentSideConnection) { - this.conn = conn - } - - // ── initialize ──────────────────────────────────────────────── - - async initialize(params: InitializeRequest): Promise { - this.clientCapabilities = params.clientCapabilities - - return { - protocolVersion: 1, - // Explicit empty authMethods signals "no authentication required" to - // Clients rather than "capability unknown". Matches authenticate() no-op. - authMethods: [], - agentInfo: { - name: 'claude-code', - title: 'Claude Code', - version: - typeof (globalThis as unknown as Record).MACRO === - 'object' && - (globalThis as unknown as Record>) - .MACRO !== null - ? String( - ( - ( - globalThis as unknown as Record< - string, - Record - > - ).MACRO as Record - ).VERSION ?? '0.0.0', - ) - : '0.0.0', - }, - agentCapabilities: { - _meta: { - claudeCode: { - promptQueueing: true, - // session/fork is UNSTABLE — not part of stable v1 SessionCapabilities. - // Advertise via _meta namespace per extensibility.mdx "Advertising - // Custom Capabilities" instead of the standard sessionCapabilities map. - forkSession: true, - }, - }, - // image:false — promptToQueryInput() does not parse ContentBlock::Image - // blocks yet. Re-enable only after multimodal query input support lands. - promptCapabilities: { - image: false, - embeddedContext: true, - }, - mcpCapabilities: { - http: true, - sse: true, - }, - loadSession: true, - sessionCapabilities: { - list: {}, - resume: {}, - close: {}, - }, - }, - } - } - - // ── authenticate ────────────────────────────────────────────── - - async authenticate( - _params: AuthenticateRequest, - ): Promise { - // No authentication required — this is a self-hosted/custom deployment - return {} - } - - // ── newSession ──────────────────────────────────────────────── - - async newSession(params: NewSessionRequest): Promise { - const result = await this.createSession(params) - this.scheduleAvailableCommandsUpdate(result.sessionId) - return result - } - - // ── resumeSession ────────────────────────────────────────────── - - async unstable_resumeSession( - params: ResumeSessionRequest, - ): Promise { - // Per session-setup.mdx "Resuming a Session": the Agent MUST NOT replay the - // conversation history via session/update notifications before responding. - // Only restore context + MCP connections, then return immediately. This - // differs from session/load which DOES replay history. - const result = await this.getOrCreateSession({ ...params, replay: false }) - this.scheduleAvailableCommandsUpdate(result.sessionId) - return result - } - - // ── loadSession ──────────────────────────────────────────────── - - async loadSession(params: LoadSessionRequest): Promise { - const result = await this.getOrCreateSession(params) - this.scheduleAvailableCommandsUpdate(result.sessionId) - return result - } - - // ── listSessions ─────────────────────────────────────────────── - - async listSessions( - params: ListSessionsRequest, - ): Promise { - // Pagination is not implemented: we always return all available sessions - // for the requested cwd (no nextCursor). Per session-list.mdx the Agent - // SHOULD return an error if the cursor is invalid, so explicitly reject - // any client-supplied cursor rather than silently accepting it. - if (params.cursor !== undefined && params.cursor !== null) { - throw new Error( - 'Pagination cursor not supported: listSessions returns all results in a single page.', - ) - } - - const candidates = await listSessionsImpl({ - dir: params.cwd ?? undefined, - }) - - const sessions = [] - for (const candidate of candidates) { - if (!candidate.cwd) continue - // Only include title when non-empty; schema allows null/omitted title. - const title = sanitizeTitle(candidate.summary ?? '') - sessions.push({ - sessionId: candidate.sessionId, - cwd: candidate.cwd, - ...(title ? { title } : {}), - updatedAt: new Date(candidate.lastModified).toISOString(), - }) - } - - return { sessions } - } - - // ── forkSession ──────────────────────────────────────────────── - - async unstable_forkSession( - params: ForkSessionRequest, - ): Promise { - // Load the source session's messages so the fork actually branches from - // the source conversation rather than starting a blank session. Per the - // unstable ForkSessionRequest, params.sessionId is the ID to fork from. - let initialMessages: Message[] | undefined - try { - const log = await getLastSessionLog(params.sessionId as UUID) - if (log && log.messages.length > 0) { - initialMessages = deserializeMessages(log.messages) - } - } catch (err) { - console.error('[ACP] fork source load failed:', err) - } - const response = await this.createSession( - { - cwd: params.cwd, - mcpServers: params.mcpServers ?? [], - _meta: params._meta, - }, - { initialMessages }, - ) - this.scheduleAvailableCommandsUpdate(response.sessionId) - return response - } - - // ── closeSession ─────────────────────────────────────────────── - - async unstable_closeSession( - params: CloseSessionRequest, - ): Promise { - const session = this.sessions.get(params.sessionId) - if (!session) { - throw new Error('Session not found') - } - await this.teardownSession(params.sessionId) - return {} - } - - // ── prompt ──────────────────────────────────────────────────── - - async prompt(params: PromptRequest): Promise { - const session = this.sessions.get(params.sessionId) - if (!session) { - throw new Error(`Session ${params.sessionId} not found`) - } - - // Extract text/image content from the prompt - const promptInput = promptToQueryInput(params.prompt) - - // Per prompt-turn.mdx, `prompt` is a required ContentBlock[] and an - // effectively-empty prompt is malformed input — reject it with an - // invalid_params error rather than fabricating a successful end_turn. - if (!promptInput.trim()) { - throw new Error('Prompt content is empty') - } - - const promptCancelGeneration = session.cancelGeneration - - // Handle prompt queuing — if a prompt is already running, queue this one - if (session.promptRunning) { - const promptUuid = randomUUID() - const cancelled = await new Promise(resolve => { - session.pendingQueue.push(promptUuid) - session.pendingMessages.set(promptUuid, { resolve }) - }) - if (cancelled) { - return { stopReason: 'cancelled' } - } - } - - if (session.cancelGeneration !== promptCancelGeneration) { - return { stopReason: 'cancelled' } - } - - // Reset cancellation only when this prompt is about to run. Queued prompts - // must not clear the cancellation state for the active prompt. - session.cancelled = false - session.promptRunning = true - - try { - // Reset the query engine's abort controller for a fresh query. - // After a previous interrupt(), the internal controller is stuck in - // aborted state — without this, submitMessage() fails immediately. - session.queryEngine.resetAbortController() - // Switch global session state so recordTranscript writes to the correct - // session file. Without this, multi-session scenarios (or creating a new - // session after another) write transcript data to the wrong file. - switchSession(params.sessionId as SessionId, getSessionProjectDir()) - - const sdkMessages = session.queryEngine.submitMessage(promptInput) - - const { stopReason, usage } = await forwardSessionUpdates( - params.sessionId, - sdkMessages, - this.conn, - session.queryEngine.getAbortSignal(), - session.toolUseCache, - this.clientCapabilities, - session.cwd, - () => session.cancelled, - ) - - // If the session was cancelled during processing, return cancelled - if (session.cancelled) { - return { stopReason: 'cancelled' } - } - - // Emit a session_info_update so Clients learn the session's display - // title / last-activity timestamp via the stable v1 session/update - // channel. The title is derived from the first user prompt. - await this.maybeEmitSessionInfoUpdate(params.sessionId, promptInput) - - // Per extensibility.mdx:39 the root of PromptResponse is reserved — - // stable v1 defines only `stopReason` (+ optional `_meta`). Token usage - // is therefore carried under the `_meta.claudeCode.usage` extension - // namespace rather than as a non-spec root field. thoughtTokens are - // included in totalTokens so reported totals match billable tokens; - // until bridge.ts tracks them they are reported as 0. - if (usage) { - const thoughtTokens = 0 - return { - stopReason, - _meta: { - claudeCode: { - usage: { - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - cachedReadTokens: usage.cachedReadTokens, - cachedWriteTokens: usage.cachedWriteTokens, - thoughtTokens, - totalTokens: - usage.inputTokens + - usage.outputTokens + - usage.cachedReadTokens + - usage.cachedWriteTokens + - thoughtTokens, - }, - }, - }, - } - } - return { stopReason } - } catch (err: unknown) { - // Treat AbortError / cancellation-shaped errors as a turn cancellation - // regardless of the session.cancelled flag, to close the race window - // between interrupt() firing and cancel() setting the flag. Per - // prompt-turn.mdx the Agent MUST return `cancelled` for aborts. - const isAbort = - err instanceof Error && - (err.name === 'AbortError' || - /abort|cancelled|interrupt/i.test(err.message)) - if (session.cancelled || isAbort) { - return { stopReason: 'cancelled' } - } - - // Check for process death errors - if ( - err instanceof Error && - (err.message.includes('terminated') || - err.message.includes('process exited')) - ) { - this.teardownSession(params.sessionId) - throw new Error( - 'The Claude Agent process exited unexpectedly. Please start a new session.', - ) - } - - throw err - } finally { - // Resolve next pending prompt if any - const nextPrompt = popNextPendingPrompt(session) - if (nextPrompt) { - session.promptRunning = true - nextPrompt.resolve(false) - } else { - session.promptRunning = false - } - } - } - - // ── cancel ──────────────────────────────────────────────────── - - async cancel(params: CancelNotification): Promise { - const session = this.sessions.get(params.sessionId) - if (!session) return - - // Set cancelled flag — checked by prompt() loop to break out - session.cancelled = true - session.cancelGeneration += 1 - - // Cancel any queued prompts - for (const [, pending] of session.pendingMessages) { - pending.resolve(true) - } - session.pendingMessages.clear() - session.pendingQueue = [] - session.pendingQueueHead = 0 - - // Interrupt the query engine to abort the current API call - session.queryEngine.interrupt() - } - - // ── setSessionMode ────────────────────────────────────────────── - - async setSessionMode( - params: SetSessionModeRequest, - ): Promise { - const session = this.sessions.get(params.sessionId) - if (!session) { - throw new Error('Session not found') - } - - this.applySessionMode(params.sessionId, params.modeId) - // Per session-modes.mdx: when the Agent changes its own mode it MUST send - // a current_mode_update notification so mode-only Clients learn the - // switch. Mirrors the current_mode_update sent by setSessionConfigOption - // when configId === 'mode'. - await this.conn.sessionUpdate({ - sessionId: params.sessionId, - update: { - sessionUpdate: 'current_mode_update', - currentModeId: params.modeId, - }, - }) - await this.updateConfigOption(params.sessionId, 'mode', params.modeId) - return {} - } - - // ── setSessionModel ───────────────────────────────────────────── - - async unstable_setSessionModel( - params: SetSessionModelRequest, - ): Promise { - const session = this.sessions.get(params.sessionId) - if (!session) { - throw new Error('Session not found') - } - // Store the raw value — QueryEngine.submitMessage() calls - // parseUserSpecifiedModel() to resolve aliases (e.g. "sonnet" → "glm-5.1-turbo") - session.queryEngine.setModel(params.modelId) - await this.updateConfigOption(params.sessionId, 'model', params.modelId) - return {} - } - - // ── setSessionConfigOption ────────────────────────────────────── - - async setSessionConfigOption( - params: SetSessionConfigOptionRequest, - ): Promise { - const session = this.sessions.get(params.sessionId) - if (!session) { - throw new Error('Session not found') - } - if (typeof params.value !== 'string') { - throw new Error( - `Invalid value for config option ${params.configId}: ${String(params.value)}`, - ) - } - - const option = session.configOptions.find(o => o.id === params.configId) - if (!option) { - throw new Error(`Unknown config option: ${params.configId}`) - } - - // Per session-config-options.mdx: value MUST be one of the values listed - // in the option's options array. Reject unknown values with an error - // rather than silently persisting them. Only `select` options carry an - // options array; `boolean` options have no enumerated values. - if (option.type === 'select') { - const validValues = flattenConfigOptionValues( - (option as { options?: unknown }).options, - ) - if (!validValues.includes(params.value)) { - throw new Error( - `Invalid value '${params.value}' for config option ${params.configId}; must be one of: ${validValues.join(', ')}`, - ) - } - } - - const value = params.value - - if (params.configId === 'mode') { - this.applySessionMode(params.sessionId, value) - await this.conn.sessionUpdate({ - sessionId: params.sessionId, - update: { - sessionUpdate: 'current_mode_update', - currentModeId: value, - }, - }) - } else if (params.configId === 'model') { - session.queryEngine.setModel(value) - } - - this.syncSessionConfigState(session, params.configId, value) - - session.configOptions = session.configOptions.map(o => - o.id === params.configId && typeof o.currentValue === 'string' - ? { ...o, currentValue: value } - : o, - ) - - return { configOptions: session.configOptions } - } - - // ── Private helpers ───────────────────────────────────────────── - - private async createSession( - params: NewSessionRequest, - opts: { - forceNewId?: boolean - sessionId?: string - initialMessages?: Message[] - } = {}, - ): Promise { - enableConfigs() - - const sessionId = opts.sessionId ?? randomUUID() - const cwd = params.cwd - - // Align the global session state so that transcript persistence, - // analytics, and cost tracking use the ACP session ID. - // Preserve the projectDir set by getOrCreateSession so that - // getSessionProjectDir() continues to resolve correctly. - const currentProjectDir = getSessionProjectDir() - switchSession(sessionId as SessionId, currentProjectDir) - - // Set CWD for the session - setOriginalCwd(cwd) - const previousProcessCwd = process.cwd() - let processCwdChanged = false - try { - process.chdir(cwd) - processCwdChanged = true - } catch { - // CWD may not exist yet; best-effort - } - - try { - // Build tools with a permissive permission context. - const permissionContext = getEmptyToolPermissionContext() - const tools: Tools = getTools(permissionContext) - - // Parse permission mode from _meta (passed by RCS/acp-link) or settings. - const meta = params._meta as Record | null | undefined - const hasMetaPermissionMode = hasOwnField(meta, 'permissionMode') - const metaPermissionMode = hasMetaPermissionMode - ? meta?.permissionMode - : undefined - const settingsPermissionMode = this.getSetting( - 'permissions.defaultMode', - ) - const permissionMode = resolveSessionPermissionMode( - metaPermissionMode, - hasMetaPermissionMode, - settingsPermissionMode, - ) - - // Create the permission bridge canUseTool function - const canUseTool = createAcpCanUseTool( - this.conn, - sessionId, - () => this.sessions.get(sessionId)?.modes.currentModeId ?? 'default', - this.clientCapabilities, - cwd, - (modeId: string) => { - this.applySessionMode(sessionId, modeId) - }, - () => - this.sessions.get(sessionId)?.appState.toolPermissionContext - .isBypassPermissionsModeAvailable ?? false, - ) - - // Parse MCP servers from ACP params - // MCP server config is handled separately in the tools system - - // ACP clients can expose bypass only when both the process and local config allow it. - const isBypassAvailable = isAcpBypassPermissionModeAvailable( - settingsPermissionMode, - ) - - // Create a mutable AppState for the session - const appState: AppState = { - ...getDefaultAppState(), - toolPermissionContext: { - ...permissionContext, - mode: permissionMode as PermissionMode, - isBypassPermissionsModeAvailable: isBypassAvailable, - }, - } - - // Load commands and agent definitions for subagent support - const [commands, agentDefinitionsResult] = await Promise.all([ - getCommands(cwd), - getAgentDefinitionsWithOverrides(cwd), - ]) - - // Inject agent definitions into appState - appState.agentDefinitions = agentDefinitionsResult - - // Build QueryEngine config - const engineConfig: QueryEngineConfig = { - cwd, - tools, - commands, - mcpClients: [], - agents: agentDefinitionsResult.activeAgents, - canUseTool, - getAppState: () => appState, - setAppState: (updater: (prev: AppState) => AppState) => { - const updated = updater(appState) - Object.assign(appState, updated) - }, - readFileCache: new FileStateCache(500, 50 * 1024 * 1024), - includePartialMessages: true, - replayUserMessages: true, - initialMessages: opts.initialMessages, - } - - const queryEngine = new QueryEngine(engineConfig) - - // Build modes — bypassPermissions is opt-in for ACP clients. - const availableModes = [ - { - id: 'default', - name: 'Default', - description: 'Standard behavior, prompts for dangerous operations', - }, - { - id: 'acceptEdits', - name: 'Accept Edits', - description: 'Auto-accept file edit operations', - }, - { - id: 'plan', - name: 'Plan Mode', - description: 'Planning mode, no actual tool execution', - }, - { - id: 'auto', - name: 'Auto', - description: - 'Use a model classifier to approve/deny permission prompts.', - }, - ...(isBypassAvailable - ? [ - { - id: 'bypassPermissions' as const, - name: 'Bypass Permissions', - description: 'Skip all permission checks', - }, - ] - : []), - { - id: 'dontAsk', - name: "Don't Ask", - description: "Don't prompt for permissions, deny if not pre-approved", - }, - ] - - const modes: SessionModeState = { - currentModeId: permissionMode, - availableModes, - } - - // Build models - const modelOptions = getModelOptions() - const currentModel = getMainLoopModel() - const models: SessionModelState = { - availableModels: modelOptions.map(m => ({ - modelId: String(m.value ?? ''), - name: m.label ?? String(m.value ?? ''), - description: m.description ?? undefined, - })), - currentModelId: currentModel, - } - - // Set the model on the engine - queryEngine.setModel(currentModel) - - // Build config options - const configOptions = buildConfigOptions(modes, models) - - const session: AcpSession = { - queryEngine, - cancelled: false, - cancelGeneration: 0, - cwd, - modes, - models, - configOptions, - promptRunning: false, - pendingMessages: new Map(), - pendingQueue: [], - pendingQueueHead: 0, - toolUseCache: {}, - clientCapabilities: this.clientCapabilities, - appState, - commands, - sessionFingerprint: computeSessionFingerprint({ - cwd, - mcpServers: params.mcpServers as - | Array<{ name: string; [key: string]: unknown }> - | undefined, - }), - } - - this.sessions.set(sessionId, session) - - // Stable v1 NewSessionResponse only defines sessionId/modes/configOptions. - // `models` is a draft/unstable field — omit it for v1 compliance. - return { - sessionId, - modes, - configOptions, - } - } finally { - if (processCwdChanged) { - process.chdir(previousProcessCwd) - } - } - } - - private async getOrCreateSession(params: { - sessionId: string - cwd: string - mcpServers?: NewSessionRequest['mcpServers'] - _meta?: NewSessionRequest['_meta'] - // replay:true (default, session/load) streams the conversation history back - // to the client via session/update. replay:false (session/resume) only - // restores the in-process context — per session-setup.mdx the Agent MUST - // NOT replay history when resuming. - replay?: boolean - }): Promise { - const shouldReplay = params.replay !== false - const existingSession = this.sessions.get(params.sessionId) - if (existingSession) { - const fingerprint = computeSessionFingerprint({ - cwd: params.cwd, - mcpServers: params.mcpServers as - | Array<{ name: string; [key: string]: unknown }> - | undefined, - }) - if (fingerprint === existingSession.sessionFingerprint) { - const resolved = await resolveSessionFilePath( - params.sessionId, - params.cwd, - ) - switchSession( - params.sessionId as SessionId, - resolved ? dirname(resolved.filePath) : null, - ) - setOriginalCwd(params.cwd) - - if (shouldReplay) { - await this.replaySessionHistory(params) - } - - return { - sessionId: params.sessionId, - modes: existingSession.modes, - configOptions: existingSession.configOptions, - } - } - - await this.teardownSession(params.sessionId) - } - - // Locate the session file by sessionId across all project directories. - // params.cwd may not match the project directory where the session was - // originally created (e.g. client sends a subdirectory path), so we - // search by sessionId first and fall back to cwd-based lookup. - const resolved = await resolveSessionFilePath(params.sessionId, params.cwd) - const projectDir = resolved ? dirname(resolved.filePath) : null - - // Per session-setup.mdx "Working Directory": the cwd MUST be the absolute - // path used for the session regardless of where the Agent was spawned. - // Reject cross-project loads where the persisted session's original cwd - // does not match the requested cwd, otherwise the client could load a - // session belonging to project B while passing project A's cwd. - if (resolved) { - const lite = await readSessionLite(resolved.filePath) - const originalCwd = lite && extractJsonStringField(lite.head, 'cwd') - if ( - originalCwd && - path.resolve(originalCwd) !== path.resolve(params.cwd) - ) { - throw new Error( - `Session cwd mismatch: session belongs to ${originalCwd}, requested ${params.cwd}`, - ) - } - } - - switchSession(params.sessionId as SessionId, projectDir) - setOriginalCwd(params.cwd) - - let initialMessages: Message[] | undefined - if (resolved) { - try { - const log = await getLastSessionLog(params.sessionId as UUID) - if (log && log.messages.length > 0) { - initialMessages = deserializeMessages(log.messages) - } - } catch (err) { - console.error('[ACP] Failed to load session history:', err) - } - } - - const response = await this.createSession( - { - cwd: params.cwd, - mcpServers: params.mcpServers ?? [], - _meta: params._meta, - }, - { sessionId: params.sessionId, initialMessages }, - ) - - // Replay history to client if loaded. session/resume skips this block. - if (shouldReplay && initialMessages && initialMessages.length > 0) { - const session = this.sessions.get(params.sessionId) - if (session) { - await replayHistoryMessages( - params.sessionId, - initialMessages as unknown as Array>, - this.conn, - session.toolUseCache, - this.clientCapabilities, - session.cwd, - ) - } - } - - return { - sessionId: response.sessionId, - modes: response.modes, - configOptions: response.configOptions, - } - } - - private async teardownSession(sessionId: string): Promise { - const session = this.sessions.get(sessionId) - if (!session) return - - await this.cancel({ sessionId }) - this.sessions.delete(sessionId) - } - - /** - * Load session history from disk and replay it to the ACP client. - * Used when switching back to a session that is already in memory - * (the client needs the conversation replayed to display it). - */ - private async replaySessionHistory(params: { - sessionId: string - cwd: string - }): Promise { - try { - const log = await getLastSessionLog(params.sessionId as UUID) - if (!log || log.messages.length === 0) return - const messages = deserializeMessages(log.messages) - if (messages.length === 0) return - - const session = this.sessions.get(params.sessionId) - if (!session) return - - await replayHistoryMessages( - params.sessionId, - messages as unknown as Array>, - this.conn, - session.toolUseCache, - this.clientCapabilities, - session.cwd, - ) - } catch (err) { - console.error('[ACP] Failed to replay session history:', err) - } - } - - private applySessionMode(sessionId: string, modeId: string): void { - if (!isPermissionMode(modeId)) { - throw new Error(`Invalid mode: ${modeId}`) - } - const session = this.sessions.get(sessionId) - if (session) { - if ( - modeId === 'bypassPermissions' && - !session.appState.toolPermissionContext.isBypassPermissionsModeAvailable - ) { - throw new Error(`Mode not available: ${modeId}`) - } - const isAvailable = session.modes.availableModes.some( - mode => mode.id === modeId, - ) - if (!isAvailable) { - throw new Error(`Mode not available: ${modeId}`) - } - - session.modes = { ...session.modes, currentModeId: modeId } - // Sync mode to appState so the permission pipeline sees the correct mode - session.appState.toolPermissionContext = { - ...session.appState.toolPermissionContext, - mode: modeId as PermissionMode, - } - } - } - - private async updateConfigOption( - sessionId: string, - configId: string, - value: string, - ): Promise { - const session = this.sessions.get(sessionId) - if (!session) return - - this.syncSessionConfigState(session, configId, value) - - session.configOptions = session.configOptions.map(o => - o.id === configId && typeof o.currentValue === 'string' - ? { ...o, currentValue: value } - : o, - ) - - await this.conn.sessionUpdate({ - sessionId, - update: { - sessionUpdate: 'config_option_update', - configOptions: session.configOptions, - }, - }) - } - - private syncSessionConfigState( - session: AcpSession, - configId: string, - value: string, - ): void { - if (configId === 'mode') { - session.modes = { ...session.modes, currentModeId: value } - } else if (configId === 'model') { - session.models = { ...session.models, currentModelId: value } - } - } - - private async sendAvailableCommandsUpdate(sessionId: string): Promise { - const session = this.sessions.get(sessionId) - if (!session) return - - const availableCommands = session.commands - .filter( - cmd => - cmd.type === 'prompt' && !cmd.isHidden && cmd.userInvocable !== false, - ) - .map(cmd => ({ - name: cmd.name, - description: cmd.description, - input: cmd.argumentHint ? { hint: cmd.argumentHint } : undefined, - })) - - await this.conn.sessionUpdate({ - sessionId, - update: { - sessionUpdate: 'available_commands_update', - availableCommands, - }, - }) - } - - private scheduleAvailableCommandsUpdate(sessionId: string): void { - setTimeout(() => { - void this.sendAvailableCommandsUpdate(sessionId).catch(err => { - console.error('[ACP] Failed to send available commands update:', err) - }) - }, 0) - } - - /** - * Emit a session_info_update notification carrying a derived session title - * (truncated first user prompt) and the current last-activity timestamp. - * Sent once per session — subsequent turns reuse the same title. - */ - private async maybeEmitSessionInfoUpdate( - sessionId: string, - firstPrompt: string, - ): Promise { - const session = this.sessions.get(sessionId) - if (!session) return - // sessionInfoTitleSent is tracked via toolUseCache to avoid reshaping - // AcpSession; use a dedicated per-session flag instead. - const cache = session.toolUseCache as ToolUseCache & { - __sessionInfoTitleSent?: boolean - } - if (cache.__sessionInfoTitleSent) return - cache.__sessionInfoTitleSent = true - const title = sanitizeTitle(firstPrompt).slice(0, 100) - try { - await this.conn.sessionUpdate({ - sessionId, - update: { - sessionUpdate: 'session_info_update', - ...(title ? { title } : {}), - updatedAt: new Date().toISOString(), - }, - }) - } catch (err) { - console.error('[ACP] Failed to send session_info_update:', err) - } - } - - /** Read a setting from Claude config (simplified — no file watching) */ - private getSetting(key: string): T | undefined { - const settings = getSettings_DEPRECATED() as Record - const value = key.split('.').reduce((current, segment) => { - if (!current || typeof current !== 'object') return undefined - return (current as Record)[segment] - }, settings) - return value as T | undefined - } -} - -// ── Helpers ──────────────────────────────────────────────────────── - -const permissionModeIds: readonly PermissionMode[] = [ - 'auto', - 'default', - 'acceptEdits', - 'bypassPermissions', - 'dontAsk', - 'plan', -] - -function isPermissionMode(modeId: string): modeId is PermissionMode { - return (permissionModeIds as readonly string[]).includes(modeId) -} - -function resolveSessionPermissionMode( - metaMode: unknown, - hasMetaMode: boolean, - settingsMode: unknown, -): PermissionMode { - if (hasMetaMode) { - const metaResolved = resolveRequiredPermissionMode( - metaMode, - '_meta.permissionMode', - ) - if ( - metaResolved === 'bypassPermissions' && - !isAcpBypassPermissionModeAvailable(settingsMode) - ) { - throw new Error( - 'Mode not available: bypassPermissions requires a local ACP bypass opt-in.', - ) - } - - return metaResolved - } - - const settingsResolved = resolveConfiguredPermissionMode(settingsMode) - return settingsResolved ?? 'default' -} - -function resolveRequiredPermissionMode( - mode: unknown, - source: string, -): PermissionMode { - if (mode === undefined || mode === null) { - throw new Error(`Invalid ${source}: expected a string.`) - } - - return resolvePermissionMode(mode, source) as PermissionMode -} - -function resolveConfiguredPermissionMode( - mode: unknown, -): PermissionMode | undefined { - if (mode === undefined || mode === null) return undefined - - try { - return resolvePermissionMode( - mode, - 'permissions.defaultMode', - ) as PermissionMode - } catch (err: unknown) { - const reason = err instanceof Error ? err.message : String(err) - console.error( - '[ACP] Invalid permissions.defaultMode, using default:', - reason, - ) - return undefined - } -} - -function hasOwnField( - value: Record | null | undefined, - key: string, -): boolean { - return !!value && Object.hasOwn(value, key) -} - -function isAcpBypassPermissionModeAvailable(settingsMode?: unknown): boolean { - return ( - isProcessBypassPermissionModeAvailable() && - (isAcpBypassLocallyEnabled() || - isSettingsBypassPermissionMode(settingsMode)) - ) -} - -function isProcessBypassPermissionModeAvailable(): boolean { - if (process.env.IS_SANDBOX) return true - if (typeof process.geteuid === 'function') return process.geteuid() !== 0 - if (typeof process.getuid === 'function') return process.getuid() !== 0 - return true -} - -function isAcpBypassLocallyEnabled(): boolean { - return ( - process.env.ACP_PERMISSION_MODE === 'bypassPermissions' || - isTruthyEnv(process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS) - ) -} - -function isSettingsBypassPermissionMode(settingsMode: unknown): boolean { - try { - return resolvePermissionMode(settingsMode) === 'bypassPermissions' - } catch { - return false - } -} - -function isTruthyEnv(value: string | undefined): boolean { - return value === '1' || value?.toLowerCase() === 'true' -} - -/** - * Flatten a SessionConfigOption's `options` (which may be flat - * SessionConfigSelectOption entries or grouped SessionConfigSelectGroup - * entries) into a list of valid value strings. Used to validate that a - * setSessionConfigOption value is one of the listed options. - */ -function flattenConfigOptionValues(options: unknown): string[] { - const values: string[] = [] - if (!Array.isArray(options)) return values - for (const opt of options) { - if (typeof opt !== 'object' || opt === null) continue - const maybeGroup = opt as { group?: unknown; options?: unknown[] } - if (Array.isArray(maybeGroup.options)) { - // SessionConfigSelectGroup — recurse into its options - for (const inner of maybeGroup.options) { - if ( - inner && - typeof inner === 'object' && - typeof (inner as { value?: unknown }).value === 'string' - ) { - values.push((inner as { value: string }).value) - } - } - } else if (typeof (opt as { value?: unknown }).value === 'string') { - // SessionConfigSelectOption - values.push((opt as { value: string }).value) - } - } - return values -} - -function popNextPendingPrompt(session: AcpSession): PendingPrompt | undefined { - while (session.pendingQueueHead < session.pendingQueue.length) { - const nextId = session.pendingQueue[session.pendingQueueHead++] - if (!nextId) continue - const next = session.pendingMessages.get(nextId) - if (!next) continue - session.pendingMessages.delete(nextId) - compactPendingQueue(session) - return next - } - - compactPendingQueue(session) - return undefined -} - -function compactPendingQueue(session: AcpSession): void { - if (session.pendingQueueHead === 0) return - - if (session.pendingQueueHead >= session.pendingQueue.length) { - session.pendingQueue = [] - session.pendingQueueHead = 0 - return - } - - if ( - session.pendingQueueHead > 1024 && - session.pendingQueueHead * 2 > session.pendingQueue.length - ) { - session.pendingQueue = session.pendingQueue.slice(session.pendingQueueHead) - session.pendingQueueHead = 0 - } -} - -function buildConfigOptions( - modes: SessionModeState, - models: SessionModelState, -): SessionConfigOption[] { - return [ - { - id: 'mode', - name: 'Mode', - description: 'Session permission mode', - category: 'mode', - type: 'select' as const, - currentValue: modes.currentModeId, - options: modes.availableModes.map( - (m: SessionModeState['availableModes'][number]) => ({ - value: m.id, - name: m.name, - description: m.description, - }), - ), - }, - { - id: 'model', - name: 'Model', - description: 'AI model to use', - category: 'model', - type: 'select' as const, - currentValue: models.currentModelId, - options: models.availableModels.map( - (m: SessionModelState['availableModels'][number]) => ({ - value: m.modelId, - name: m.name, - description: m.description ?? undefined, - }), - ), - }, - ] as SessionConfigOption[] -} +export { AcpAgent } from './agent/AcpAgent.js' diff --git a/src/services/acp/agent/AcpAgent.ts b/src/services/acp/agent/AcpAgent.ts new file mode 100644 index 000000000..453115fb4 --- /dev/null +++ b/src/services/acp/agent/AcpAgent.ts @@ -0,0 +1,404 @@ +/** + * ACP Agent implementation — bridges ACP protocol methods to Claude Code's + * internal QueryEngine / query() pipeline. + * + * Architecture: Uses internal QueryEngine (not @anthropic-ai/claude-agent-sdk) + * to directly run queries, with a bridge layer converting SDKMessage → ACP SessionUpdate. + * + * NOTE: The AcpAgent class is split across three modules for line-budget reasons. + * The class shell + lightweight protocol handlers live here; the heavy + * session-lifecycle methods (createSession / getOrCreateSession / + * replaySessionHistory / teardownSession / applySessionMode / updateConfigOption) + * are attached to the prototype in `./sessionLifecycle.js`, and the prompt + * flow (prompt / setSessionConfigOption) in `./promptFlow.js`. The barrel + * `./index.js` imports those side-effect modules so the prototype is fully + * populated before any AcpAgent instance is constructed. + */ +import type { + Agent, + AgentSideConnection, + InitializeRequest, + InitializeResponse, + AuthenticateRequest, + AuthenticateResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + CancelNotification, + LoadSessionRequest, + LoadSessionResponse, + ListSessionsRequest, + ListSessionsResponse, + ResumeSessionRequest, + ResumeSessionResponse, + ForkSessionRequest, + ForkSessionResponse, + CloseSessionRequest, + CloseSessionResponse, + SetSessionModeRequest, + SetSessionModeResponse, + SetSessionModelRequest, + SetSessionModelResponse, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, + ClientCapabilities, +} from '@agentclientprotocol/sdk' +import type { Message } from '../../../types/message.js' +import { sanitizeTitle } from '../utils.js' +import { listSessionsImpl } from '../../../utils/listSessionsImpl.js' +import type { AcpSession } from './sessionTypes.js' + +// ── Agent class ─────────────────────────────────────────────────── +// +// NOTE: This class is intentionally merged with the `AcpAgent` interface +// declared at the bottom of this file. The merged interface declares methods +// that are attached to AcpAgent.prototype at module load time by the sibling +// side-effect modules (createSessionMethod.ts / sessionLifecycle.ts / +// promptFlow.ts) imported by the barrel (./agent.ts). This is the standard +// prototype-augmentation pattern and is safe because the barrel guarantees +// the side-effect imports run before any instance is constructed. +// biome-ignore lint/suspicious/noUnsafeDeclarationMerging: prototype-augmentation pattern — merged interface methods are attached to AcpAgent.prototype by sibling side-effect modules imported by the barrel (./agent.ts) before any instance is constructed. +export class AcpAgent implements Agent { + private conn: AgentSideConnection + sessions = new Map() + private clientCapabilities?: ClientCapabilities + + constructor(conn: AgentSideConnection) { + this.conn = conn + } + + // ── initialize ──────────────────────────────────────────────── + + async initialize(params: InitializeRequest): Promise { + this.clientCapabilities = params.clientCapabilities + + return { + protocolVersion: 1, + // Explicit empty authMethods signals "no authentication required" to + // Clients rather than "capability unknown". Matches authenticate() no-op. + authMethods: [], + agentInfo: { + name: 'claude-code', + title: 'Claude Code', + version: + typeof (globalThis as unknown as Record).MACRO === + 'object' && + (globalThis as unknown as Record>) + .MACRO !== null + ? String( + ( + ( + globalThis as unknown as Record< + string, + Record + > + ).MACRO as Record + ).VERSION ?? '0.0.0', + ) + : '0.0.0', + }, + agentCapabilities: { + _meta: { + claudeCode: { + promptQueueing: true, + // session/fork is UNSTABLE — not part of stable v1 SessionCapabilities. + // Advertise via _meta namespace per extensibility.mdx "Advertising + // Custom Capabilities" instead of the standard sessionCapabilities map. + forkSession: true, + }, + }, + // image:false — promptToQueryInput() does not parse ContentBlock::Image + // blocks yet. Re-enable only after multimodal query input support lands. + promptCapabilities: { + image: false, + embeddedContext: true, + }, + mcpCapabilities: { + http: true, + sse: true, + }, + loadSession: true, + sessionCapabilities: { + list: {}, + resume: {}, + close: {}, + }, + }, + } + } + + // ── authenticate ────────────────────────────────────────────── + + async authenticate( + _params: AuthenticateRequest, + ): Promise { + // No authentication required — this is a self-hosted/custom deployment + return {} + } + + // ── newSession ──────────────────────────────────────────────── + + async newSession(params: NewSessionRequest): Promise { + const result = await this.createSession(params) + this.scheduleAvailableCommandsUpdate(result.sessionId) + return result + } + + // ── resumeSession ────────────────────────────────────────────── + + async unstable_resumeSession( + params: ResumeSessionRequest, + ): Promise { + // Per session-setup.mdx "Resuming a Session": the Agent MUST NOT replay the + // conversation history via session/update notifications before responding. + // Only restore context + MCP connections, then return immediately. This + // differs from session/load which DOES replay history. + const result = await this.getOrCreateSession({ ...params, replay: false }) + this.scheduleAvailableCommandsUpdate(result.sessionId) + return result + } + + // ── loadSession ──────────────────────────────────────────────── + + async loadSession(params: LoadSessionRequest): Promise { + const result = await this.getOrCreateSession(params) + this.scheduleAvailableCommandsUpdate(result.sessionId) + return result + } + + // ── listSessions ─────────────────────────────────────────────── + + async listSessions( + params: ListSessionsRequest, + ): Promise { + // Pagination is not implemented: we always return all available sessions + // for the requested cwd (no nextCursor). Per session-list.mdx the Agent + // SHOULD return an error if the cursor is invalid, so explicitly reject + // any client-supplied cursor rather than silently accepting it. + if (params.cursor !== undefined && params.cursor !== null) { + throw new Error( + 'Pagination cursor not supported: listSessions returns all results in a single page.', + ) + } + + const candidates = await listSessionsImpl({ + dir: params.cwd ?? undefined, + }) + + const sessions = [] + for (const candidate of candidates) { + if (!candidate.cwd) continue + // Only include title when non-empty; schema allows null/omitted title. + const title = sanitizeTitle(candidate.summary ?? '') + sessions.push({ + sessionId: candidate.sessionId, + cwd: candidate.cwd, + ...(title ? { title } : {}), + updatedAt: new Date(candidate.lastModified).toISOString(), + }) + } + + return { sessions } + } + + // ── forkSession ──────────────────────────────────────────────── + + async unstable_forkSession( + params: ForkSessionRequest, + ): Promise { + // Load the source session's messages so the fork actually branches from + // the source conversation rather than starting a blank session. Per the + // unstable ForkSessionRequest, params.sessionId is the ID to fork from. + const { initialMessages } = await loadForkSourceMessages(params.sessionId) + const response = await this.createSession( + { + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + _meta: params._meta, + }, + { initialMessages }, + ) + this.scheduleAvailableCommandsUpdate(response.sessionId) + return response + } + + // ── closeSession ─────────────────────────────────────────────── + + async unstable_closeSession( + params: CloseSessionRequest, + ): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error('Session not found') + } + await this.teardownSession(params.sessionId) + return {} + } + + // ── cancel ──────────────────────────────────────────────────── + + async cancel(params: CancelNotification): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) return + + // Set cancelled flag — checked by prompt() loop to break out + session.cancelled = true + session.cancelGeneration += 1 + + // Cancel any queued prompts + for (const [, pending] of session.pendingMessages) { + pending.resolve(true) + } + session.pendingMessages.clear() + session.pendingQueue = [] + session.pendingQueueHead = 0 + + // Interrupt the query engine to abort the current API call + session.queryEngine.interrupt() + } + + // ── setSessionMode ────────────────────────────────────────────── + + async setSessionMode( + params: SetSessionModeRequest, + ): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error('Session not found') + } + + this.applySessionMode(params.sessionId, params.modeId) + // Per session-modes.mdx: when the Agent changes its own mode it MUST send + // a current_mode_update notification so mode-only Clients learn the + // switch. Mirrors the current_mode_update sent by setSessionConfigOption + // when configId === 'mode'. + await this.conn.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: 'current_mode_update', + currentModeId: params.modeId, + }, + }) + await this.updateConfigOption(params.sessionId, 'mode', params.modeId) + return {} + } + + // ── setSessionModel ───────────────────────────────────────────── + + async unstable_setSessionModel( + params: SetSessionModelRequest, + ): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error('Session not found') + } + // Store the raw value — QueryEngine.submitMessage() calls + // parseUserSpecifiedModel() to resolve aliases (e.g. "sonnet" → "glm-5.1-turbo") + session.queryEngine.setModel(params.modelId) + await this.updateConfigOption(params.sessionId, 'model', params.modelId) + return {} + } + + // ── Private helpers (lightweight, kept with the class) ────────── + + private async sendAvailableCommandsUpdate(sessionId: string): Promise { + const session = this.sessions.get(sessionId) + if (!session) return + + const availableCommands = session.commands + .filter( + cmd => + cmd.type === 'prompt' && !cmd.isHidden && cmd.userInvocable !== false, + ) + .map(cmd => ({ + name: cmd.name, + description: cmd.description, + input: cmd.argumentHint ? { hint: cmd.argumentHint } : undefined, + })) + + await this.conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands, + }, + }) + } + + private scheduleAvailableCommandsUpdate(sessionId: string): void { + setTimeout(() => { + void this.sendAvailableCommandsUpdate(sessionId).catch(err => { + console.error('[ACP] Failed to send available commands update:', err) + }) + }, 0) + } +} + +// ── Prototype-attached methods (declared here for type safety) ──── +// +// The following methods are implemented in sibling modules +// (createSessionMethod.ts / sessionLifecycle.ts / promptFlow.ts) and attached +// to AcpAgent.prototype via Object.assign at module load time. They are +// declared on the class via TypeScript declaration merging so `this` is +// typed correctly in the prototype-augmentation modules. +export interface AcpAgent { + // ── prompt flow (promptFlow.ts) ─────────────────────────────── + prompt(params: PromptRequest): Promise + setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise + + // ── session lifecycle (sessionLifecycle.ts) ─────────────────── + createSession( + params: NewSessionRequest, + opts?: { + forceNewId?: boolean + sessionId?: string + initialMessages?: Message[] + }, + ): Promise + getOrCreateSession(params: { + sessionId: string + cwd: string + mcpServers?: NewSessionRequest['mcpServers'] + _meta?: NewSessionRequest['_meta'] + replay?: boolean + }): Promise + teardownSession(sessionId: string): Promise + replaySessionHistory(params: { + sessionId: string + cwd: string + }): Promise + applySessionMode(sessionId: string, modeId: string): void + updateConfigOption( + sessionId: string, + configId: string, + value: string, + ): Promise +} + +// ── Module-local helpers used only by the class shell ──────────── + +import { type UUID } from 'node:crypto' +import { deserializeMessages } from '../../../utils/conversationRecovery.js' +import { getLastSessionLog } from '../../../utils/sessionStorage.js' + +/** + * Load the source session's persisted messages for forkSession. + * Extracted as a module-local helper to keep the fork handler compact. + */ +async function loadForkSourceMessages( + sessionId: string, +): Promise<{ initialMessages: Message[] | undefined }> { + let initialMessages: Message[] | undefined + try { + const log = await getLastSessionLog(sessionId as UUID) + if (log && log.messages.length > 0) { + initialMessages = deserializeMessages(log.messages) + } + } catch (err) { + console.error('[ACP] fork source load failed:', err) + } + return { initialMessages } +} diff --git a/src/services/acp/agent/configOptions.ts b/src/services/acp/agent/configOptions.ts new file mode 100644 index 000000000..aa99cf13e --- /dev/null +++ b/src/services/acp/agent/configOptions.ts @@ -0,0 +1,74 @@ +import type { + SessionModeState, + SessionModelState, + SessionConfigOption, +} from '@agentclientprotocol/sdk' + +export function buildConfigOptions( + modes: SessionModeState, + models: SessionModelState, +): SessionConfigOption[] { + return [ + { + id: 'mode', + name: 'Mode', + description: 'Session permission mode', + category: 'mode', + type: 'select' as const, + currentValue: modes.currentModeId, + options: modes.availableModes.map( + (m: SessionModeState['availableModes'][number]) => ({ + value: m.id, + name: m.name, + description: m.description, + }), + ), + }, + { + id: 'model', + name: 'Model', + description: 'AI model to use', + category: 'model', + type: 'select' as const, + currentValue: models.currentModelId, + options: models.availableModels.map( + (m: SessionModelState['availableModels'][number]) => ({ + value: m.modelId, + name: m.name, + description: m.description ?? undefined, + }), + ), + }, + ] as SessionConfigOption[] +} + +/** + * Flatten a SessionConfigOption's `options` (which may be flat + * SessionConfigSelectOption entries or grouped SessionConfigSelectGroup + * entries) into a list of valid value strings. Used to validate that a + * setSessionConfigOption value is one of the listed options. + */ +export function flattenConfigOptionValues(options: unknown): string[] { + const values: string[] = [] + if (!Array.isArray(options)) return values + for (const opt of options) { + if (typeof opt !== 'object' || opt === null) continue + const maybeGroup = opt as { group?: unknown; options?: unknown[] } + if (Array.isArray(maybeGroup.options)) { + // SessionConfigSelectGroup — recurse into its options + for (const inner of maybeGroup.options) { + if ( + inner && + typeof inner === 'object' && + typeof (inner as { value?: unknown }).value === 'string' + ) { + values.push((inner as { value: string }).value) + } + } + } else if (typeof (opt as { value?: unknown }).value === 'string') { + // SessionConfigSelectOption + values.push((opt as { value: string }).value) + } + } + return values +} diff --git a/src/services/acp/agent/createSessionMethod.ts b/src/services/acp/agent/createSessionMethod.ts new file mode 100644 index 000000000..8e2cc04c2 --- /dev/null +++ b/src/services/acp/agent/createSessionMethod.ts @@ -0,0 +1,291 @@ +/** + * AcpAgent.prototype.createSession implementation, attached via Object.assign. + * Extracted from sessionLifecycle.ts to keep that module under the 500-line + * budget. The barrel (./index.ts) imports this module for its side effect. + */ +import { randomUUID } from 'node:crypto' +import type { + NewSessionRequest, + NewSessionResponse, + SessionModeState, + SessionModelState, +} from '@agentclientprotocol/sdk' +import type { Message } from '../../../types/message.js' +import { QueryEngine } from '../../../QueryEngine.js' +import type { QueryEngineConfig } from '../../../QueryEngine.js' +import type { Tools } from '../../../Tool.js' +import { getTools } from '../../../tools.js' +import { getEmptyToolPermissionContext } from '../../../Tool.js' +import type { PermissionMode } from '../../../types/permissions.js' +import { getCommands } from '../../../commands.js' +import { getAgentDefinitionsWithOverrides } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' +import { + setOriginalCwd, + switchSession, + getSessionProjectDir, +} from '../../../bootstrap/state.js' +import type { SessionId } from '../../../types/ids.js' +import { enableConfigs } from '../../../utils/config.js' +import { FileStateCache } from '../../../utils/fileStateCache.js' +import { getDefaultAppState } from '../../../state/AppStateStore.js' +import type { AppState } from '../../../state/AppStateStore.js' +import { createAcpCanUseTool } from '../permissions.js' +import { computeSessionFingerprint } from '../utils.js' +import { getMainLoopModel } from '../../../utils/model/model.js' +import { getModelOptions } from '../../../utils/model/modelOptions.js' +import { getSettings_DEPRECATED } from '../../../utils/settings/settings.js' +import { AcpAgent } from './AcpAgent.js' +import type { AcpSession } from './sessionTypes.js' +import { + resolveSessionPermissionMode, + isAcpBypassPermissionModeAvailable, + hasOwnField, +} from './permissionMode.js' +import { buildConfigOptions } from './configOptions.js' +import { readClientCapabilities } from './internalAccessors.js' + +/** + * Resolve the effective `permissions.defaultMode` setting by walking the + * settings object. Lives here so createSession can read it without depending + * on AcpAgent.getSetting (which is a private instance method on the shell). + */ +function readSettingsPermissionMode(): unknown { + const settings = getSettings_DEPRECATED() as Record + const perms = settings.permissions as Record | undefined + return perms?.defaultMode +} + +// ── createSession ──────────────────────────────────────────────── + +async function createSession( + this: AcpAgent, + params: NewSessionRequest, + opts: { + forceNewId?: boolean + sessionId?: string + initialMessages?: Message[] + } = {}, +): Promise { + enableConfigs() + + const sessionId = opts.sessionId ?? randomUUID() + const cwd = params.cwd + + // Align the global session state so that transcript persistence, + // analytics, and cost tracking use the ACP session ID. + // Preserve the projectDir set by getOrCreateSession so that + // getSessionProjectDir() continues to resolve correctly. + const currentProjectDir = getSessionProjectDir() + switchSession(sessionId as SessionId, currentProjectDir) + + // Set CWD for the session + setOriginalCwd(cwd) + const previousProcessCwd = process.cwd() + let processCwdChanged = false + try { + process.chdir(cwd) + processCwdChanged = true + } catch { + // CWD may not exist yet; best-effort + } + + try { + // Build tools with a permissive permission context. + const permissionContext = getEmptyToolPermissionContext() + const tools: Tools = getTools(permissionContext) + + // Parse permission mode from _meta (passed by RCS/acp-link) or settings. + const meta = params._meta as Record | null | undefined + const hasMetaPermissionMode = hasOwnField(meta, 'permissionMode') + const metaPermissionMode = hasMetaPermissionMode + ? meta?.permissionMode + : undefined + const settingsPermissionMode = readSettingsPermissionMode() + const permissionMode = resolveSessionPermissionMode( + metaPermissionMode, + hasMetaPermissionMode, + settingsPermissionMode, + ) + + // The clientCapabilities field on the shell is private; access it via + // the public initialize() side effect. Since createSession is only ever + // called after initialize() has run (per ACP protocol), this accessor + // is safe. + const clientCapabilities = readClientCapabilities(this) + + // Create the permission bridge canUseTool function. The connection field + // is private on the shell; access it through the internal accessor. + const conn = ( + this as unknown as { + conn: import('@agentclientprotocol/sdk').AgentSideConnection + } + ).conn + const canUseTool = createAcpCanUseTool( + conn, + sessionId, + () => this.sessions.get(sessionId)?.modes.currentModeId ?? 'default', + clientCapabilities, + cwd, + (modeId: string) => { + this.applySessionMode(sessionId, modeId) + }, + () => + this.sessions.get(sessionId)?.appState.toolPermissionContext + .isBypassPermissionsModeAvailable ?? false, + ) + + // Parse MCP servers from ACP params + // MCP server config is handled separately in the tools system + + // ACP clients can expose bypass only when both the process and local config allow it. + const isBypassAvailable = isAcpBypassPermissionModeAvailable( + settingsPermissionMode, + ) + + // Create a mutable AppState for the session + const appState: AppState = { + ...getDefaultAppState(), + toolPermissionContext: { + ...permissionContext, + mode: permissionMode as PermissionMode, + isBypassPermissionsModeAvailable: isBypassAvailable, + }, + } + + // Load commands and agent definitions for subagent support + const [commands, agentDefinitionsResult] = await Promise.all([ + getCommands(cwd), + getAgentDefinitionsWithOverrides(cwd), + ]) + + // Inject agent definitions into appState + appState.agentDefinitions = agentDefinitionsResult + + // Build QueryEngine config + const engineConfig: QueryEngineConfig = { + cwd, + tools, + commands, + mcpClients: [], + agents: agentDefinitionsResult.activeAgents, + canUseTool, + getAppState: () => appState, + setAppState: (updater: (prev: AppState) => AppState) => { + const updated = updater(appState) + Object.assign(appState, updated) + }, + readFileCache: new FileStateCache(500, 50 * 1024 * 1024), + includePartialMessages: true, + replayUserMessages: true, + initialMessages: opts.initialMessages, + } + + const queryEngine = new QueryEngine(engineConfig) + + // Build modes — bypassPermissions is opt-in for ACP clients. + const availableModes = [ + { + id: 'default', + name: 'Default', + description: 'Standard behavior, prompts for dangerous operations', + }, + { + id: 'acceptEdits', + name: 'Accept Edits', + description: 'Auto-accept file edit operations', + }, + { + id: 'plan', + name: 'Plan Mode', + description: 'Planning mode, no actual tool execution', + }, + { + id: 'auto', + name: 'Auto', + description: + 'Use a model classifier to approve/deny permission prompts.', + }, + ...(isBypassAvailable + ? [ + { + id: 'bypassPermissions' as const, + name: 'Bypass Permissions', + description: 'Skip all permission checks', + }, + ] + : []), + { + id: 'dontAsk', + name: "Don't Ask", + description: "Don't prompt for permissions, deny if not pre-approved", + }, + ] + + const modes: SessionModeState = { + currentModeId: permissionMode, + availableModes, + } + + // Build models + const modelOptions = getModelOptions() + const currentModel = getMainLoopModel() + const models: SessionModelState = { + availableModels: modelOptions.map(m => ({ + modelId: String(m.value ?? ''), + name: m.label ?? String(m.value ?? ''), + description: m.description ?? undefined, + })), + currentModelId: currentModel, + } + + // Set the model on the engine + queryEngine.setModel(currentModel) + + // Build config options + const configOptions = buildConfigOptions(modes, models) + + const session: AcpSession = { + queryEngine, + cancelled: false, + cancelGeneration: 0, + cwd, + modes, + models, + configOptions, + promptRunning: false, + pendingMessages: new Map(), + pendingQueue: [], + pendingQueueHead: 0, + toolUseCache: {}, + clientCapabilities, + appState, + commands, + sessionFingerprint: computeSessionFingerprint({ + cwd, + mcpServers: params.mcpServers as + | Array<{ name: string; [key: string]: unknown }> + | undefined, + }), + } + + this.sessions.set(sessionId, session) + + // Stable v1 NewSessionResponse only defines sessionId/modes/configOptions. + // `models` is a draft/unstable field — omit it for v1 compliance. + return { + sessionId, + modes, + configOptions, + } + } finally { + if (processCwdChanged) { + process.chdir(previousProcessCwd) + } + } +} + +// ── Prototype attachment ───────────────────────────────────────── + +Object.assign(AcpAgent.prototype, { + createSession, +}) diff --git a/src/services/acp/agent/internalAccessors.ts b/src/services/acp/agent/internalAccessors.ts new file mode 100644 index 000000000..1eb123672 --- /dev/null +++ b/src/services/acp/agent/internalAccessors.ts @@ -0,0 +1,54 @@ +/** + * Internal accessors for AcpAgent private fields and session-state helpers, + * shared across the prototype-augmentation modules (createSessionMethod / + * sessionLifecycle / promptFlow). + * + * AcpAgent's `conn` and `clientCapabilities` fields are declared `private` + * on the shell class. TS-only privacy (no #) means bracket access still + * works at runtime, but we cast through `unknown` to keep tsc strict happy + * without widening the public API surface of the class. + */ +import type { + AgentSideConnection, + ClientCapabilities, +} from '@agentclientprotocol/sdk' +import type { AcpAgent } from './AcpAgent.js' +import type { AcpSession } from './sessionTypes.js' + +type AcpAgentInternals = { + conn: AgentSideConnection + clientCapabilities: ClientCapabilities | undefined +} + +export function getConnection(agent: AcpAgent): AgentSideConnection { + return (agent as unknown as AcpAgentInternals).conn +} + +export function readClientCapabilities( + agent: AcpAgent, +): ClientCapabilities | undefined { + return (agent as unknown as AcpAgentInternals).clientCapabilities +} + +/** + * Update the session's current mode/model id based on the configId. + * + * This logic was originally the private `AcpAgent.syncSessionConfigState` + * method on the shell class. It is called by the prototype-augmented + * `updateConfigOption` (sessionLifecycle.ts) and `setSessionConfigOption` + * (promptFlow.ts). Moving it here keeps it next to its only callers and + * avoids the `noUnusedPrivateClassMembers` false positive that the + * cast-based access would otherwise trigger on the shell. + */ +export function syncSessionConfigState( + _agent: AcpAgent, + session: AcpSession, + configId: string, + value: string, +): void { + if (configId === 'mode') { + session.modes = { ...session.modes, currentModeId: value } + } else if (configId === 'model') { + session.models = { ...session.models, currentModelId: value } + } +} diff --git a/src/services/acp/agent/permissionMode.ts b/src/services/acp/agent/permissionMode.ts new file mode 100644 index 000000000..a16dbb2c7 --- /dev/null +++ b/src/services/acp/agent/permissionMode.ts @@ -0,0 +1,115 @@ +import type { PermissionMode } from '../../../types/permissions.js' +import { resolvePermissionMode } from '../utils.js' + +export const permissionModeIds: readonly PermissionMode[] = [ + 'auto', + 'default', + 'acceptEdits', + 'bypassPermissions', + 'dontAsk', + 'plan', +] + +export function isPermissionMode(modeId: string): modeId is PermissionMode { + return (permissionModeIds as readonly string[]).includes(modeId) +} + +export function resolveSessionPermissionMode( + metaMode: unknown, + hasMetaMode: boolean, + settingsMode: unknown, +): PermissionMode { + if (hasMetaMode) { + const metaResolved = resolveRequiredPermissionMode( + metaMode, + '_meta.permissionMode', + ) + if ( + metaResolved === 'bypassPermissions' && + !isAcpBypassPermissionModeAvailable(settingsMode) + ) { + throw new Error( + 'Mode not available: bypassPermissions requires a local ACP bypass opt-in.', + ) + } + + return metaResolved + } + + const settingsResolved = resolveConfiguredPermissionMode(settingsMode) + return settingsResolved ?? 'default' +} + +function resolveRequiredPermissionMode( + mode: unknown, + source: string, +): PermissionMode { + if (mode === undefined || mode === null) { + throw new Error(`Invalid ${source}: expected a string.`) + } + + return resolvePermissionMode(mode, source) as PermissionMode +} + +function resolveConfiguredPermissionMode( + mode: unknown, +): PermissionMode | undefined { + if (mode === undefined || mode === null) return undefined + + try { + return resolvePermissionMode( + mode, + 'permissions.defaultMode', + ) as PermissionMode + } catch (err: unknown) { + const reason = err instanceof Error ? err.message : String(err) + console.error( + '[ACP] Invalid permissions.defaultMode, using default:', + reason, + ) + return undefined + } +} + +export function hasOwnField( + value: Record | null | undefined, + key: string, +): boolean { + return !!value && Object.hasOwn(value, key) +} + +export function isAcpBypassPermissionModeAvailable( + settingsMode?: unknown, +): boolean { + return ( + isProcessBypassPermissionModeAvailable() && + (isAcpBypassLocallyEnabled() || + isSettingsBypassPermissionMode(settingsMode)) + ) +} + +function isProcessBypassPermissionModeAvailable(): boolean { + if (process.env.IS_SANDBOX) return true + if (typeof process.geteuid === 'function') return process.geteuid() !== 0 + if (typeof process.getuid === 'function') return process.getuid() !== 0 + return true +} + +function isAcpBypassLocallyEnabled(): boolean { + return ( + process.env.ACP_PERMISSION_MODE === 'bypassPermissions' || + isTruthyEnv(process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS) + ) +} + +function isSettingsBypassPermissionMode(settingsMode: unknown): boolean { + try { + return resolvePermissionMode(settingsMode) === 'bypassPermissions' + } catch { + return false + } +} + +function isTruthyEnv(value: string | undefined): boolean { + return value === '1' || value?.toLowerCase() === 'true' +} diff --git a/src/services/acp/agent/promptFlow.ts b/src/services/acp/agent/promptFlow.ts new file mode 100644 index 000000000..29bdc847f --- /dev/null +++ b/src/services/acp/agent/promptFlow.ts @@ -0,0 +1,293 @@ +/** + * Prompt-flow methods for AcpAgent, attached to the prototype via + * Object.assign. Kept in a sibling module to keep AcpAgent.ts under the + * 500-line budget. The barrel (./index.ts) imports this module for its + * side effect so the prototype is populated before any instance is built. + * + * Methods attached: prompt, setSessionConfigOption. + */ +import { randomUUID } from 'node:crypto' +import type { + PromptRequest, + PromptResponse, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, +} from '@agentclientprotocol/sdk' +import type { SessionId } from '../../../types/ids.js' +import { + switchSession, + getSessionProjectDir, +} from '../../../bootstrap/state.js' +import { forwardSessionUpdates } from '../bridge.js' +import type { ToolUseCache } from '../bridge.js' +import { promptToQueryInput } from '../promptConversion.js' +import { sanitizeTitle } from '../utils.js' +import { AcpAgent } from './AcpAgent.js' +import type { AcpSession } from './sessionTypes.js' +import { flattenConfigOptionValues } from './configOptions.js' +import { popNextPendingPrompt } from './promptQueue.js' +import { + getConnection, + readClientCapabilities, + syncSessionConfigState, +} from './internalAccessors.js' + +// ── prompt ─────────────────────────────────────────────────────── + +async function prompt( + this: AcpAgent, + params: PromptRequest, +): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error(`Session ${params.sessionId} not found`) + } + + // Extract text/image content from the prompt + const promptInput = promptToQueryInput(params.prompt) + + // Per prompt-turn.mdx, `prompt` is a required ContentBlock[] and an + // effectively-empty prompt is malformed input — reject it with an + // invalid_params error rather than fabricating a successful end_turn. + if (!promptInput.trim()) { + throw new Error('Prompt content is empty') + } + + const promptCancelGeneration = session.cancelGeneration + + // Handle prompt queuing — if a prompt is already running, queue this one + if (session.promptRunning) { + const promptUuid = randomUUID() + const cancelled = await new Promise(resolve => { + session.pendingQueue.push(promptUuid) + session.pendingMessages.set(promptUuid, { resolve }) + }) + if (cancelled) { + return { stopReason: 'cancelled' } + } + } + + if (session.cancelGeneration !== promptCancelGeneration) { + return { stopReason: 'cancelled' } + } + + // Reset cancellation only when this prompt is about to run. Queued prompts + // must not clear the cancellation state for the active prompt. + session.cancelled = false + session.promptRunning = true + + try { + // Reset the query engine's abort controller for a fresh query. + // After a previous interrupt(), the internal controller is stuck in + // aborted state — without this, submitMessage() fails immediately. + session.queryEngine.resetAbortController() + // Switch global session state so recordTranscript writes to the correct + // session file. Without this, multi-session scenarios (or creating a new + // session after another) write transcript data to the wrong file. + switchSession(params.sessionId as SessionId, getSessionProjectDir()) + + const sdkMessages = session.queryEngine.submitMessage(promptInput) + + const { stopReason, usage } = await forwardSessionUpdates( + params.sessionId, + sdkMessages, + getConnection(this), + session.queryEngine.getAbortSignal(), + session.toolUseCache, + readClientCapabilities(this), + session.cwd, + () => session.cancelled, + ) + + // If the session was cancelled during processing, return cancelled + if (session.cancelled) { + return { stopReason: 'cancelled' } + } + + // Emit a session_info_update so Clients learn the session's display + // title / last-activity timestamp via the stable v1 session/update + // channel. The title is derived from the first user prompt. + await emitSessionInfoUpdate(this, params.sessionId, promptInput) + + // Per extensibility.mdx:39 the root of PromptResponse is reserved — + // stable v1 defines only `stopReason` (+ optional `_meta`). Token usage + // is therefore carried under the `_meta.claudeCode.usage` extension + // namespace rather than as a non-spec root field. thoughtTokens are + // included in totalTokens so reported totals match billable tokens; + // until bridge.ts tracks them they are reported as 0. + if (usage) { + const thoughtTokens = 0 + return { + stopReason, + _meta: { + claudeCode: { + usage: { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedReadTokens: usage.cachedReadTokens, + cachedWriteTokens: usage.cachedWriteTokens, + thoughtTokens, + totalTokens: + usage.inputTokens + + usage.outputTokens + + usage.cachedReadTokens + + usage.cachedWriteTokens + + thoughtTokens, + }, + }, + }, + } + } + return { stopReason } + } catch (err: unknown) { + // Treat AbortError / cancellation-shaped errors as a turn cancellation + // regardless of the session.cancelled flag, to close the race window + // between interrupt() firing and cancel() setting the flag. Per + // prompt-turn.mdx the Agent MUST return `cancelled` for aborts. + const isAbort = + err instanceof Error && + (err.name === 'AbortError' || + /abort|cancelled|interrupt/i.test(err.message)) + if (session.cancelled || isAbort) { + return { stopReason: 'cancelled' } + } + + // Check for process death errors + if ( + err instanceof Error && + (err.message.includes('terminated') || + err.message.includes('process exited')) + ) { + await this.teardownSession(params.sessionId) + throw new Error( + 'The Claude Agent process exited unexpectedly. Please start a new session.', + ) + } + + throw err + } finally { + // Resolve next pending prompt if any + const nextPrompt = popNextPendingPrompt(session) + if (nextPrompt) { + session.promptRunning = true + nextPrompt.resolve(false) + } else { + session.promptRunning = false + } + } +} + +// ── setSessionConfigOption ─────────────────────────────────────── + +async function setSessionConfigOption( + this: AcpAgent, + params: SetSessionConfigOptionRequest, +): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error('Session not found') + } + if (typeof params.value !== 'string') { + throw new Error( + `Invalid value for config option ${params.configId}: ${String(params.value)}`, + ) + } + + const option = session.configOptions.find(o => o.id === params.configId) + if (!option) { + throw new Error(`Unknown config option: ${params.configId}`) + } + + // Per session-config-options.mdx: value MUST be one of the values listed + // in the option's options array. Reject unknown values with an error + // rather than silently persisting them. Only `select` options carry an + // options array; `boolean` options have no enumerated values. + if (option.type === 'select') { + const validValues = flattenConfigOptionValues( + (option as { options?: unknown }).options, + ) + if (!validValues.includes(params.value)) { + throw new Error( + `Invalid value '${params.value}' for config option ${params.configId}; must be one of: ${validValues.join(', ')}`, + ) + } + } + + const value = params.value + + if (params.configId === 'mode') { + this.applySessionMode(params.sessionId, value) + await getConnection(this).sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: 'current_mode_update', + currentModeId: value, + }, + }) + } else if (params.configId === 'model') { + session.queryEngine.setModel(value) + } + + syncSessionConfigState(this, session, params.configId, value) + + session.configOptions = session.configOptions.map(o => + o.id === params.configId && typeof o.currentValue === 'string' + ? { ...o, currentValue: value } + : o, + ) + + return { configOptions: session.configOptions } +} + +// ── Private-field accessors ────────────────────────────────────── +// +// getConnection / readClientCapabilities / syncSessionConfigState are +// imported from ./internalAccessors.js (shared with sessionLifecycle.ts and +// createSessionMethod.ts). The session_info_update helper below is local to +// this module because it is only called from prompt(). + +/** + * Emit a session_info_update notification carrying a derived session title + * (truncated first user prompt) and the current last-activity timestamp. + * Sent once per session — subsequent turns reuse the same title. + * + * This logic was originally the private `AcpAgent.maybeEmitSessionInfoUpdate` + * method on the shell class. It is only called from the prompt flow, so it + * lives here to avoid the `noUnusedPrivateClassMembers` false positive that + * cast-based access would otherwise trigger on the shell. + */ +async function emitSessionInfoUpdate( + agent: AcpAgent, + sessionId: string, + firstPrompt: string, +): Promise { + const session = agent.sessions.get(sessionId) + if (!session) return + // sessionInfoTitleSent is tracked via toolUseCache to avoid reshaping + // AcpSession; use a dedicated per-session flag instead. + const cache = session.toolUseCache as ToolUseCache & { + __sessionInfoTitleSent?: boolean + } + if (cache.__sessionInfoTitleSent) return + cache.__sessionInfoTitleSent = true + const title = sanitizeTitle(firstPrompt).slice(0, 100) + try { + await getConnection(agent).sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'session_info_update', + ...(title ? { title } : {}), + updatedAt: new Date().toISOString(), + }, + }) + } catch (err) { + console.error('[ACP] Failed to send session_info_update:', err) + } +} + +// ── Prototype attachment ───────────────────────────────────────── + +Object.assign(AcpAgent.prototype, { + prompt, + setSessionConfigOption, +}) diff --git a/src/services/acp/agent/promptQueue.ts b/src/services/acp/agent/promptQueue.ts new file mode 100644 index 000000000..a7beb0f41 --- /dev/null +++ b/src/services/acp/agent/promptQueue.ts @@ -0,0 +1,36 @@ +import type { AcpSession, PendingPrompt } from './sessionTypes.js' + +export function popNextPendingPrompt( + session: AcpSession, +): PendingPrompt | undefined { + while (session.pendingQueueHead < session.pendingQueue.length) { + const nextId = session.pendingQueue[session.pendingQueueHead++] + if (!nextId) continue + const next = session.pendingMessages.get(nextId) + if (!next) continue + session.pendingMessages.delete(nextId) + compactPendingQueue(session) + return next + } + + compactPendingQueue(session) + return undefined +} + +function compactPendingQueue(session: AcpSession): void { + if (session.pendingQueueHead === 0) return + + if (session.pendingQueueHead >= session.pendingQueue.length) { + session.pendingQueue = [] + session.pendingQueueHead = 0 + return + } + + if ( + session.pendingQueueHead > 1024 && + session.pendingQueueHead * 2 > session.pendingQueue.length + ) { + session.pendingQueue = session.pendingQueue.slice(session.pendingQueueHead) + session.pendingQueueHead = 0 + } +} diff --git a/src/services/acp/agent/sessionLifecycle.ts b/src/services/acp/agent/sessionLifecycle.ts new file mode 100644 index 000000000..0b6eb7e03 --- /dev/null +++ b/src/services/acp/agent/sessionLifecycle.ts @@ -0,0 +1,280 @@ +/** + * Session-lifecycle methods for AcpAgent (excluding createSession, which + * lives in ./createSessionMethod.ts), attached to the prototype via + * Object.assign. The barrel (./index.ts) imports this module for its side + * effect so the prototype is populated before any instance is built. + * + * Methods attached here: getOrCreateSession, teardownSession, + * replaySessionHistory, applySessionMode, updateConfigOption. + */ +import { type UUID } from 'node:crypto' +import { dirname } from 'node:path' +import * as path from 'node:path' +import type { + NewSessionRequest, + NewSessionResponse, +} from '@agentclientprotocol/sdk' +import type { Message } from '../../../types/message.js' +import { deserializeMessages } from '../../../utils/conversationRecovery.js' +import { getLastSessionLog } from '../../../utils/sessionStorage.js' +import type { PermissionMode } from '../../../types/permissions.js' +import { setOriginalCwd, switchSession } from '../../../bootstrap/state.js' +import type { SessionId } from '../../../types/ids.js' +import { replayHistoryMessages } from '../bridge.js' +import { computeSessionFingerprint } from '../utils.js' +import { + resolveSessionFilePath, + readSessionLite, + extractJsonStringField, +} from '../../../utils/sessionStoragePortable.js' +import { AcpAgent } from './AcpAgent.js' +import type { AcpSession } from './sessionTypes.js' +import { isPermissionMode } from './permissionMode.js' +import { + getConnection, + readClientCapabilities, + syncSessionConfigState, +} from './internalAccessors.js' + +// ── getOrCreateSession ─────────────────────────────────────────── + +async function getOrCreateSession( + this: AcpAgent, + params: { + sessionId: string + cwd: string + mcpServers?: NewSessionRequest['mcpServers'] + _meta?: NewSessionRequest['_meta'] + // replay:true (default, session/load) streams the conversation history back + // to the client via session/update. replay:false (session/resume) only + // restores the in-process context — per session-setup.mdx the Agent MUST + // NOT replay history when resuming. + replay?: boolean + }, +): Promise { + const shouldReplay = params.replay !== false + const existingSession = this.sessions.get(params.sessionId) + if (existingSession) { + const fingerprint = computeSessionFingerprint({ + cwd: params.cwd, + mcpServers: params.mcpServers as + | Array<{ name: string; [key: string]: unknown }> + | undefined, + }) + if (fingerprint === existingSession.sessionFingerprint) { + const resolved = await resolveSessionFilePath( + params.sessionId, + params.cwd, + ) + switchSession( + params.sessionId as SessionId, + resolved ? dirname(resolved.filePath) : null, + ) + setOriginalCwd(params.cwd) + + if (shouldReplay) { + await this.replaySessionHistory(params) + } + + return { + sessionId: params.sessionId, + modes: existingSession.modes, + configOptions: existingSession.configOptions, + } + } + + await this.teardownSession(params.sessionId) + } + + // Locate the session file by sessionId across all project directories. + // params.cwd may not match the project directory where the session was + // originally created (e.g. client sends a subdirectory path), so we + // search by sessionId first and fall back to cwd-based lookup. + const resolved = await resolveSessionFilePath(params.sessionId, params.cwd) + const projectDir = resolved ? dirname(resolved.filePath) : null + + // Per session-setup.mdx "Working Directory": the cwd MUST be the absolute + // path used for the session regardless of where the Agent was spawned. + // Reject cross-project loads where the persisted session's original cwd + // does not match the requested cwd, otherwise the client could load a + // session belonging to project B while passing project A's cwd. + if (resolved) { + const lite = await readSessionLite(resolved.filePath) + const originalCwd = lite && extractJsonStringField(lite.head, 'cwd') + if (originalCwd && path.resolve(originalCwd) !== path.resolve(params.cwd)) { + throw new Error( + `Session cwd mismatch: session belongs to ${originalCwd}, requested ${params.cwd}`, + ) + } + } + + switchSession(params.sessionId as SessionId, projectDir) + setOriginalCwd(params.cwd) + + let initialMessages: Message[] | undefined + if (resolved) { + try { + const log = await getLastSessionLog(params.sessionId as UUID) + if (log && log.messages.length > 0) { + initialMessages = deserializeMessages(log.messages) + } + } catch (err) { + console.error('[ACP] Failed to load session history:', err) + } + } + + const response = await this.createSession( + { + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + _meta: params._meta, + }, + { sessionId: params.sessionId, initialMessages }, + ) + + // Replay history to client if loaded. session/resume skips this block. + if (shouldReplay && initialMessages && initialMessages.length > 0) { + const session = this.sessions.get(params.sessionId) + if (session) { + await replayHistoryMessages( + params.sessionId, + initialMessages as unknown as Array>, + getConnection(this), + session.toolUseCache, + readClientCapabilities(this), + session.cwd, + ) + } + } + + return { + sessionId: response.sessionId, + modes: response.modes, + configOptions: response.configOptions, + } +} + +// ── teardownSession ────────────────────────────────────────────── + +async function teardownSession( + this: AcpAgent, + sessionId: string, +): Promise { + const session = this.sessions.get(sessionId) + if (!session) return + + await this.cancel({ sessionId }) + this.sessions.delete(sessionId) +} + +// ── replaySessionHistory ───────────────────────────────────────── + +/** + * Load session history from disk and replay it to the ACP client. + * Used when switching back to a session that is already in memory + * (the client needs the conversation replayed to display it). + */ +async function replaySessionHistory( + this: AcpAgent, + params: { + sessionId: string + cwd: string + }, +): Promise { + try { + const log = await getLastSessionLog(params.sessionId as UUID) + if (!log || log.messages.length === 0) return + const messages = deserializeMessages(log.messages) + if (messages.length === 0) return + + const session = this.sessions.get(params.sessionId) + if (!session) return + + await replayHistoryMessages( + params.sessionId, + messages as unknown as Array>, + getConnection(this), + session.toolUseCache, + readClientCapabilities(this), + session.cwd, + ) + } catch (err) { + console.error('[ACP] Failed to replay session history:', err) + } +} + +// ── applySessionMode ───────────────────────────────────────────── + +function applySessionMode( + this: AcpAgent, + sessionId: string, + modeId: string, +): void { + if (!isPermissionMode(modeId)) { + throw new Error(`Invalid mode: ${modeId}`) + } + const session = this.sessions.get(sessionId) + if (session) { + if ( + modeId === 'bypassPermissions' && + !session.appState.toolPermissionContext.isBypassPermissionsModeAvailable + ) { + throw new Error(`Mode not available: ${modeId}`) + } + const isAvailable = session.modes.availableModes.some( + mode => mode.id === modeId, + ) + if (!isAvailable) { + throw new Error(`Mode not available: ${modeId}`) + } + + session.modes = { ...session.modes, currentModeId: modeId } + // Sync mode to appState so the permission pipeline sees the correct mode + session.appState.toolPermissionContext = { + ...session.appState.toolPermissionContext, + mode: modeId as PermissionMode, + } + } +} + +// ── updateConfigOption ─────────────────────────────────────────── + +async function updateConfigOption( + this: AcpAgent, + sessionId: string, + configId: string, + value: string, +): Promise { + const session = this.sessions.get(sessionId) + if (!session) return + + // Delegate to the shell's private syncSessionConfigState via a typed cast. + // The shell declares syncSessionConfigState as a private method; it is not + // part of the merged public interface, so we access it through the shared + // internal accessor to preserve exact original behavior. + syncSessionConfigState(this, session, configId, value) + + session.configOptions = session.configOptions.map(o => + o.id === configId && typeof o.currentValue === 'string' + ? { ...o, currentValue: value } + : o, + ) + + await getConnection(this).sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'config_option_update', + configOptions: session.configOptions, + }, + }) +} + +// ── Prototype attachment ───────────────────────────────────────── + +Object.assign(AcpAgent.prototype, { + getOrCreateSession, + teardownSession, + replaySessionHistory, + applySessionMode, + updateConfigOption, +}) diff --git a/src/services/acp/agent/sessionTypes.ts b/src/services/acp/agent/sessionTypes.ts new file mode 100644 index 000000000..80f4c9c1e --- /dev/null +++ b/src/services/acp/agent/sessionTypes.ts @@ -0,0 +1,35 @@ +import type { + ClientCapabilities, + SessionModeState, + SessionModelState, + SessionConfigOption, +} from '@agentclientprotocol/sdk' +import type { QueryEngine } from '../../../QueryEngine.js' +import type { Command } from '../../../types/command.js' +import type { AppState } from '../../../state/AppStateStore.js' +import type { ToolUseCache } from '../bridge.js' + +// ── Session state ───────────────────────────────────────────────── + +export type AcpSession = { + queryEngine: QueryEngine + cancelled: boolean + cancelGeneration: number + cwd: string + sessionFingerprint: string + modes: SessionModeState + models: SessionModelState + configOptions: SessionConfigOption[] + promptRunning: boolean + pendingMessages: Map + pendingQueue: string[] + pendingQueueHead: number + toolUseCache: ToolUseCache + clientCapabilities?: ClientCapabilities + appState: AppState + commands: Command[] +} + +export type PendingPrompt = { + resolve: (cancelled: boolean) => void +} diff --git a/src/services/acp/bridge.ts b/src/services/acp/bridge.ts index bfa5489ef..bfbe40107 100644 --- a/src/services/acp/bridge.ts +++ b/src/services/acp/bridge.ts @@ -10,1507 +10,20 @@ * - result (turn termination with usage/cost) * - progress (subagent progress) * - tool_use_summary - */ -import type { - AgentSideConnection, - ClientCapabilities, - ContentBlock, - PlanEntry, - SessionNotification, - SessionUpdate, - StopReason, - ToolCallContent, - ToolCallLocation, - ToolKind, -} from '@agentclientprotocol/sdk' -import type { SDKMessage } from '../../entrypoints/sdk/coreTypes.generated.js' -import { toDisplayPath, markdownEscape } from './utils.js' -import { isAbsolute, resolve } from 'node:path' - -/** - * Normalises an emitted file path against the session cwd so that - * ToolCallLocation.path / Diff.path values are always absolute, as required - * by the ACP v1 spec (tool-calls.mdx:304-306; all file paths MUST be absolute). - * If no cwd is available, the original value is returned unchanged. - */ -function toAbsolutePath( - filePath: string | undefined, - cwd?: string, -): string | undefined { - if (!filePath) return undefined - if (!cwd) return filePath - return isAbsolute(filePath) ? filePath : resolve(cwd, filePath) -} - -// ── ToolUseCache ────────────────────────────────────────────────── - -/** Maps tool_use_id → tool metadata for tracked inflight tool calls. */ -export type ToolUseCache = { - [key: string]: { - type: 'tool_use' | 'server_tool_use' | 'mcp_tool_use' - id: string - name: string - input: unknown - } -} - -// ── Session usage tracking ──────────────────────────────────────── - -/** Accumulated token usage across a session, updated per result message. */ -export type SessionUsage = { - inputTokens: number - outputTokens: number - cachedReadTokens: number - cachedWriteTokens: number -} - -/** Token usage reported in SDK result messages. */ -type BridgeUsage = { - input_tokens?: number - output_tokens?: number - cache_read_input_tokens?: number - cache_creation_input_tokens?: number -} - -/** system-init, compact_boundary, status, api_retry, local_command_output messages. */ -type BridgeSystemMessage = { - type: 'system' - subtype?: string - session_id?: string - content?: string - status?: string - compact_result?: string - compact_error?: string - model?: string - uuid?: string - [key: string]: unknown -} - -/** Turn completion message: success with usage, or error with stop_reason. */ -type BridgeResultMessage = { - type: 'result' - subtype?: string - usage?: BridgeUsage - modelUsage?: Record - total_cost_usd?: number - is_error?: boolean - stop_reason?: string | null - result?: string - errors?: string[] - duration_ms?: number - duration_api_ms?: number - num_turns?: number - permission_denials?: unknown[] - session_id?: string - [key: string]: unknown -} - -/** Full assistant response message after the turn completes. */ -type BridgeAssistantMessage = { - type: 'assistant' - message?: { - role?: string - id?: string - model?: string - content?: string | Array> - usage?: BridgeUsage | Record - stop_reason?: string | null - [key: string]: unknown - } - parent_tool_use_id?: string | null - uuid?: string - session_id?: string - error?: unknown - [key: string]: unknown -} - -/** Real-time streaming event (aka partial_assistant in the SDK schema). */ -type BridgeStreamEventMessage = { - type: 'stream_event' - event?: { type?: string; [key: string]: unknown } - message?: Record - parent_tool_use_id?: string | null - session_id?: string - uuid?: string - [key: string]: unknown -} - -/** User prompt message (may include tool_use_result from prior turns). */ -type BridgeUserMessage = { - type: 'user' - message?: Record - uuid?: string - isReplay?: boolean - isMeta?: boolean - timestamp?: string - [key: string]: unknown -} - -/** Subagent or hook progress notification (internal, not an SDK message member). */ -type BridgeProgressMessage = { - type: 'progress' - data?: { - type?: string - message?: Record - [key: string]: unknown - } - [key: string]: unknown -} - -/** Summary of tool calls made during a turn. */ -type BridgeToolUseSummaryMessage = { - type: 'tool_use_summary' - summary?: string - preceding_tool_use_ids?: string[] - uuid?: string - session_id?: string - [key: string]: unknown -} - -/** File attachment metadata (internal, not an SDK message member). */ -type BridgeAttachmentMessage = { - type: 'attachment' - [key: string]: unknown -} - -/** Compaction boundary marker (type is 'compact_boundary', not 'system'). */ -type BridgeCompactBoundaryMessage = { - type: 'compact_boundary' - compact_metadata?: Record - [key: string]: unknown -} - -/** ACP bridge local discriminated union — covers all message shapes consumed by the forwarding loop. */ -type BridgeSDKMessage = - | BridgeSystemMessage - | BridgeResultMessage - | BridgeAssistantMessage - | BridgeStreamEventMessage - | BridgeUserMessage - | BridgeProgressMessage - | BridgeToolUseSummaryMessage - | BridgeAttachmentMessage - | BridgeCompactBoundaryMessage - -const logger: { debug: (...args: unknown[]) => void } = console - -// ── Tool info conversion ────────────────────────────────────────── - -/** Sanitised tool metadata sent to ACP client for tool_call notifications. */ -interface ToolInfo { - title: string - kind: ToolKind - content: ToolCallContent[] - locations?: ToolCallLocation[] -} - -export function toolInfoFromToolUse( - toolUse: { name: string; id: string; input: Record }, - _supportsTerminalOutput: boolean = false, - cwd?: string, -): ToolInfo { - const name = toolUse.name - const input = toolUse.input - - switch (name) { - case 'Agent': - case 'Task': { - const description = (input?.description as string | undefined) ?? 'Task' - const prompt = input?.prompt as string | undefined - return { - title: description, - kind: 'think', - content: prompt - ? [ - { - type: 'content' as const, - content: { type: 'text' as const, text: prompt }, - }, - ] - : [], - } - } - - case 'Bash': { - const command = (input?.command as string | undefined) ?? 'Terminal' - const description = input?.description as string | undefined - // Standard ACP terminal lifecycle (terminal/create → embed real terminalId → - // terminal/release) is not wired through BashTool yet. Embedding a fake - // terminalId here would cause compliant clients to fail terminal/output - // lookups, so we fall back to inline text content per audit doc §5.2. - // The _supportsTerminalOutput flag is retained for forward compatibility - // once terminal/create is actually plumbed through. - void _supportsTerminalOutput - return { - title: command, - kind: 'execute', - content: description - ? [ - { - type: 'content' as const, - content: { type: 'text' as const, text: description }, - }, - ] - : [], - } - } - - case 'Read': { - const inputFilePath = input?.file_path as string | undefined - const filePath = inputFilePath ?? 'File' - const offset = input?.offset as number | undefined - const limit = input?.limit as number | undefined - let suffix = '' - if (limit && limit > 0) { - suffix = ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})` - } else if (offset) { - suffix = ` (from line ${offset})` - } - const displayPath = filePath ? toDisplayPath(filePath, cwd) : 'File' - const absReadPath = toAbsolutePath(inputFilePath, cwd) - return { - title: `Read ${displayPath}${suffix}`, - kind: 'read', - locations: absReadPath - ? [{ path: absReadPath, line: offset ?? 1 }] - : [], - content: [], - } - } - - case 'Write': { - const filePath = (input?.file_path as string | undefined) ?? '' - const content = (input?.content as string | undefined) ?? '' - const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined - const absWritePath = toAbsolutePath(filePath, cwd) - return { - title: displayPath ? `Write ${displayPath}` : 'Write', - kind: 'edit', - content: absWritePath - ? [ - { - type: 'diff' as const, - path: absWritePath, - oldText: null, - newText: content, - }, - ] - : [ - { - type: 'content' as const, - content: { type: 'text' as const, text: content }, - }, - ], - locations: absWritePath ? [{ path: absWritePath }] : [], - } - } - - case 'Edit': { - const filePath = (input?.file_path as string | undefined) ?? '' - const oldString = (input?.old_string as string | undefined) ?? '' - const newString = (input?.new_string as string | undefined) ?? '' - const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined - const absEditPath = toAbsolutePath(filePath, cwd) - return { - title: displayPath ? `Edit ${displayPath}` : 'Edit', - kind: 'edit', - content: absEditPath - ? [ - { - type: 'diff' as const, - path: absEditPath, - oldText: oldString || null, - newText: newString, - }, - ] - : [], - locations: absEditPath ? [{ path: absEditPath }] : [], - } - } - - case 'Glob': { - const globPath = (input?.path as string | undefined) ?? '' - const pattern = (input?.pattern as string | undefined) ?? '' - const absGlobPath = toAbsolutePath(globPath, cwd) - let label = 'Find' - if (globPath) label += ` \`${globPath}\`` - if (pattern) label += ` \`${pattern}\`` - return { - title: label, - kind: 'search', - content: [], - locations: absGlobPath ? [{ path: absGlobPath }] : [], - } - } - - case 'Grep': { - const grepPattern = (input?.pattern as string | undefined) ?? '' - const grepPath = (input?.path as string | undefined) ?? '' - let label = 'grep' - if (input?.['-i']) label += ' -i' - if (input?.['-n']) label += ' -n' - if (input?.['-A'] !== undefined) label += ` -A ${input['-A'] as number}` - if (input?.['-B'] !== undefined) label += ` -B ${input['-B'] as number}` - if (input?.['-C'] !== undefined) label += ` -C ${input['-C'] as number}` - if (input?.output_mode === 'files_with_matches') label += ' -l' - else if (input?.output_mode === 'count') label += ' -c' - if (input?.head_limit !== undefined) - label += ` | head -${input.head_limit as number}` - if (input?.glob) label += ` --include="${input.glob as string}"` - if (input?.type) label += ` --type=${input.type as string}` - if (input?.multiline) label += ' -P' - if (grepPattern) label += ` "${grepPattern}"` - if (grepPath) label += ` ${grepPath}` - return { - title: label, - kind: 'search', - content: [], - } - } - - case 'WebFetch': { - const url = (input?.url as string | undefined) ?? '' - const fetchPrompt = input?.prompt as string | undefined - return { - title: url ? `Fetch ${url}` : 'Fetch', - kind: 'fetch', - content: fetchPrompt - ? [ - { - type: 'content' as const, - content: { type: 'text' as const, text: fetchPrompt }, - }, - ] - : [], - } - } - - case 'WebSearch': { - const query = (input?.query as string | undefined) ?? 'Web search' - let label = `"${query}"` - const allowed = input?.allowed_domains as string[] | undefined - const blocked = input?.blocked_domains as string[] | undefined - if (allowed && allowed.length > 0) - label += ` (allowed: ${allowed.join(', ')})` - if (blocked && blocked.length > 0) - label += ` (blocked: ${blocked.join(', ')})` - return { - title: label, - kind: 'fetch', - content: [], - } - } - - case 'TodoWrite': { - const todos = input?.todos as Array<{ content: string }> | undefined - return { - title: Array.isArray(todos) - ? `Update TODOs: ${todos.map(t => t.content).join(', ')}` - : 'Update TODOs', - kind: 'think', - content: [], - } - } - - case 'ExitPlanMode': { - const plan = (input as Record)?.plan as - | string - | undefined - return { - title: 'Ready to code?', - kind: 'switch_mode', - content: plan - ? [ - { - type: 'content' as const, - content: { type: 'text' as const, text: plan }, - }, - ] - : [], - } - } - - default: - return { - title: name || 'Unknown Tool', - kind: 'other', - content: [], - } - } -} - -// ── Tool result conversion ──────────────────────────────────────── - -export function toolUpdateFromToolResult( - toolResult: Record, - toolUse: { name: string; id: string } | undefined, - _supportsTerminalOutput: boolean = false, -): { - content?: ToolCallContent[] - title?: string - _meta?: Record -} { - if (!toolUse) return {} - - const isError = toolResult.is_error === true - const resultContent = toolResult.content as - | string - | Array> - | undefined - - // For error results, return error content - if (isError && resultContent) { - return toAcpContentUpdate(resultContent, true) - } - - switch (toolUse.name) { - case 'Read': { - if (typeof resultContent === 'string' && resultContent.length > 0) { - return { - content: [ - { - type: 'content' as const, - content: { - type: 'text' as const, - text: markdownEscape(resultContent), - }, - }, - ], - } - } - if (Array.isArray(resultContent) && resultContent.length > 0) { - return { - content: resultContent.map((c: Record) => ({ - type: 'content' as const, - content: - c.type === 'text' - ? { - type: 'text' as const, - text: markdownEscape(c.text as string), - } - : toAcpContentBlock(c, false), - })), - } - } - return {} - } - - case 'Bash': { - let output = '' - // Standard ACP terminal lifecycle (terminal/create → embed real terminalId - // → terminal/release) is not wired through BashTool yet. Previously this - // branch embedded a fake terminalId (= toolUse.id, never registered via - // terminal/create) and injected non-standard _meta keys (terminal_info / - // terminal_output / terminal_exit) that compliant clients cannot - // interpret. We now fall back to inline text content for the output; see - // audit doc §5.2/§4.4. The _supportsTerminalOutput flag is retained on - // the signature for forward compatibility once terminal/create is plumbed - // through. - void _supportsTerminalOutput - - // Handle bash_code_execution_result format - if ( - resultContent && - typeof resultContent === 'object' && - !Array.isArray(resultContent) && - (resultContent as Record).type === - 'bash_code_execution_result' - ) { - const bashResult = resultContent as Record - output = [bashResult.stdout, bashResult.stderr] - .filter(Boolean) - .join('\n') - } else if (typeof resultContent === 'string') { - output = resultContent - } else if (Array.isArray(resultContent) && resultContent.length > 0) { - output = resultContent - .map((c: Record) => - c.type === 'text' ? (c.text as string) : '', - ) - .join('\n') - } - - if (output.trim()) { - return { - content: [ - { - type: 'content' as const, - content: { - type: 'text' as const, - text: `\`\`\`console\n${output.trimEnd()}\n\`\`\``, - }, - }, - ], - } - } - return {} - } - - case 'Edit': - case 'Write': { - return {} - } - - case 'ExitPlanMode': { - return { title: 'Exited Plan Mode' } - } - - default: { - return toAcpContentUpdate(resultContent ?? '', isError) - } - } -} - -function toAcpContentUpdate( - content: unknown, - isError: boolean, -): { content?: ToolCallContent[] } { - if (Array.isArray(content) && content.length > 0) { - return { - content: content.map((c: Record) => ({ - type: 'content' as const, - content: toAcpContentBlock(c, isError), - })), - } - } - if (typeof content === 'string' && content.length > 0) { - return { - content: [ - { - type: 'content' as const, - content: { - type: 'text' as const, - text: isError ? `\`\`\`\n${content}\n\`\`\`` : content, - }, - }, - ], - } - } - return {} -} - -function toAcpContentBlock( - content: Record, - isError: boolean, -): ContentBlock { - const wrapText = (text: string): ContentBlock => ({ - type: 'text', - text: isError ? `\`\`\`\n${text}\n\`\`\`` : text, - }) - - const type = content.type as string - switch (type) { - case 'text': { - const text = content.text as string - return { type: 'text', text: isError ? `\`\`\`\n${text}\n\`\`\`` : text } - } - case 'image': { - const source = content.source as Record | undefined - if (source?.type === 'base64') { - return { - type: 'image', - data: source.data as string, - mimeType: source.media_type as string, - } - } - return wrapText( - source?.type === 'url' - ? `[image: ${source.url as string}]` - : '[image: file reference]', - ) - } - case 'resource_link': { - // ACP v1 ResourceLink requires name + uri. Name falls back to uri when - // absent so the client always has a display label. mimeType is optional. - const uri = content.uri as string | undefined - const name = - (content.name as string | undefined) ?? (uri as string | undefined) - return { - type: 'resource_link', - uri: uri as string, - name: name as string, - mimeType: content.mimeType as string | undefined, - } - } - case 'resource': { - // ACP v1 EmbeddedResource wraps an optional TextResource / BlobResource - // shape. Forward the standard fields the client knows how to render. - const r = content.resource as Record | undefined - // Construct a TextResource or BlobResource payload depending on what is - // present. Cast through unknown because not every source shape satisfies - // the full union contract. - const resourcePayload = { - uri: (r?.uri as string | undefined) ?? '', - mimeType: r?.mimeType as string | null | undefined, - ...(typeof r?.text === 'string' ? { text: r.text as string } : {}), - ...(typeof r?.blob === 'string' ? { blob: r.blob as string } : {}), - } - return { - type: 'resource', - resource: resourcePayload, - } as unknown as ContentBlock - } - case 'tool_reference': - return wrapText(`Tool: ${content.tool_name as string}`) - case 'tool_search_tool_search_result': { - const refs = content.tool_references as - | Array<{ tool_name: string }> - | undefined - return wrapText( - `Tools found: ${refs?.map(r => r.tool_name).join(', ') || 'none'}`, - ) - } - case 'tool_search_tool_result_error': - return wrapText( - `Error: ${content.error_code as string}${content.error_message ? ` - ${content.error_message as string}` : ''}`, - ) - case 'web_search_result': - return wrapText(`${content.title as string} (${content.url as string})`) - case 'web_search_tool_result_error': - return wrapText(`Error: ${content.error_code as string}`) - case 'web_fetch_result': - return wrapText(`Fetched: ${content.url as string}`) - case 'web_fetch_tool_result_error': - return wrapText(`Error: ${content.error_code as string}`) - case 'code_execution_result': - case 'bash_code_execution_result': - return wrapText( - `Output: ${(content.stdout as string) || (content.stderr as string) || ''}`, - ) - case 'code_execution_tool_result_error': - case 'bash_code_execution_tool_result_error': - return wrapText(`Error: ${content.error_code as string}`) - case 'text_editor_code_execution_view_result': - return wrapText(content.content as string) - case 'text_editor_code_execution_create_result': - return wrapText(content.is_file_update ? 'File updated' : 'File created') - case 'text_editor_code_execution_str_replace_result': { - const lines = content.lines as string[] | undefined - return wrapText(lines?.join('\n') || '') - } - case 'text_editor_code_execution_tool_result_error': - return wrapText( - `Error: ${content.error_code as string}${content.error_message ? ` - ${content.error_message as string}` : ''}`, - ) - default: - try { - return { type: 'text', text: JSON.stringify(content) } - } catch { - return { type: 'text', text: '[content]' } - } - } -} - -// ── Edit tool response → diff ────────────────────────────────────── - -/** Context lines and diff metadata for one hunk of an Edit tool response. */ -interface EditToolResponseHunk { - oldStart: number - oldLines: number - newStart: number - newLines: number - lines: string[] -} - -/** Result block for Edit/Write tool responses containing hunks and optional file stats. */ -interface EditToolResponse { - filePath?: string - structuredPatch?: EditToolResponseHunk[] -} - -/** - * Builds diff ToolUpdate content from the structured Edit toolResponse. - * Parses structuredPatch hunks (lines prefixed with -, +, space) into - * oldText/newText diff pairs. * - * The optional `cwd` is used to normalise the emitted path against the - * session cwd so that Diff.path / ToolCallLocation.path are absolute as - * required by the ACP v1 spec (audit §5.5). + * This file is the public entrypoint (barrel) re-exporting from the `./bridge/` + * sub-modules. The split keeps each sub-file under 500 lines while preserving + * the exact public API surface — permissions.test.ts snapshots every named + * export from this module, so DO NOT add internal-only exports here. */ -export function toolUpdateFromEditToolResponse( - toolResponse: unknown, - cwd?: string, -): { - content?: ToolCallContent[] - locations?: ToolCallLocation[] -} { - if (!toolResponse || typeof toolResponse !== 'object') return {} - const response = toolResponse as EditToolResponse - if (!response.filePath || !Array.isArray(response.structuredPatch)) return {} - - const absPath = toAbsolutePath(response.filePath, cwd) ?? response.filePath - - const content: ToolCallContent[] = [] - const locations: ToolCallLocation[] = [] - - for (const { lines, newStart } of response.structuredPatch) { - const oldText: string[] = [] - const newText: string[] = [] - for (const line of lines) { - if (line.startsWith('-')) { - oldText.push(line.slice(1)) - } else if (line.startsWith('+')) { - newText.push(line.slice(1)) - } else { - oldText.push(line.slice(1)) - newText.push(line.slice(1)) - } - } - if (oldText.length > 0 || newText.length > 0) { - locations.push({ path: absPath, line: newStart }) - content.push({ - type: 'diff', - path: absPath, - oldText: oldText.join('\n') || null, - newText: newText.join('\n'), - }) - } - } - - const result: { - content?: ToolCallContent[] - locations?: ToolCallLocation[] - } = {} - if (content.length > 0) result.content = content - if (locations.length > 0) result.locations = locations - return result -} - -export function nextSdkMessageOrAbort( - sdkMessages: AsyncGenerator, - abortSignal: AbortSignal, -): Promise> { - if (abortSignal.aborted) { - return Promise.resolve({ done: true, value: undefined }) - } - let abortHandler: (() => void) | undefined - const abortPromise = new Promise>( - resolve => { - abortHandler = () => resolve({ done: true, value: undefined }) - abortSignal.addEventListener('abort', abortHandler, { once: true }) - }, - ) - return Promise.race([sdkMessages.next(), abortPromise]).finally(() => { - if (abortHandler) { - abortSignal.removeEventListener('abort', abortHandler) - } - }) -} - -// ── Main forwarding function ────────────────────────────────────── - -/** - * Iterates SDKMessages from QueryEngine.submitMessage(), converts each - * to ACP SessionUpdate notifications, and sends them via conn.sessionUpdate(). - * Returns the final StopReason and accumulated usage for the prompt turn. - */ -export async function forwardSessionUpdates( - sessionId: string, - sdkMessages: AsyncGenerator, - conn: AgentSideConnection, - abortSignal: AbortSignal, - toolUseCache: ToolUseCache, - clientCapabilities?: ClientCapabilities, - cwd?: string, - isCancelled?: () => boolean, -): Promise<{ stopReason: StopReason; usage?: SessionUsage }> { - let stopReason: StopReason = 'end_turn' - const accumulatedUsage: SessionUsage = { - inputTokens: 0, - outputTokens: 0, - cachedReadTokens: 0, - cachedWriteTokens: 0, - } - - // Track last assistant usage/model for context window size computation - let lastAssistantTotalUsage: number | null = null - let lastAssistantModel: string | null = null - let lastContextWindowSize = 200000 - let streamingActive = false - - try { - while (!abortSignal.aborted) { - // Race the next message against the abort signal so we unblock - // immediately when cancelled, even if the generator is waiting for - // a slow API response. - const nextResult = await nextSdkMessageOrAbort(sdkMessages, abortSignal) - if (nextResult.done || abortSignal.aborted) break - const rawMsg = nextResult.value - if (rawMsg == null) continue - const msg = rawMsg as BridgeSDKMessage - - switch (msg.type) { - // ── System messages ──────────────────────────────────────── - case 'system': { - const subtype = msg.subtype - - if (subtype === 'compact_boundary') { - // Reset assistant usage tracking after compaction - lastAssistantTotalUsage = 0 - // NOTE: usage_update is an UNSTABLE SessionUpdate discriminator (not in - // stable v1 schema). Token/cost info has no v1-stable carrier; we drop - // it from session/update and rely on PromptResponse._meta for clients - // that need it (see audit §4.1). - await conn.sessionUpdate({ - sessionId, - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: '\n\nCompacting completed.' }, - }, - }) - } - // api_retry, local_command_output — skip for now - break - } - - // ── Result messages ──────────────────────────────────────── - case 'result': { - const usage = msg.usage - - if (usage) { - accumulatedUsage.inputTokens += usage.input_tokens ?? 0 - accumulatedUsage.outputTokens += usage.output_tokens ?? 0 - accumulatedUsage.cachedReadTokens += - usage.cache_read_input_tokens ?? 0 - accumulatedUsage.cachedWriteTokens += - usage.cache_creation_input_tokens ?? 0 - } - - // Resolve context window size from modelUsage via prefix matching - const modelUsage = msg.modelUsage - if (modelUsage && lastAssistantModel) { - const match = getMatchingModelUsage(modelUsage, lastAssistantModel) - if (match?.contextWindow) { - lastContextWindowSize = match.contextWindow - } - } - - // NOTE: usage_update was removed — it is an UNSTABLE SessionUpdate - // discriminator not present in the stable v1 schema (audit §4.1). Token - // and cost information is returned via PromptResponse._meta.claudeCode.usage - // instead. - - // Determine stop reason - const subtype = msg.subtype - const isError = msg.is_error - - if (abortSignal.aborted) { - stopReason = 'cancelled' - break - } - - switch (subtype) { - case 'success': { - // Map Anthropic stop_reason to ACP StopReason. Branches are mutually - // exclusive so a max_tokens termination that is also flagged isError - // no longer silently flips to end_turn (audit §3.3, §3.4). refusal - // (safety refusal) is a first-class ACP stop reason that must surface - // to the client instead of being misreported as end_turn. - const r = msg.stop_reason - if (r === 'max_tokens') stopReason = 'max_tokens' - else if (r === 'refusal') stopReason = 'refusal' - else stopReason = 'end_turn' - if (isError) stopReason = 'end_turn' - break - } - case 'error_during_execution': { - // Mutually exclusive: max_tokens wins when reported, otherwise the - // error path falls back to end_turn. Avoids the prior two-if - // sequence that overwrote max_tokens with end_turn (audit §3.4). - if (msg.stop_reason === 'max_tokens') { - stopReason = 'max_tokens' - } else { - stopReason = 'end_turn' - } - break - } - case 'error_max_budget_usd': - case 'error_max_turns': - case 'error_max_structured_output_retries': - if (isError) { - stopReason = 'max_turn_requests' - } else { - stopReason = 'max_turn_requests' - } - break - } - break - } - - // ── Stream events ────────────────────────────────────────── - case 'stream_event': { - const notifications = streamEventToAcpNotifications( - msg, - sessionId, - toolUseCache, - conn, - { - clientCapabilities, - cwd, - }, - ) - for (const notification of notifications) { - await conn.sessionUpdate(notification) - } - streamingActive = true - break - } - - // ── Assistant messages ───────────────────────────────────── - case 'assistant': { - // Track last assistant total usage for context window computation - // (only for top-level messages, not subagents) - const assistantMsg = msg.message - const parentToolUseId = msg.parent_tool_use_id - if (assistantMsg?.usage && parentToolUseId === null) { - const usage = assistantMsg.usage - lastAssistantTotalUsage = - (typeof usage.input_tokens === 'number' - ? usage.input_tokens - : 0) + - (typeof usage.output_tokens === 'number' - ? usage.output_tokens - : 0) + - (typeof usage.cache_read_input_tokens === 'number' - ? usage.cache_read_input_tokens - : 0) + - (typeof usage.cache_creation_input_tokens === 'number' - ? usage.cache_creation_input_tokens - : 0) - } - // Track the current top-level model for context window size lookup - if ( - parentToolUseId === null && - assistantMsg?.model && - assistantMsg.model !== '' - ) { - lastAssistantModel = assistantMsg.model - } - - const notifications = assistantMessageToAcpNotifications( - msg, - sessionId, - toolUseCache, - conn, - { - clientCapabilities, - cwd, - parentToolUseId, - streamingActive, - }, - ) - for (const notification of notifications) { - await conn.sessionUpdate(notification) - } - break - } - - // ── User messages ────────────────────────────────────────── - case 'user': { - // In ACP mode, user messages from replay/synthetic are typically skipped - // The client already knows what the user sent - break - } - - // ── Progress messages ────────────────────────────────────── - case 'progress': { - const progressData = msg.data - if (!progressData) break - - // Handle agent/skill subagent progress - const progressType = progressData.type - if ( - progressType === 'agent_progress' || - progressType === 'skill_progress' - ) { - const progressMessage = progressData.message - if (progressMessage) { - const content = progressMessage.content as - | Array> - | undefined - if (content) { - for (const block of content) { - if (block.type === 'text') { - await conn.sessionUpdate({ - sessionId, - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: block.text as string }, - }, - }) - } - } - } - } - } - break - } - - // ── Tool use summary ─────────────────────────────────────── - case 'tool_use_summary': { - // Skip for now — not critical for basic functionality - break - } - - // ── Attachment messages ──────────────────────────────────── - case 'attachment': { - // Skip — handled by QueryEngine internally - break - } - - // ── Compact boundary ─────────────────────────────────────── - case 'compact_boundary': { - lastAssistantTotalUsage = 0 - // NOTE: usage_update removed — UNSTABLE discriminator, not in v1 stable - // schema (audit §4.1). Token info flows through PromptResponse._meta. - await conn.sessionUpdate({ - sessionId, - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: '\n\nCompacting completed.' }, - }, - }) - break - } - - default: - logger.debug('Ignoring unknown SDK message type') - break - } - } - - // If we exited the loop because abort fired or cancel was requested, return cancelled - if (abortSignal.aborted || isCancelled?.()) { - return { stopReason: 'cancelled', usage: accumulatedUsage } - } - } catch (err: unknown) { - if (abortSignal.aborted) { - return { stopReason: 'cancelled', usage: accumulatedUsage } - } - throw err - } - - return { stopReason, usage: accumulatedUsage } -} - -// ── Assistant message conversion ────────────────────────────────── - -function assistantMessageToAcpNotifications( - msg: SDKMessage, - sessionId: string, - toolUseCache: ToolUseCache, - conn: AgentSideConnection, - options?: { - clientCapabilities?: ClientCapabilities - parentToolUseId?: string | null - cwd?: string - streamingActive?: boolean - }, -): SessionNotification[] { - const message = msg.message as Record | undefined - if (!message) return [] - - const content = message.content as - | string - | Array> - | undefined - if (!content) return [] - - // If content is a string, treat as text - if (typeof content === 'string') { - return [ - { - sessionId, - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: content }, - }, - }, - ] - } - - // When streaming is active, text/thinking were already sent via stream_event - // messages. Filter them out to avoid duplicate agent_message_chunk / - // agent_thought_chunk notifications. String content (synthetic messages) - // is unaffected — those have no corresponding stream_events. - const contentToProcess = options?.streamingActive - ? content.filter( - block => block.type !== 'text' && block.type !== 'thinking', - ) - : content - - if (contentToProcess.length === 0) return [] - - return toAcpNotifications( - contentToProcess, - 'assistant', - sessionId, - toolUseCache, - conn, - undefined, - options, - ) -} - -// ── Stream event conversion ─────────────────────────────────────── - -function streamEventToAcpNotifications( - msg: SDKMessage, - sessionId: string, - toolUseCache: ToolUseCache, - conn: AgentSideConnection, - options?: { - clientCapabilities?: ClientCapabilities - cwd?: string - streamingActive?: boolean - }, -): SessionNotification[] { - const event = (msg as unknown as { event: Record }).event - if (!event) return [] - - switch (event.type as string) { - case 'content_block_start': { - const contentBlock = event.content_block as - | Record - | undefined - if (!contentBlock) return [] - return toAcpNotifications( - [contentBlock], - 'assistant', - sessionId, - toolUseCache, - conn, - undefined, - { - clientCapabilities: options?.clientCapabilities, - parentToolUseId: msg.parent_tool_use_id as string | null | undefined, - cwd: options?.cwd, - }, - ) - } - case 'content_block_delta': { - const delta = event.delta as Record | undefined - if (!delta) return [] - return toAcpNotifications( - [delta], - 'assistant', - sessionId, - toolUseCache, - conn, - undefined, - { - clientCapabilities: options?.clientCapabilities, - parentToolUseId: msg.parent_tool_use_id as string | null | undefined, - cwd: options?.cwd, - }, - ) - } - // No content to emit - case 'message_start': - case 'message_delta': - case 'message_stop': - case 'content_block_stop': - return [] - - default: - return [] - } -} - -// ── Core content block → ACP notification conversion ────────────── - -function toAcpNotifications( - content: Array>, - role: 'assistant' | 'user', - sessionId: string, - toolUseCache: ToolUseCache, - _conn: AgentSideConnection, - _logger?: { error: (...args: unknown[]) => void }, - options?: { - registerHooks?: boolean - clientCapabilities?: ClientCapabilities - parentToolUseId?: string | null - cwd?: string - streamingActive?: boolean - }, -): SessionNotification[] { - const output: SessionNotification[] = [] - - for (const chunk of content) { - const chunkType = chunk.type as string - let update: SessionUpdate | null = null - - switch (chunkType) { - case 'text': - case 'text_delta': { - const text = (chunk.text as string) ?? '' - update = { - sessionUpdate: - role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk', - content: { type: 'text', text }, - } - break - } - - case 'thinking': - case 'thinking_delta': { - const thinking = (chunk.thinking as string) ?? '' - update = { - sessionUpdate: 'agent_thought_chunk', - content: { type: 'text', text: thinking }, - } - break - } - - case 'image': { - const source = chunk.source as Record | undefined - if (source?.type === 'base64') { - update = { - sessionUpdate: - role === 'assistant' - ? 'agent_message_chunk' - : 'user_message_chunk', - content: { - type: 'image', - data: source.data as string, - mimeType: source.media_type as string, - }, - } - } - break - } - - case 'tool_use': - case 'server_tool_use': - case 'mcp_tool_use': { - const toolUseId = (chunk.id as string) ?? '' - const toolName = (chunk.name as string) ?? 'unknown' - const toolInput = chunk.input as Record | undefined - const alreadyCached = toolUseId in toolUseCache - - // Cache this tool_use for later matching - toolUseCache[toolUseId] = { - type: chunkType as 'tool_use' | 'server_tool_use' | 'mcp_tool_use', - id: toolUseId, - name: toolName, - input: toolInput, - } - - // TodoWrite → plan update - if (toolName === 'TodoWrite') { - const todos = (toolInput as Record)?.todos as - | Array<{ content: string; status: string }> - | undefined - if (Array.isArray(todos)) { - const entries: PlanEntry[] = todos.map(todo => ({ - content: todo.content, - status: normalizePlanStatus(todo.status), - priority: 'medium', - })) - update = { - sessionUpdate: 'plan', - entries, - } - } - } else { - // Regular tool call - const rawInput = toolInput ? { ...toolInput } : {} - - if (alreadyCached) { - // Second encounter — tool_use input is now fully received. - // The tool is about to execute (pending permission, then run). - // Emit a tool_call_update with status 'in_progress' so clients - // can distinguish "awaiting approval / running" from the initial - // 'pending' (per ACP v1 ToolCallStatus lifecycle, schema.json:3525). - update = { - _meta: { - claudeCode: { toolName }, - }, - toolCallId: toolUseId, - sessionUpdate: 'tool_call_update', - status: 'in_progress', - rawInput, - ...toolInfoFromToolUse( - { name: toolName, id: toolUseId, input: toolInput ?? {} }, - false, - options?.cwd, - ), - } - } else { - // First encounter — send as tool_call - update = { - _meta: { - claudeCode: { toolName }, - }, - toolCallId: toolUseId, - sessionUpdate: 'tool_call', - rawInput, - status: 'pending', - ...toolInfoFromToolUse( - { name: toolName, id: toolUseId, input: toolInput ?? {} }, - false, - options?.cwd, - ), - } - } - } - break - } - - case 'tool_result': - case 'mcp_tool_result': { - const toolUseId = (chunk.tool_use_id as string | undefined) ?? '' - const toolUse = toolUseCache[toolUseId] - if (!toolUse) break - - if (toolUse.name !== 'TodoWrite') { - const toolUpdate = toolUpdateFromToolResult( - chunk as unknown as Record, - { name: toolUse.name, id: toolUse.id }, - false, - ) - - update = { - _meta: { - claudeCode: { toolName: toolUse.name }, - }, - toolCallId: toolUseId, - sessionUpdate: 'tool_call_update', - status: - (chunk.is_error as boolean | undefined) === true - ? 'failed' - : 'completed', - rawOutput: chunk.content, - ...toolUpdate, - } - } - break - } - - case 'redacted_thinking': - case 'input_json_delta': - case 'citations_delta': - case 'signature_delta': - case 'container_upload': - case 'compaction': - case 'compaction_delta': - // Skip these types - break - } - - if (update) { - // Add parentToolUseId to _meta if present - if (options?.parentToolUseId) { - const existingMeta = (update as Record)._meta as - | Record - | undefined - ;(update as Record)._meta = { - ...existingMeta, - claudeCode: { - ...((existingMeta?.claudeCode as Record) ?? {}), - parentToolUseId: options.parentToolUseId, - }, - } - } - output.push({ sessionId, update }) - } - } - - return output -} - -function normalizePlanStatus( - status: string, -): 'pending' | 'in_progress' | 'completed' { - if (status === 'in_progress') return 'in_progress' - if (status === 'completed') return 'completed' - return 'pending' -} - -// ── History replay ────────────────────────────────────────────────── - -/** - * Replays conversation history messages to the ACP client as session updates. - * Used when resuming/loading a session to show the client the previous conversation. - */ -export async function replayHistoryMessages( - sessionId: string, - messages: Array>, - conn: AgentSideConnection, - toolUseCache: ToolUseCache, - clientCapabilities?: ClientCapabilities, - cwd?: string, -): Promise { - for (const rawMsg of messages) { - const msg = rawMsg as BridgeSDKMessage - // Skip non-conversation messages - if (msg.type !== 'user' && msg.type !== 'assistant') { - logger.debug('Ignoring unknown SDK message type') - continue - } - // Skip meta messages (synthetic continuation prompts) - if (msg.isMeta === true) continue - - const messageData = msg.message - const content = messageData?.content - if (!content) continue - - const role: 'assistant' | 'user' = - msg.type === 'assistant' ? 'assistant' : 'user' - - if (typeof content === 'string') { - if (!content.trim()) continue - await conn.sessionUpdate({ - sessionId, - update: { - sessionUpdate: - role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk', - content: { type: 'text', text: content }, - }, - }) - continue - } - - if (Array.isArray(content)) { - const notifications = toAcpNotifications( - content as Array>, - role, - sessionId, - toolUseCache, - conn, - undefined, - { clientCapabilities, cwd }, - ) - for (const notification of notifications) { - await conn.sessionUpdate(notification) - } - } - } -} - -// ── Model usage matching ────────────────────────────────────────── - -function commonPrefixLength(a: string, b: string): number { - let i = 0 - const maxLen = Math.min(a.length, b.length) - while (i < maxLen && a[i] === b[i]) i++ - return i -} - -function getMatchingModelUsage( - modelUsage: Record, - currentModel: string, -): { contextWindow?: number } | null { - let bestKey: string | null = null - let bestLen = 0 - - for (const key of Object.keys(modelUsage)) { - const len = commonPrefixLength(key, currentModel) - if (len > bestLen) { - bestLen = len - bestKey = key - } - } - - return bestKey ? (modelUsage[bestKey] ?? null) : null -} +export type { ToolUseCache, SessionUsage } from './bridge/types.js' +export { toolInfoFromToolUse } from './bridge/toolInfo.js' +export { + toolUpdateFromToolResult, + toolUpdateFromEditToolResponse, +} from './bridge/toolResults.js' +export { + nextSdkMessageOrAbort, + forwardSessionUpdates, + replayHistoryMessages, +} from './bridge/forwarding.js' diff --git a/src/services/acp/bridge/contentBlocks.ts b/src/services/acp/bridge/contentBlocks.ts new file mode 100644 index 000000000..60fe7d8e6 --- /dev/null +++ b/src/services/acp/bridge/contentBlocks.ts @@ -0,0 +1,146 @@ +// Low-level conversion of Claude content block shapes into ACP ContentBlock values. +import type { ContentBlock, ToolCallContent } from './types.js' + +/** + * Wraps a string or array of content blocks into a `{ content: ToolCallContent[] }` + * update object. Used by `toolUpdateFromToolResult` for the default / error paths. + */ +export function toAcpContentUpdate( + content: unknown, + isError: boolean, +): { content?: ToolCallContent[] } { + if (Array.isArray(content) && content.length > 0) { + return { + content: content.map((c: Record) => ({ + type: 'content' as const, + content: toAcpContentBlock(c, isError), + })), + } + } + if (typeof content === 'string' && content.length > 0) { + return { + content: [ + { + type: 'content' as const, + content: { + type: 'text' as const, + text: isError ? `\`\`\`\n${content}\n\`\`\`` : content, + }, + }, + ], + } + } + return {} +} + +export function toAcpContentBlock( + content: Record, + isError: boolean, +): ContentBlock { + const wrapText = (text: string): ContentBlock => ({ + type: 'text', + text: isError ? `\`\`\`\n${text}\n\`\`\`` : text, + }) + + const type = content.type as string + switch (type) { + case 'text': { + const text = content.text as string + return { type: 'text', text: isError ? `\`\`\`\n${text}\n\`\`\`` : text } + } + case 'image': { + const source = content.source as Record | undefined + if (source?.type === 'base64') { + return { + type: 'image', + data: source.data as string, + mimeType: source.media_type as string, + } + } + return wrapText( + source?.type === 'url' + ? `[image: ${source.url as string}]` + : '[image: file reference]', + ) + } + case 'resource_link': { + // ACP v1 ResourceLink requires name + uri. Name falls back to uri when + // absent so the client always has a display label. mimeType is optional. + const uri = content.uri as string | undefined + const name = + (content.name as string | undefined) ?? (uri as string | undefined) + return { + type: 'resource_link', + uri: uri as string, + name: name as string, + mimeType: content.mimeType as string | undefined, + } + } + case 'resource': { + // ACP v1 EmbeddedResource wraps an optional TextResource / BlobResource + // shape. Forward the standard fields the client knows how to render. + const r = content.resource as Record | undefined + // Construct a TextResource or BlobResource payload depending on what is + // present. Cast through unknown because not every source shape satisfies + // the full union contract. + const resourcePayload = { + uri: (r?.uri as string | undefined) ?? '', + mimeType: r?.mimeType as string | null | undefined, + ...(typeof r?.text === 'string' ? { text: r.text as string } : {}), + ...(typeof r?.blob === 'string' ? { blob: r.blob as string } : {}), + } + return { + type: 'resource', + resource: resourcePayload, + } as unknown as ContentBlock + } + case 'tool_reference': + return wrapText(`Tool: ${content.tool_name as string}`) + case 'tool_search_tool_search_result': { + const refs = content.tool_references as + | Array<{ tool_name: string }> + | undefined + return wrapText( + `Tools found: ${refs?.map(r => r.tool_name).join(', ') || 'none'}`, + ) + } + case 'tool_search_tool_result_error': + return wrapText( + `Error: ${content.error_code as string}${content.error_message ? ` - ${content.error_message as string}` : ''}`, + ) + case 'web_search_result': + return wrapText(`${content.title as string} (${content.url as string})`) + case 'web_search_tool_result_error': + return wrapText(`Error: ${content.error_code as string}`) + case 'web_fetch_result': + return wrapText(`Fetched: ${content.url as string}`) + case 'web_fetch_tool_result_error': + return wrapText(`Error: ${content.error_code as string}`) + case 'code_execution_result': + case 'bash_code_execution_result': + return wrapText( + `Output: ${(content.stdout as string) || (content.stderr as string) || ''}`, + ) + case 'code_execution_tool_result_error': + case 'bash_code_execution_tool_result_error': + return wrapText(`Error: ${content.error_code as string}`) + case 'text_editor_code_execution_view_result': + return wrapText(content.content as string) + case 'text_editor_code_execution_create_result': + return wrapText(content.is_file_update ? 'File updated' : 'File created') + case 'text_editor_code_execution_str_replace_result': { + const lines = content.lines as string[] | undefined + return wrapText(lines?.join('\n') || '') + } + case 'text_editor_code_execution_tool_result_error': + return wrapText( + `Error: ${content.error_code as string}${content.error_message ? ` - ${content.error_message as string}` : ''}`, + ) + default: + try { + return { type: 'text', text: JSON.stringify(content) } + } catch { + return { type: 'text', text: '[content]' } + } + } +} diff --git a/src/services/acp/bridge/forwarding.ts b/src/services/acp/bridge/forwarding.ts new file mode 100644 index 000000000..866e9a3e5 --- /dev/null +++ b/src/services/acp/bridge/forwarding.ts @@ -0,0 +1,402 @@ +// Stream replay + forwarding loop. +// +// `nextSdkMessageOrAbort` races an async generator against an AbortSignal. +// `forwardSessionUpdates` consumes the SDKMessage stream and dispatches into +// the notification converters, accumulating usage and mapping stop reasons. +// `replayHistoryMessages` replays stored user/assistant history through +// `toAcpNotifications`. +import type { + AgentSideConnection, + ClientCapabilities, + StopReason, +} from '@agentclientprotocol/sdk' +import type { SDKMessage } from '../../../entrypoints/sdk/coreTypes.generated.js' +import type { BridgeSDKMessage, SessionUsage, ToolUseCache } from './types.js' +import { + assistantMessageToAcpNotifications, + streamEventToAcpNotifications, + toAcpNotifications, +} from './notifications.js' +import { getMatchingModelUsage } from './modelUsage.js' + +// Top-level const alias retained from the original module. Only the +// forwardSessionUpdates default branch and replayHistoryMessages reference it. +const logger: { debug: (...args: unknown[]) => void } = console + +export function nextSdkMessageOrAbort( + sdkMessages: AsyncGenerator, + abortSignal: AbortSignal, +): Promise> { + if (abortSignal.aborted) { + return Promise.resolve({ done: true, value: undefined }) + } + let abortHandler: (() => void) | undefined + const abortPromise = new Promise>( + resolve => { + abortHandler = () => resolve({ done: true, value: undefined }) + abortSignal.addEventListener('abort', abortHandler, { once: true }) + }, + ) + return Promise.race([sdkMessages.next(), abortPromise]).finally(() => { + if (abortHandler) { + abortSignal.removeEventListener('abort', abortHandler) + } + }) +} + +// ── Main forwarding function ────────────────────────────────────── + +/** + * Iterates SDKMessages from QueryEngine.submitMessage(), converts each + * to ACP SessionUpdate notifications, and sends them via conn.sessionUpdate(). + * Returns the final StopReason and accumulated usage for the prompt turn. + */ +export async function forwardSessionUpdates( + sessionId: string, + sdkMessages: AsyncGenerator, + conn: AgentSideConnection, + abortSignal: AbortSignal, + toolUseCache: ToolUseCache, + clientCapabilities?: ClientCapabilities, + cwd?: string, + isCancelled?: () => boolean, +): Promise<{ stopReason: StopReason; usage?: SessionUsage }> { + let stopReason: StopReason = 'end_turn' + const accumulatedUsage: SessionUsage = { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + } + + // Track last assistant usage/model for context window size computation + let lastAssistantTotalUsage: number | null = null + let lastAssistantModel: string | null = null + let lastContextWindowSize = 200000 + let streamingActive = false + + try { + while (!abortSignal.aborted) { + // Race the next message against the abort signal so we unblock + // immediately when cancelled, even if the generator is waiting for + // a slow API response. + const nextResult = await nextSdkMessageOrAbort(sdkMessages, abortSignal) + if (nextResult.done || abortSignal.aborted) break + const rawMsg = nextResult.value + if (rawMsg == null) continue + const msg = rawMsg as BridgeSDKMessage + + switch (msg.type) { + // ── System messages ──────────────────────────────────────── + case 'system': { + const subtype = msg.subtype + + if (subtype === 'compact_boundary') { + // Reset assistant usage tracking after compaction + lastAssistantTotalUsage = 0 + // NOTE: usage_update is an UNSTABLE SessionUpdate discriminator (not in + // stable v1 schema). Token/cost info has no v1-stable carrier; we drop + // it from session/update and rely on PromptResponse._meta for clients + // that need it (see audit §4.1). + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '\n\nCompacting completed.' }, + }, + }) + } + // api_retry, local_command_output — skip for now + break + } + + // ── Result messages ──────────────────────────────────────── + case 'result': { + const usage = msg.usage + + if (usage) { + accumulatedUsage.inputTokens += usage.input_tokens ?? 0 + accumulatedUsage.outputTokens += usage.output_tokens ?? 0 + accumulatedUsage.cachedReadTokens += + usage.cache_read_input_tokens ?? 0 + accumulatedUsage.cachedWriteTokens += + usage.cache_creation_input_tokens ?? 0 + } + + // Resolve context window size from modelUsage via prefix matching + const modelUsage = msg.modelUsage + if (modelUsage && lastAssistantModel) { + const match = getMatchingModelUsage(modelUsage, lastAssistantModel) + if (match?.contextWindow) { + lastContextWindowSize = match.contextWindow + } + } + + // NOTE: usage_update was removed — it is an UNSTABLE SessionUpdate + // discriminator not present in the stable v1 schema (audit §4.1). Token + // and cost information is returned via PromptResponse._meta.claudeCode.usage + // instead. + + // Determine stop reason + const subtype = msg.subtype + const isError = msg.is_error + + if (abortSignal.aborted) { + stopReason = 'cancelled' + break + } + + switch (subtype) { + case 'success': { + // Map Anthropic stop_reason to ACP StopReason. Branches are mutually + // exclusive so a max_tokens termination that is also flagged isError + // no longer silently flips to end_turn (audit §3.3, §3.4). refusal + // (safety refusal) is a first-class ACP stop reason that must surface + // to the client instead of being misreported as end_turn. + const r = msg.stop_reason + if (r === 'max_tokens') stopReason = 'max_tokens' + else if (r === 'refusal') stopReason = 'refusal' + else stopReason = 'end_turn' + if (isError) stopReason = 'end_turn' + break + } + case 'error_during_execution': { + // Mutually exclusive: max_tokens wins when reported, otherwise the + // error path falls back to end_turn. Avoids the prior two-if + // sequence that overwrote max_tokens with end_turn (audit §3.4). + if (msg.stop_reason === 'max_tokens') { + stopReason = 'max_tokens' + } else { + stopReason = 'end_turn' + } + break + } + case 'error_max_budget_usd': + case 'error_max_turns': + case 'error_max_structured_output_retries': + if (isError) { + stopReason = 'max_turn_requests' + } else { + stopReason = 'max_turn_requests' + } + break + } + break + } + + // ── Stream events ────────────────────────────────────────── + case 'stream_event': { + const notifications = streamEventToAcpNotifications( + msg, + sessionId, + toolUseCache, + conn, + { + clientCapabilities, + cwd, + }, + ) + for (const notification of notifications) { + await conn.sessionUpdate(notification) + } + streamingActive = true + break + } + + // ── Assistant messages ───────────────────────────────────── + case 'assistant': { + // Track last assistant total usage for context window computation + // (only for top-level messages, not subagents) + const assistantMsg = msg.message + const parentToolUseId = msg.parent_tool_use_id + if (assistantMsg?.usage && parentToolUseId === null) { + const usage = assistantMsg.usage + lastAssistantTotalUsage = + (typeof usage.input_tokens === 'number' + ? usage.input_tokens + : 0) + + (typeof usage.output_tokens === 'number' + ? usage.output_tokens + : 0) + + (typeof usage.cache_read_input_tokens === 'number' + ? usage.cache_read_input_tokens + : 0) + + (typeof usage.cache_creation_input_tokens === 'number' + ? usage.cache_creation_input_tokens + : 0) + } + // Track the current top-level model for context window size lookup + if ( + parentToolUseId === null && + assistantMsg?.model && + assistantMsg.model !== '' + ) { + lastAssistantModel = assistantMsg.model + } + + const notifications = assistantMessageToAcpNotifications( + msg, + sessionId, + toolUseCache, + conn, + { + clientCapabilities, + cwd, + parentToolUseId, + streamingActive, + }, + ) + for (const notification of notifications) { + await conn.sessionUpdate(notification) + } + break + } + + // ── User messages ────────────────────────────────────────── + case 'user': { + // In ACP mode, user messages from replay/synthetic are typically skipped + // The client already knows what the user sent + break + } + + // ── Progress messages ────────────────────────────────────── + case 'progress': { + const progressData = msg.data + if (!progressData) break + + // Handle agent/skill subagent progress + const progressType = progressData.type + if ( + progressType === 'agent_progress' || + progressType === 'skill_progress' + ) { + const progressMessage = progressData.message + if (progressMessage) { + const content = progressMessage.content as + | Array> + | undefined + if (content) { + for (const block of content) { + if (block.type === 'text') { + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: block.text as string }, + }, + }) + } + } + } + } + } + break + } + + // ── Tool use summary ─────────────────────────────────────── + case 'tool_use_summary': { + // Skip for now — not critical for basic functionality + break + } + + // ── Attachment messages ──────────────────────────────────── + case 'attachment': { + // Skip — handled by QueryEngine internally + break + } + + // ── Compact boundary ─────────────────────────────────────── + case 'compact_boundary': { + lastAssistantTotalUsage = 0 + // NOTE: usage_update removed — UNSTABLE discriminator, not in v1 stable + // schema (audit §4.1). Token info flows through PromptResponse._meta. + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '\n\nCompacting completed.' }, + }, + }) + break + } + + default: + logger.debug('Ignoring unknown SDK message type') + break + } + } + + // If we exited the loop because abort fired or cancel was requested, return cancelled + if (abortSignal.aborted || isCancelled?.()) { + return { stopReason: 'cancelled', usage: accumulatedUsage } + } + } catch (err: unknown) { + if (abortSignal.aborted) { + return { stopReason: 'cancelled', usage: accumulatedUsage } + } + throw err + } + + return { stopReason, usage: accumulatedUsage } +} + +// ── History replay ────────────────────────────────────────────────── + +/** + * Replays conversation history messages to the ACP client as session updates. + * Used when resuming/loading a session to show the client the previous conversation. + */ +export async function replayHistoryMessages( + sessionId: string, + messages: Array>, + conn: AgentSideConnection, + toolUseCache: ToolUseCache, + clientCapabilities?: ClientCapabilities, + cwd?: string, +): Promise { + for (const rawMsg of messages) { + const msg = rawMsg as BridgeSDKMessage + // Skip non-conversation messages + if (msg.type !== 'user' && msg.type !== 'assistant') { + logger.debug('Ignoring unknown SDK message type') + continue + } + // Skip meta messages (synthetic continuation prompts) + if (msg.isMeta === true) continue + + const messageData = msg.message + const content = messageData?.content + if (!content) continue + + const role: 'assistant' | 'user' = + msg.type === 'assistant' ? 'assistant' : 'user' + + if (typeof content === 'string') { + if (!content.trim()) continue + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: + role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk', + content: { type: 'text', text: content }, + }, + }) + continue + } + + if (Array.isArray(content)) { + const notifications = toAcpNotifications( + content as Array>, + role, + sessionId, + toolUseCache, + conn, + undefined, + { clientCapabilities, cwd }, + ) + for (const notification of notifications) { + await conn.sessionUpdate(notification) + } + } + } +} diff --git a/src/services/acp/bridge/modelUsage.ts b/src/services/acp/bridge/modelUsage.ts new file mode 100644 index 000000000..1c2b7c49e --- /dev/null +++ b/src/services/acp/bridge/modelUsage.ts @@ -0,0 +1,27 @@ +// Pure helpers used by the forwarding loop to resolve contextWindow from the +// modelUsage map by longest prefix match. + +export function commonPrefixLength(a: string, b: string): number { + let i = 0 + const maxLen = Math.min(a.length, b.length) + while (i < maxLen && a[i] === b[i]) i++ + return i +} + +export function getMatchingModelUsage( + modelUsage: Record, + currentModel: string, +): { contextWindow?: number } | null { + let bestKey: string | null = null + let bestLen = 0 + + for (const key of Object.keys(modelUsage)) { + const len = commonPrefixLength(key, currentModel) + if (len > bestLen) { + bestLen = len + bestKey = key + } + } + + return bestKey ? (modelUsage[bestKey] ?? null) : null +} diff --git a/src/services/acp/bridge/notifications.ts b/src/services/acp/bridge/notifications.ts new file mode 100644 index 000000000..6932943fc --- /dev/null +++ b/src/services/acp/bridge/notifications.ts @@ -0,0 +1,351 @@ +// Core content-block → SessionUpdate conversion engine. +// +// `toAcpNotifications` handles text/thinking/image/tool_use/tool_result/etc. +// and writes into the ToolUseCache. `assistantMessageToAcpNotifications` and +// `streamEventToAcpNotifications` are thin adapters. `normalizePlanStatus` +// maps TodoWrite status strings onto the ACP PlanEntry status enum. +import type { + AgentSideConnection, + ClientCapabilities, + PlanEntry, + SessionNotification, + SessionUpdate, +} from '@agentclientprotocol/sdk' +import type { ToolUseCache } from './types.js' +import { toolInfoFromToolUse } from './toolInfo.js' +import { toolUpdateFromToolResult } from './toolResults.js' + +/** + * Maps a TodoWrite status string onto the ACP PlanEntry status enum. + * Unknown / unsupported values fall back to 'pending'. + */ +export function normalizePlanStatus( + status: string, +): 'pending' | 'in_progress' | 'completed' { + if (status === 'in_progress') return 'in_progress' + if (status === 'completed') return 'completed' + return 'pending' +} + +export function toAcpNotifications( + content: Array>, + role: 'assistant' | 'user', + sessionId: string, + toolUseCache: ToolUseCache, + _conn: AgentSideConnection, + _logger?: { error: (...args: unknown[]) => void }, + options?: { + registerHooks?: boolean + clientCapabilities?: ClientCapabilities + parentToolUseId?: string | null + cwd?: string + streamingActive?: boolean + }, +): SessionNotification[] { + const output: SessionNotification[] = [] + + for (const chunk of content) { + const chunkType = chunk.type as string + let update: SessionUpdate | null = null + + switch (chunkType) { + case 'text': + case 'text_delta': { + const text = (chunk.text as string) ?? '' + update = { + sessionUpdate: + role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk', + content: { type: 'text', text }, + } + break + } + + case 'thinking': + case 'thinking_delta': { + const thinking = (chunk.thinking as string) ?? '' + update = { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: thinking }, + } + break + } + + case 'image': { + const source = chunk.source as Record | undefined + if (source?.type === 'base64') { + update = { + sessionUpdate: + role === 'assistant' + ? 'agent_message_chunk' + : 'user_message_chunk', + content: { + type: 'image', + data: source.data as string, + mimeType: source.media_type as string, + }, + } + } + break + } + + case 'tool_use': + case 'server_tool_use': + case 'mcp_tool_use': { + const toolUseId = (chunk.id as string) ?? '' + const toolName = (chunk.name as string) ?? 'unknown' + const toolInput = chunk.input as Record | undefined + const alreadyCached = toolUseId in toolUseCache + + // Cache this tool_use for later matching + toolUseCache[toolUseId] = { + type: chunkType as 'tool_use' | 'server_tool_use' | 'mcp_tool_use', + id: toolUseId, + name: toolName, + input: toolInput, + } + + // TodoWrite → plan update + if (toolName === 'TodoWrite') { + const todos = (toolInput as Record)?.todos as + | Array<{ content: string; status: string }> + | undefined + if (Array.isArray(todos)) { + const entries: PlanEntry[] = todos.map(todo => ({ + content: todo.content, + status: normalizePlanStatus(todo.status), + priority: 'medium', + })) + update = { + sessionUpdate: 'plan', + entries, + } + } + } else { + // Regular tool call + const rawInput = toolInput ? { ...toolInput } : {} + + if (alreadyCached) { + // Second encounter — tool_use input is now fully received. + // The tool is about to execute (pending permission, then run). + // Emit a tool_call_update with status 'in_progress' so clients + // can distinguish "awaiting approval / running" from the initial + // 'pending' (per ACP v1 ToolCallStatus lifecycle, schema.json:3525). + update = { + _meta: { + claudeCode: { toolName }, + }, + toolCallId: toolUseId, + sessionUpdate: 'tool_call_update', + status: 'in_progress', + rawInput, + ...toolInfoFromToolUse( + { name: toolName, id: toolUseId, input: toolInput ?? {} }, + false, + options?.cwd, + ), + } + } else { + // First encounter — send as tool_call + update = { + _meta: { + claudeCode: { toolName }, + }, + toolCallId: toolUseId, + sessionUpdate: 'tool_call', + rawInput, + status: 'pending', + ...toolInfoFromToolUse( + { name: toolName, id: toolUseId, input: toolInput ?? {} }, + false, + options?.cwd, + ), + } + } + } + break + } + + case 'tool_result': + case 'mcp_tool_result': { + const toolUseId = (chunk.tool_use_id as string | undefined) ?? '' + const toolUse = toolUseCache[toolUseId] + if (!toolUse) break + + if (toolUse.name !== 'TodoWrite') { + const toolUpdate = toolUpdateFromToolResult( + chunk as unknown as Record, + { name: toolUse.name, id: toolUse.id }, + false, + ) + + update = { + _meta: { + claudeCode: { toolName: toolUse.name }, + }, + toolCallId: toolUseId, + sessionUpdate: 'tool_call_update', + status: + (chunk.is_error as boolean | undefined) === true + ? 'failed' + : 'completed', + rawOutput: chunk.content, + ...toolUpdate, + } + } + break + } + + case 'redacted_thinking': + case 'input_json_delta': + case 'citations_delta': + case 'signature_delta': + case 'container_upload': + case 'compaction': + case 'compaction_delta': + // Skip these types + break + } + + if (update) { + // Add parentToolUseId to _meta if present + if (options?.parentToolUseId) { + const existingMeta = (update as Record)._meta as + | Record + | undefined + ;(update as Record)._meta = { + ...existingMeta, + claudeCode: { + ...((existingMeta?.claudeCode as Record) ?? {}), + parentToolUseId: options.parentToolUseId, + }, + } + } + output.push({ sessionId, update }) + } + } + + return output +} + +export function assistantMessageToAcpNotifications( + msg: { message?: unknown; parent_tool_use_id?: string | null }, + sessionId: string, + toolUseCache: ToolUseCache, + conn: AgentSideConnection, + options?: { + clientCapabilities?: ClientCapabilities + parentToolUseId?: string | null + cwd?: string + streamingActive?: boolean + }, +): SessionNotification[] { + const message = msg.message as Record | undefined + if (!message) return [] + + const content = message.content as + | string + | Array> + | undefined + if (!content) return [] + + // If content is a string, treat as text + if (typeof content === 'string') { + return [ + { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: content }, + }, + }, + ] + } + + // When streaming is active, text/thinking were already sent via stream_event + // messages. Filter them out to avoid duplicate agent_message_chunk / + // agent_thought_chunk notifications. String content (synthetic messages) + // is unaffected — those have no corresponding stream_events. + const contentToProcess = options?.streamingActive + ? content.filter( + block => block.type !== 'text' && block.type !== 'thinking', + ) + : content + + if (contentToProcess.length === 0) return [] + + return toAcpNotifications( + contentToProcess, + 'assistant', + sessionId, + toolUseCache, + conn, + undefined, + options, + ) +} + +export function streamEventToAcpNotifications( + msg: { + event?: Record + parent_tool_use_id?: string | null + }, + sessionId: string, + toolUseCache: ToolUseCache, + conn: AgentSideConnection, + options?: { + clientCapabilities?: ClientCapabilities + cwd?: string + streamingActive?: boolean + }, +): SessionNotification[] { + const event = (msg as unknown as { event: Record }).event + if (!event) return [] + + switch (event.type as string) { + case 'content_block_start': { + const contentBlock = event.content_block as + | Record + | undefined + if (!contentBlock) return [] + return toAcpNotifications( + [contentBlock], + 'assistant', + sessionId, + toolUseCache, + conn, + undefined, + { + clientCapabilities: options?.clientCapabilities, + parentToolUseId: msg.parent_tool_use_id as string | null | undefined, + cwd: options?.cwd, + }, + ) + } + case 'content_block_delta': { + const delta = event.delta as Record | undefined + if (!delta) return [] + return toAcpNotifications( + [delta], + 'assistant', + sessionId, + toolUseCache, + conn, + undefined, + { + clientCapabilities: options?.clientCapabilities, + parentToolUseId: msg.parent_tool_use_id as string | null | undefined, + cwd: options?.cwd, + }, + ) + } + // No content to emit + case 'message_start': + case 'message_delta': + case 'message_stop': + case 'content_block_stop': + return [] + + default: + return [] + } +} diff --git a/src/services/acp/bridge/paths.ts b/src/services/acp/bridge/paths.ts new file mode 100644 index 000000000..fb04ef3d8 --- /dev/null +++ b/src/services/acp/bridge/paths.ts @@ -0,0 +1,17 @@ +// Pure path-normalisation helper used by toolInfo / toolResults / forwarding. +import { isAbsolute, resolve } from 'node:path' + +/** + * Normalises an emitted file path against the session cwd so that + * ToolCallLocation.path / Diff.path values are always absolute, as required + * by the ACP v1 spec (tool-calls.mdx:304-306; all file paths MUST be absolute). + * If no cwd is available, the original value is returned unchanged. + */ +export function toAbsolutePath( + filePath: string | undefined, + cwd?: string, +): string | undefined { + if (!filePath) return undefined + if (!cwd) return filePath + return isAbsolute(filePath) ? filePath : resolve(cwd, filePath) +} diff --git a/src/services/acp/bridge/toolInfo.ts b/src/services/acp/bridge/toolInfo.ts new file mode 100644 index 000000000..7de27ae0b --- /dev/null +++ b/src/services/acp/bridge/toolInfo.ts @@ -0,0 +1,239 @@ +// toolInfoFromToolUse — large switch mapping each known tool name to ACP ToolInfo. +import type { ToolInfo } from './types.js' +import { toAbsolutePath } from './paths.js' +import { toDisplayPath } from '../utils.js' + +export function toolInfoFromToolUse( + toolUse: { name: string; id: string; input: Record }, + _supportsTerminalOutput: boolean = false, + cwd?: string, +): ToolInfo { + const name = toolUse.name + const input = toolUse.input + + switch (name) { + case 'Agent': + case 'Task': { + const description = (input?.description as string | undefined) ?? 'Task' + const prompt = input?.prompt as string | undefined + return { + title: description, + kind: 'think', + content: prompt + ? [ + { + type: 'content' as const, + content: { type: 'text' as const, text: prompt }, + }, + ] + : [], + } + } + + case 'Bash': { + const command = (input?.command as string | undefined) ?? 'Terminal' + const description = input?.description as string | undefined + // Standard ACP terminal lifecycle (terminal/create → embed real terminalId → + // terminal/release) is not wired through BashTool yet. Embedding a fake + // terminalId here would cause compliant clients to fail terminal/output + // lookups, so we fall back to inline text content per audit doc §5.2. + // The _supportsTerminalOutput flag is retained for forward compatibility + // once terminal/create is actually plumbed through. + void _supportsTerminalOutput + return { + title: command, + kind: 'execute', + content: description + ? [ + { + type: 'content' as const, + content: { type: 'text' as const, text: description }, + }, + ] + : [], + } + } + + case 'Read': { + const inputFilePath = input?.file_path as string | undefined + const filePath = inputFilePath ?? 'File' + const offset = input?.offset as number | undefined + const limit = input?.limit as number | undefined + let suffix = '' + if (limit && limit > 0) { + suffix = ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})` + } else if (offset) { + suffix = ` (from line ${offset})` + } + const displayPath = filePath ? toDisplayPath(filePath, cwd) : 'File' + const absReadPath = toAbsolutePath(inputFilePath, cwd) + return { + title: `Read ${displayPath}${suffix}`, + kind: 'read', + locations: absReadPath + ? [{ path: absReadPath, line: offset ?? 1 }] + : [], + content: [], + } + } + + case 'Write': { + const filePath = (input?.file_path as string | undefined) ?? '' + const content = (input?.content as string | undefined) ?? '' + const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined + const absWritePath = toAbsolutePath(filePath, cwd) + return { + title: displayPath ? `Write ${displayPath}` : 'Write', + kind: 'edit', + content: absWritePath + ? [ + { + type: 'diff' as const, + path: absWritePath, + oldText: null, + newText: content, + }, + ] + : [ + { + type: 'content' as const, + content: { type: 'text' as const, text: content }, + }, + ], + locations: absWritePath ? [{ path: absWritePath }] : [], + } + } + + case 'Edit': { + const filePath = (input?.file_path as string | undefined) ?? '' + const oldString = (input?.old_string as string | undefined) ?? '' + const newString = (input?.new_string as string | undefined) ?? '' + const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined + const absEditPath = toAbsolutePath(filePath, cwd) + return { + title: displayPath ? `Edit ${displayPath}` : 'Edit', + kind: 'edit', + content: absEditPath + ? [ + { + type: 'diff' as const, + path: absEditPath, + oldText: oldString || null, + newText: newString, + }, + ] + : [], + locations: absEditPath ? [{ path: absEditPath }] : [], + } + } + + case 'Glob': { + const globPath = (input?.path as string | undefined) ?? '' + const pattern = (input?.pattern as string | undefined) ?? '' + const absGlobPath = toAbsolutePath(globPath, cwd) + let label = 'Find' + if (globPath) label += ` \`${globPath}\`` + if (pattern) label += ` \`${pattern}\`` + return { + title: label, + kind: 'search', + content: [], + locations: absGlobPath ? [{ path: absGlobPath }] : [], + } + } + + case 'Grep': { + const grepPattern = (input?.pattern as string | undefined) ?? '' + const grepPath = (input?.path as string | undefined) ?? '' + let label = 'grep' + if (input?.['-i']) label += ' -i' + if (input?.['-n']) label += ' -n' + if (input?.['-A'] !== undefined) label += ` -A ${input['-A'] as number}` + if (input?.['-B'] !== undefined) label += ` -B ${input['-B'] as number}` + if (input?.['-C'] !== undefined) label += ` -C ${input['-C'] as number}` + if (input?.output_mode === 'files_with_matches') label += ' -l' + else if (input?.output_mode === 'count') label += ' -c' + if (input?.head_limit !== undefined) + label += ` | head -${input.head_limit as number}` + if (input?.glob) label += ` --include="${input.glob as string}"` + if (input?.type) label += ` --type=${input.type as string}` + if (input?.multiline) label += ' -P' + if (grepPattern) label += ` "${grepPattern}"` + if (grepPath) label += ` ${grepPath}` + return { + title: label, + kind: 'search', + content: [], + } + } + + case 'WebFetch': { + const url = (input?.url as string | undefined) ?? '' + const fetchPrompt = input?.prompt as string | undefined + return { + title: url ? `Fetch ${url}` : 'Fetch', + kind: 'fetch', + content: fetchPrompt + ? [ + { + type: 'content' as const, + content: { type: 'text' as const, text: fetchPrompt }, + }, + ] + : [], + } + } + + case 'WebSearch': { + const query = (input?.query as string | undefined) ?? 'Web search' + let label = `"${query}"` + const allowed = input?.allowed_domains as string[] | undefined + const blocked = input?.blocked_domains as string[] | undefined + if (allowed && allowed.length > 0) + label += ` (allowed: ${allowed.join(', ')})` + if (blocked && blocked.length > 0) + label += ` (blocked: ${blocked.join(', ')})` + return { + title: label, + kind: 'fetch', + content: [], + } + } + + case 'TodoWrite': { + const todos = input?.todos as Array<{ content: string }> | undefined + return { + title: Array.isArray(todos) + ? `Update TODOs: ${todos.map(t => t.content).join(', ')}` + : 'Update TODOs', + kind: 'think', + content: [], + } + } + + case 'ExitPlanMode': { + const plan = (input as Record)?.plan as + | string + | undefined + return { + title: 'Ready to code?', + kind: 'switch_mode', + content: plan + ? [ + { + type: 'content' as const, + content: { type: 'text' as const, text: plan }, + }, + ] + : [], + } + } + + default: + return { + title: name || 'Unknown Tool', + kind: 'other', + content: [], + } + } +} diff --git a/src/services/acp/bridge/toolResults.ts b/src/services/acp/bridge/toolResults.ts new file mode 100644 index 000000000..4c38c8773 --- /dev/null +++ b/src/services/acp/bridge/toolResults.ts @@ -0,0 +1,184 @@ +// Tool result → ToolCallContent conversion. +import type { ToolCallContent } from './types.js' +import type { EditToolResponse } from './types.js' +import { toAcpContentUpdate, toAcpContentBlock } from './contentBlocks.js' +import { toAbsolutePath } from './paths.js' +import { markdownEscape } from '../utils.js' + +export function toolUpdateFromToolResult( + toolResult: Record, + toolUse: { name: string; id: string } | undefined, + _supportsTerminalOutput: boolean = false, +): { + content?: ToolCallContent[] + title?: string + _meta?: Record +} { + if (!toolUse) return {} + + const isError = toolResult.is_error === true + const resultContent = toolResult.content as + | string + | Array> + | undefined + + // For error results, return error content + if (isError && resultContent) { + return toAcpContentUpdate(resultContent, true) + } + + switch (toolUse.name) { + case 'Read': { + if (typeof resultContent === 'string' && resultContent.length > 0) { + return { + content: [ + { + type: 'content' as const, + content: { + type: 'text' as const, + text: markdownEscape(resultContent), + }, + }, + ], + } + } + if (Array.isArray(resultContent) && resultContent.length > 0) { + return { + content: resultContent.map((c: Record) => ({ + type: 'content' as const, + content: + c.type === 'text' + ? { + type: 'text' as const, + text: markdownEscape(c.text as string), + } + : toAcpContentBlock(c, false), + })), + } + } + return {} + } + + case 'Bash': { + let output = '' + // Standard ACP terminal lifecycle (terminal/create → embed real terminalId + // → terminal/release) is not wired through BashTool yet. Previously this + // branch embedded a fake terminalId (= toolUse.id, never registered via + // terminal/create) and injected non-standard _meta keys (terminal_info / + // terminal_output / terminal_exit) that compliant clients cannot + // interpret. We now fall back to inline text content for the output; see + // audit doc §5.2/§4.4. The _supportsTerminalOutput flag is retained on + // the signature for forward compatibility once terminal/create is plumbed + // through. + void _supportsTerminalOutput + + // Handle bash_code_execution_result format + if ( + resultContent && + typeof resultContent === 'object' && + !Array.isArray(resultContent) && + (resultContent as Record).type === + 'bash_code_execution_result' + ) { + const bashResult = resultContent as Record + output = [bashResult.stdout, bashResult.stderr] + .filter(Boolean) + .join('\n') + } else if (typeof resultContent === 'string') { + output = resultContent + } else if (Array.isArray(resultContent) && resultContent.length > 0) { + output = resultContent + .map((c: Record) => + c.type === 'text' ? (c.text as string) : '', + ) + .join('\n') + } + + if (output.trim()) { + return { + content: [ + { + type: 'content' as const, + content: { + type: 'text' as const, + text: `\`\`\`console\n${output.trimEnd()}\n\`\`\``, + }, + }, + ], + } + } + return {} + } + + case 'Edit': + case 'Write': { + return {} + } + + case 'ExitPlanMode': { + return { title: 'Exited Plan Mode' } + } + + default: { + return toAcpContentUpdate(resultContent ?? '', isError) + } + } +} + +/** + * Builds diff ToolUpdate content from the structured Edit toolResponse. + * Parses structuredPatch hunks (lines prefixed with -, +, space) into + * oldText/newText diff pairs. + * + * The optional `cwd` is used to normalise the emitted path against the + * session cwd so that Diff.path / ToolCallLocation.path are absolute as + * required by the ACP v1 spec (audit §5.5). + */ +export function toolUpdateFromEditToolResponse( + toolResponse: unknown, + cwd?: string, +): { + content?: ToolCallContent[] + locations?: { path: string; line?: number }[] +} { + if (!toolResponse || typeof toolResponse !== 'object') return {} + const response = toolResponse as EditToolResponse + if (!response.filePath || !Array.isArray(response.structuredPatch)) return {} + + const absPath = toAbsolutePath(response.filePath, cwd) ?? response.filePath + + const content: ToolCallContent[] = [] + const locations: { path: string; line?: number }[] = [] + + for (const { lines, newStart } of response.structuredPatch) { + const oldText: string[] = [] + const newText: string[] = [] + for (const line of lines) { + if (line.startsWith('-')) { + oldText.push(line.slice(1)) + } else if (line.startsWith('+')) { + newText.push(line.slice(1)) + } else { + oldText.push(line.slice(1)) + newText.push(line.slice(1)) + } + } + if (oldText.length > 0 || newText.length > 0) { + locations.push({ path: absPath, line: newStart }) + content.push({ + type: 'diff', + path: absPath, + oldText: oldText.join('\n') || null, + newText: newText.join('\n'), + }) + } + } + + const result: { + content?: ToolCallContent[] + locations?: { path: string; line?: number }[] + } = {} + if (content.length > 0) result.content = content + if (locations.length > 0) result.locations = locations + return result +} diff --git a/src/services/acp/bridge/types.ts b/src/services/acp/bridge/types.ts new file mode 100644 index 000000000..bdb8031f8 --- /dev/null +++ b/src/services/acp/bridge/types.ts @@ -0,0 +1,188 @@ +// Shared ACP-bridge type definitions. +// +// Re-exports the SDK type-only imports that the rest of the bridge sub-modules +// depend on, plus the local discriminated union of every message shape consumed +// by the forwarding loop. +import type { + ContentBlock, + ToolCallContent, + ToolCallLocation, + ToolKind, +} from '@agentclientprotocol/sdk' + +export type { ContentBlock, ToolCallContent, ToolCallLocation, ToolKind } + +// ── ToolUseCache ────────────────────────────────────────────────── + +/** Maps tool_use_id → tool metadata for tracked inflight tool calls. */ +export type ToolUseCache = { + [key: string]: { + type: 'tool_use' | 'server_tool_use' | 'mcp_tool_use' + id: string + name: string + input: unknown + } +} + +// ── Session usage tracking ──────────────────────────────────────── + +/** Accumulated token usage across a session, updated per result message. */ +export type SessionUsage = { + inputTokens: number + outputTokens: number + cachedReadTokens: number + cachedWriteTokens: number +} + +/** Token usage reported in SDK result messages. */ +export type BridgeUsage = { + input_tokens?: number + output_tokens?: number + cache_read_input_tokens?: number + cache_creation_input_tokens?: number +} + +/** system-init, compact_boundary, status, api_retry, local_command_output messages. */ +export type BridgeSystemMessage = { + type: 'system' + subtype?: string + session_id?: string + content?: string + status?: string + compact_result?: string + compact_error?: string + model?: string + uuid?: string + [key: string]: unknown +} + +/** Turn completion message: success with usage, or error with stop_reason. */ +export type BridgeResultMessage = { + type: 'result' + subtype?: string + usage?: BridgeUsage + modelUsage?: Record + total_cost_usd?: number + is_error?: boolean + stop_reason?: string | null + result?: string + errors?: string[] + duration_ms?: number + duration_api_ms?: number + num_turns?: number + permission_denials?: unknown[] + session_id?: string + [key: string]: unknown +} + +/** Full assistant response message after the turn completes. */ +export type BridgeAssistantMessage = { + type: 'assistant' + message?: { + role?: string + id?: string + model?: string + content?: string | Array> + usage?: BridgeUsage | Record + stop_reason?: string | null + [key: string]: unknown + } + parent_tool_use_id?: string | null + uuid?: string + session_id?: string + error?: unknown + [key: string]: unknown +} + +/** Real-time streaming event (aka partial_assistant in the SDK schema). */ +export type BridgeStreamEventMessage = { + type: 'stream_event' + event?: { type?: string; [key: string]: unknown } + message?: Record + parent_tool_use_id?: string | null + session_id?: string + uuid?: string + [key: string]: unknown +} + +/** User prompt message (may include tool_use_result from prior turns). */ +export type BridgeUserMessage = { + type: 'user' + message?: Record + uuid?: string + isReplay?: boolean + isMeta?: boolean + timestamp?: string + [key: string]: unknown +} + +/** Subagent or hook progress notification (internal, not an SDK message member). */ +export type BridgeProgressMessage = { + type: 'progress' + data?: { + type?: string + message?: Record + [key: string]: unknown + } + [key: string]: unknown +} + +/** Summary of tool calls made during a turn. */ +export type BridgeToolUseSummaryMessage = { + type: 'tool_use_summary' + summary?: string + preceding_tool_use_ids?: string[] + uuid?: string + session_id?: string + [key: string]: unknown +} + +/** File attachment metadata (internal, not an SDK message member). */ +export type BridgeAttachmentMessage = { + type: 'attachment' + [key: string]: unknown +} + +/** Compaction boundary marker (type is 'compact_boundary', not 'system'). */ +export type BridgeCompactBoundaryMessage = { + type: 'compact_boundary' + compact_metadata?: Record + [key: string]: unknown +} + +/** ACP bridge local discriminated union — covers all message shapes consumed by the forwarding loop. */ +export type BridgeSDKMessage = + | BridgeSystemMessage + | BridgeResultMessage + | BridgeAssistantMessage + | BridgeStreamEventMessage + | BridgeUserMessage + | BridgeProgressMessage + | BridgeToolUseSummaryMessage + | BridgeAttachmentMessage + | BridgeCompactBoundaryMessage + +// ── Tool info / edit response shapes ────────────────────────────── + +/** Sanitised tool metadata sent to ACP client for tool_call notifications. */ +export interface ToolInfo { + title: string + kind: ToolKind + content: ToolCallContent[] + locations?: ToolCallLocation[] +} + +/** Context lines and diff metadata for one hunk of an Edit tool response. */ +export interface EditToolResponseHunk { + oldStart: number + oldLines: number + newStart: number + newLines: number + lines: string[] +} + +/** Result block for Edit/Write tool responses containing hunks and optional file stats. */ +export interface EditToolResponse { + filePath?: string + structuredPatch?: EditToolResponseHunk[] +}