refactor: 拆分 3 个过大 ACP 文件为模块化子文件(每个 <500 行)

通过 4 阶段 workflow(分析 → 计划 → 重构 → 验证)将 3 个超大的 ACP
源文件拆分为 28 个模块化子文件,每个均严格小于 500 行,且完整保留
所有公共 API(barrel 模式重导出)。

变更概要:
- packages/acp-link/src/server.ts: 1800 → 20 行(barrel),新增 11 个子模块
  (server/types、payload-decode、permission-mode、runtime-state、dispatch、
  handlers-agent、handlers-session、acp-client、client-send、start-server、
  testing-internals)
- src/services/acp/agent.ts: 1297 → 33 行(barrel),新增 9 个子模块
  (agent/AcpAgent、sessionTypes、permissionMode、configOptions、promptQueue、
  internalAccessors、createSessionMethod、sessionLifecycle、promptFlow)
- src/services/acp/bridge.ts: 1516 → 29 行(barrel),新增 8 个子模块
  (bridge/types、paths、contentBlocks、toolInfo、toolResults、modelUsage、
  notifications、forwarding)

验证:
- bun run precheck 全通过(typecheck + lint + 5851 tests)
- ACP service tests: 176 pass / 0 fail
- ACP link tests: 47 pass / 0 fail
- 所有外部消费者(entry.ts、permissions.ts、__tests__/)的 import 路径不变
- 测试文件零修改

迁移计划详见 docs/acp-refactor-plan.md。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-19 15:39:01 +08:00
parent 35768837a7
commit 65f81de52b
32 changed files with 5481 additions and 4591 deletions

281
docs/acp-refactor-plan.md Normal file
View File

@@ -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 12.
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 3435 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.

File diff suppressed because it is too large Load Diff

View File

@@ -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()
}

View File

@@ -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<string, string> = {
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
}

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
const payload = optionalRecord(params)
await handleListSessions(ws, {
cwd: optionalString(payload.cwd),
cursor: optionalString(payload.cursor),
})
}
async function handleJsonRpcLoadSession(
ws: WSContext,
params: unknown,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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> | 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<void> {
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)
}
}

View File

@@ -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<void> {
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<Uint8Array>
const output = Readable.toWeb(
agentProcess.stdout!,
) as unknown as ReadableStream<Uint8Array>
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 })
}

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<acp::ContentBlock> to agent
export async function handlePrompt(
ws: WSContext,
params: { content: ContentBlock[] },
): Promise<void> {
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<void> {
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<void> {
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}`,
)
}
}

View File

@@ -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<string, unknown> {
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<string, unknown>,
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<string, unknown> {
if (!isRecord(value)) {
throw new Error(`Invalid ${type} payload`)
}
return value
}
export function optionalPayloadRecord(
value: unknown,
type: string,
): Record<string, unknown> {
if (value === undefined) return {}
return payloadRecord(value, type)
}
export function optionalRecord(value: unknown): Record<string, unknown> {
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<string, unknown>,
): 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))
}

View File

@@ -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,
}
}

View File

@@ -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<WSContext, ClientState>()
// 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)}`
}

View File

@@ -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<void> {
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(() => {})
}

View File

@@ -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<void> {
assertTestingInternalsEnabled()
return dispatchClientMessage(ws, data as ProxyMessage)
},
dispatchJsonRpcMessage(ws: WSContext, data: unknown): Promise<void> {
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<string, unknown>
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)
}
},
}

View File

@@ -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<typeof setTimeout>
}
// 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<string, unknown> | null
loadSession?: boolean
mcpCapabilities?: {
_meta?: Record<string, unknown> | null
clientServers?: boolean
}
promptCapabilities?: PromptCapabilities
sessionCapabilities?: {
_meta?: Record<string, unknown> | null
fork?: Record<string, unknown> | null
list?: Record<string, unknown> | null
resume?: Record<string, unknown> | null
}
}
// Track connected clients and their agent connections
export interface ClientState {
process: ChildProcess | null
connection: acp.ClientSideConnection | null
sessionId: string | null
pendingPermissions: Map<string, PendingPermission>
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<string, unknown>
/** 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' }

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, AcpSession>()
private clientCapabilities?: ClientCapabilities
constructor(conn: AgentSideConnection) {
this.conn = conn
}
// ── initialize ────────────────────────────────────────────────
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
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<string, unknown>).MACRO ===
'object' &&
(globalThis as unknown as Record<string, Record<string, unknown>>)
.MACRO !== null
? String(
(
(
globalThis as unknown as Record<
string,
Record<string, unknown>
>
).MACRO as Record<string, unknown>
).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<AuthenticateResponse> {
// No authentication required — this is a self-hosted/custom deployment
return {}
}
// ── newSession ────────────────────────────────────────────────
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
const result = await this.createSession(params)
this.scheduleAvailableCommandsUpdate(result.sessionId)
return result
}
// ── resumeSession ──────────────────────────────────────────────
async unstable_resumeSession(
params: ResumeSessionRequest,
): Promise<ResumeSessionResponse> {
// 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<LoadSessionResponse> {
const result = await this.getOrCreateSession(params)
this.scheduleAvailableCommandsUpdate(result.sessionId)
return result
}
// ── listSessions ───────────────────────────────────────────────
async listSessions(
params: ListSessionsRequest,
): Promise<ListSessionsResponse> {
// 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<ForkSessionResponse> {
// 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<CloseSessionResponse> {
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<void> {
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<SetSessionModeResponse> {
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<SetSessionModelResponse> {
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<void> {
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<PromptResponse>
setSessionConfigOption(
params: SetSessionConfigOptionRequest,
): Promise<SetSessionConfigOptionResponse>
// ── session lifecycle (sessionLifecycle.ts) ───────────────────
createSession(
params: NewSessionRequest,
opts?: {
forceNewId?: boolean
sessionId?: string
initialMessages?: Message[]
},
): Promise<NewSessionResponse>
getOrCreateSession(params: {
sessionId: string
cwd: string
mcpServers?: NewSessionRequest['mcpServers']
_meta?: NewSessionRequest['_meta']
replay?: boolean
}): Promise<NewSessionResponse>
teardownSession(sessionId: string): Promise<void>
replaySessionHistory(params: {
sessionId: string
cwd: string
}): Promise<void>
applySessionMode(sessionId: string, modeId: string): void
updateConfigOption(
sessionId: string,
configId: string,
value: string,
): Promise<void>
}
// ── 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 }
}

View File

@@ -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
}

View File

@@ -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<string, unknown>
const perms = settings.permissions as Record<string, unknown> | undefined
return perms?.defaultMode
}
// ── createSession ────────────────────────────────────────────────
async function createSession(
this: AcpAgent,
params: NewSessionRequest,
opts: {
forceNewId?: boolean
sessionId?: string
initialMessages?: Message[]
} = {},
): Promise<NewSessionResponse> {
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<string, unknown> | 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,
})

View File

@@ -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 }
}
}

View File

@@ -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<string, unknown> | 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'
}

View File

@@ -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<PromptResponse> {
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<boolean>(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<SetSessionConfigOptionResponse> {
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<void> {
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,
})

View File

@@ -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
}
}

View File

@@ -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<NewSessionResponse> {
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<Record<string, unknown>>,
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<void> {
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<void> {
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<Record<string, unknown>>,
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<void> {
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,
})

View File

@@ -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<string, PendingPrompt>
pendingQueue: string[]
pendingQueueHead: number
toolUseCache: ToolUseCache
clientCapabilities?: ClientCapabilities
appState: AppState
commands: Command[]
}
export type PendingPrompt = {
resolve: (cancelled: boolean) => void
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, unknown>) => ({
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<string, unknown>,
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<string, unknown> | 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<string, unknown> | 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]' }
}
}
}

View File

@@ -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<SDKMessage, void, unknown>,
abortSignal: AbortSignal,
): Promise<IteratorResult<SDKMessage, void>> {
if (abortSignal.aborted) {
return Promise.resolve({ done: true, value: undefined })
}
let abortHandler: (() => void) | undefined
const abortPromise = new Promise<IteratorResult<SDKMessage, void>>(
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<SDKMessage, void, unknown>,
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 !== '<synthetic>'
) {
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<Record<string, unknown>>
| 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<Record<string, unknown>>,
conn: AgentSideConnection,
toolUseCache: ToolUseCache,
clientCapabilities?: ClientCapabilities,
cwd?: string,
): Promise<void> {
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<Record<string, unknown>>,
role,
sessionId,
toolUseCache,
conn,
undefined,
{ clientCapabilities, cwd },
)
for (const notification of notifications) {
await conn.sessionUpdate(notification)
}
}
}
}

View File

@@ -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<string, { contextWindow?: number }>,
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
}

View File

@@ -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<Record<string, unknown>>,
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<string, unknown> | 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<string, unknown> | 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<string, unknown>)?.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<string, unknown>,
{ 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<string, unknown>)._meta as
| Record<string, unknown>
| undefined
;(update as Record<string, unknown>)._meta = {
...existingMeta,
claudeCode: {
...((existingMeta?.claudeCode as Record<string, unknown>) ?? {}),
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<string, unknown> | undefined
if (!message) return []
const content = message.content as
| string
| Array<Record<string, unknown>>
| 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<string, unknown>
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<string, unknown> }).event
if (!event) return []
switch (event.type as string) {
case 'content_block_start': {
const contentBlock = event.content_block as
| Record<string, unknown>
| 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<string, unknown> | 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 []
}
}

View File

@@ -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)
}

View File

@@ -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<string, unknown> },
_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<string, unknown>)?.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: [],
}
}
}

View File

@@ -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<string, unknown>,
toolUse: { name: string; id: string } | undefined,
_supportsTerminalOutput: boolean = false,
): {
content?: ToolCallContent[]
title?: string
_meta?: Record<string, unknown>
} {
if (!toolUse) return {}
const isError = toolResult.is_error === true
const resultContent = toolResult.content as
| string
| Array<Record<string, unknown>>
| 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<string, unknown>) => ({
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<string, unknown>).type ===
'bash_code_execution_result'
) {
const bashResult = resultContent as Record<string, unknown>
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<string, unknown>) =>
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
}

View File

@@ -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<string, { contextWindow?: number }>
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<Record<string, unknown>>
usage?: BridgeUsage | Record<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
[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<string, unknown>
[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[]
}