mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-20 07:15:51 +00:00
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:
281
docs/acp-refactor-plan.md
Normal file
281
docs/acp-refactor-plan.md
Normal 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 1–2.
|
||||
|
||||
Within each phase, the internal creation order is always: **types → leaf pure helpers → mid-level helpers → handlers → dispatch → barrel → delete original**. This keeps the import graph acyclic at every intermediate commit.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — `src/services/acp/bridge.ts`
|
||||
|
||||
### Directory structure
|
||||
|
||||
```
|
||||
src/services/acp/
|
||||
├── bridge.ts ← DELETED (replaced by directory)
|
||||
└── bridge/
|
||||
├── index.ts ← barrel (public API)
|
||||
├── types.ts ← type definitions
|
||||
├── paths.ts ← toAbsolutePath
|
||||
├── contentBlocks.ts ← low-level block conversion
|
||||
├── toolInfo.ts ← toolInfoFromToolUse
|
||||
├── toolResults.ts ← tool result → ToolCallContent
|
||||
├── modelUsage.ts ← context-window prefix helpers
|
||||
├── notifications.ts ← content-block → SessionUpdate engine
|
||||
└── forwarding.ts ← stream replay + forwarding loop
|
||||
```
|
||||
|
||||
### Files, responsibilities, line budgets
|
||||
|
||||
| File | Responsibility | Exports | Budget |
|
||||
|------|----------------|---------|-------:|
|
||||
| `bridge/types.ts` | Shared ACP-bridge type definitions: `ToolUseCache`, `SessionUsage`, `BridgeUsage`, `Bridge*Message` interfaces, `BridgeSDKMessage` discriminated union, `ToolInfo`, `EditToolResponseHunk`, `EditToolResponse`. Re-exports SDK type-only imports (`ContentBlock`, `ToolCallContent`, `ToolCallLocation`, `ToolKind`). | 16 symbols | ~150 |
|
||||
| `bridge/paths.ts` | Pure path-normalisation helper `toAbsolutePath` used by toolInfo / toolResults / forwarding. Leaf module, no bridge-internal imports. | `toAbsolutePath` | ~20 |
|
||||
| `bridge/contentBlocks.ts` | Low-level conversion of Claude content block shapes into ACP `ContentBlock` values. `toAcpContentUpdate` wraps arrays/strings into `ToolCallContent[]` via `toAcpContentBlock`. Leaf module. | `toAcpContentUpdate`, `toAcpContentBlock` | ~150 |
|
||||
| `bridge/toolInfo.ts` | `toolInfoFromToolUse` — large switch mapping each known tool name (Agent/Task, Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, TodoWrite, ExitPlanMode, default) to ACP `ToolInfo` (title, kind, content, locations). Depends on `paths.toAbsolutePath` and `../utils.js` (`toDisplayPath`). | `toolInfoFromToolUse` | ~250 |
|
||||
| `bridge/toolResults.ts` | `toolUpdateFromToolResult` (Read markdown escape, Bash console fence, Edit/Write no-op, ExitPlanMode title, default via `toAcpContentUpdate`); `toolUpdateFromEditToolResponse` (parses `structuredPatch` hunks into diff `ToolCallContent` with absolute paths). Depends on `contentBlocks` and `paths`. | `toolUpdateFromToolResult`, `toolUpdateFromEditToolResponse` | ~180 |
|
||||
| `bridge/modelUsage.ts` | `commonPrefixLength` and `getMatchingModelUsage` — pure helpers used by the forwarding loop to resolve `contextWindow` from `modelUsage` map by prefix match. Leaf module. | `commonPrefixLength`, `getMatchingModelUsage` | ~35 |
|
||||
| `bridge/notifications.ts` | Core content-block → `SessionUpdate` conversion engine. `toAcpNotifications` handles text/thinking/image/tool_use/tool_result/etc. and writes into `ToolUseCache`. `assistantMessageToAcpNotifications` and `streamEventToAcpNotifications` are thin adapters. `normalizePlanStatus` helper for TodoWrite plan mapping. Depends on `toolInfo.toolInfoFromToolUse`, `toolResults.toolUpdateFromToolResult`, and `types`. **No logger** in original — do NOT add one here. | `toAcpNotifications`, `assistantMessageToAcpNotifications`, `streamEventToAcpNotifications`, `normalizePlanStatus` | ~320 |
|
||||
| `bridge/forwarding.ts` | `nextSdkMessageOrAbort` (races async generator against `AbortSignal`); `forwardSessionUpdates` (main loop consuming `SDKMessage` stream, dispatching to notification converters, accumulating usage, mapping stop reasons); `replayHistoryMessages` (replays stored user/assistant history through `toAcpNotifications`). The module-level `const logger = console` lives here (only `forwardSessionUpdates` default branch and `replayHistoryMessages` reference `logger.debug`). Depends on `types`, `notifications`, `modelUsage`. | `nextSdkMessageOrAbort`, `forwardSessionUpdates`, `replayHistoryMessages` | ~280 |
|
||||
| `bridge/index.ts` | Barrel — see content below. | 8 re-exports | ~20 |
|
||||
|
||||
### Barrel content — `src/services/acp/bridge/index.ts`
|
||||
|
||||
```ts
|
||||
// Barrel preserving the public API of the former src/services/acp/bridge.ts.
|
||||
// Do NOT add internal-only exports here: permissions.test.ts snapshots the
|
||||
// entire module surface via require('../bridge.ts') and would break if the
|
||||
// exported name set changes.
|
||||
export type { ToolUseCache, SessionUsage } from './types.js'
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
} from './toolInfo.js'
|
||||
export {
|
||||
toolUpdateFromToolResult,
|
||||
toolUpdateFromEditToolResponse,
|
||||
} from './toolResults.js'
|
||||
export {
|
||||
nextSdkMessageOrAbort,
|
||||
forwardSessionUpdates,
|
||||
replayHistoryMessages,
|
||||
} from './forwarding.js'
|
||||
```
|
||||
|
||||
### Phase 1 verification
|
||||
|
||||
```bash
|
||||
# After creating all sub-files and deleting bridge.ts:
|
||||
bun test src/services/acp/__tests__/bridge.test.ts
|
||||
bun test src/services/acp/__tests__/permissions.test.ts # snapshot-sensitive
|
||||
bun test src/services/acp/__tests__/agent.test.ts # imports bridge.js + agent.js
|
||||
bun run precheck # typecheck + lint + test
|
||||
```
|
||||
|
||||
### Phase 1 risk callouts
|
||||
|
||||
- **Snapshot sensitivity**: `permissions.test.ts` lines 34–35 do `require('../bridge.ts')` and snapshot every named export. The barrel MUST export exactly `{ ToolUseCache, SessionUsage, toolInfoFromToolUse, toolUpdateFromToolResult, toolUpdateFromEditToolResponse, nextSdkMessageOrAbort, forwardSessionUpdates, replayHistoryMessages }`. Do NOT re-export `ToolInfo`, `BridgeSDKMessage`, or any internal helper.
|
||||
- **Logger alias**: the original `const logger = console` is a top-level const with no runtime side effect. Keep it ONLY in `forwarding.ts`. Do NOT create a shared `logger.ts` (would risk a cycle) and do NOT give `notifications.ts` its own logger (the original does not reference one).
|
||||
- **`ToolInfo` stays internal**: it is the return type of `toolInfoFromToolUse` but was never exported from the original `bridge.ts`. Keep it module-internal so the public surface matches the original exactly.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — `src/services/acp/agent.ts`
|
||||
|
||||
### Directory structure
|
||||
|
||||
```
|
||||
src/services/acp/
|
||||
├── agent.ts ← DELETED (replaced by directory)
|
||||
└── agent/
|
||||
├── index.ts ← barrel (re-exports AcpAgent)
|
||||
├── sessionTypes.ts ← AcpSession / PendingPrompt types
|
||||
├── permissionMode.ts ← permission mode resolution
|
||||
├── configOptions.ts ← config option list builder
|
||||
├── promptQueue.ts ← pending-prompt queue helpers
|
||||
└── AcpAgent.ts ← the AcpAgent class body
|
||||
```
|
||||
|
||||
### Files, responsibilities, line budgets
|
||||
|
||||
| File | Responsibility | Exports | Budget |
|
||||
|------|----------------|---------|-------:|
|
||||
| `agent/sessionTypes.ts` | Type definitions for in-process ACP session state. `AcpSession` and `PendingPrompt` type aliases shared across agent internals and helpers. | `AcpSession`, `PendingPrompt` | ~35 |
|
||||
| `agent/permissionMode.ts` | Resolve the effective permission mode from `_meta`, settings, and process env. Determine whether ACP `bypassPermissions` mode is available (process + local opt-in + settings). `PermissionMode`-id validation guard. Imports `PermissionMode` type from `../../types/permissions.js` and `resolvePermissionMode` from `../utils.js` — leaf module, does NOT import AcpAgent. | `permissionModeIds`, `isPermissionMode`, `resolveSessionPermissionMode`, `isAcpBypassPermissionModeAvailable`, `hasOwnField` | ~110 |
|
||||
| `agent/configOptions.ts` | Build the ACP session config option list (mode + model select options) from session states. `flattenConfigOptionValues` flattens grouped/flat select options into valid value strings for validation. Imports ACP SDK types (`SessionModeState`, `SessionModelState`, `SessionConfigOption`). Leaf module. | `buildConfigOptions`, `flattenConfigOptionValues` | ~70 |
|
||||
| `agent/promptQueue.ts` | Pending-prompt queue management: `popNextPendingPrompt`, `compactPendingQueue` (compacts queue head to bound memory). Pure helpers operating on `AcpSession.pendingQueue` / `pendingMessages`. Imports `sessionTypes` only. | `popNextPendingPrompt`, `compactPendingQueue` | ~45 |
|
||||
| `agent/AcpAgent.ts` | The `AcpAgent` class implementing the ACP Agent interface. All protocol method handlers (`initialize`, `authenticate`, `newSession`, `resumeSession`, `loadSession`, `listSessions`, `forkSession`, `closeSession`, `prompt`, `cancel`, `setSessionMode`, `setSessionModel`, `setSessionConfigOption`) and private lifecycle helpers (`createSession`, `getOrCreateSession`, `teardownSession`, `replaySessionHistory`, `applySessionMode`, `updateConfigOption`, `syncSessionConfigState`, `sendAvailableCommandsUpdate`, `scheduleAvailableCommandsUpdate`, `maybeEmitSessionInfoUpdate`, `getSetting`). Imports `sessionTypes`, `permissionMode`, `configOptions`, `promptQueue`. Imports `ToolUseCache`, `forwardSessionUpdates`, `replayHistoryMessages` from `../bridge.js` (the Phase 1 barrel). | `AcpAgent` | ~480 |
|
||||
| `agent/index.ts` | Barrel — see content below. | `AcpAgent` | ~5 |
|
||||
|
||||
### Barrel content — `src/services/acp/agent/index.ts`
|
||||
|
||||
```ts
|
||||
// Barrel preserving the public API of the former src/services/acp/agent.ts.
|
||||
// Tests import AcpAgent via '../agent.js' (Bun/tsc resolve the directory's
|
||||
// index.ts). Keep this file to a single re-export.
|
||||
export { AcpAgent } from './AcpAgent.js'
|
||||
```
|
||||
|
||||
### Why the class body is NOT split further
|
||||
|
||||
The `AcpAgent` class is a single cohesive unit bound by `this.sessions` and `this.conn`. Methods like `createSession`, `prompt`, `cancel`, `teardownSession`, `applySessionMode`, `updateConfigOption` all reference `this.*` and shared private helpers. Extracting methods to a separate module would require passing the session map and connection as parameters and would create tight bidirectional coupling with high cycle risk. Therefore the class body stays in one module (~480 lines, under the 500 limit); only pure helpers and types are extracted. This keeps the import graph strictly acyclic: `sessionTypes`/`permissionMode`/`configOptions`/`promptQueue` are pure leaves that never import `AcpAgent`.
|
||||
|
||||
### Phase 2 verification
|
||||
|
||||
```bash
|
||||
bun test src/services/acp/__tests__/agent.test.ts # imports ../agent.js + ../bridge.js
|
||||
bun test src/services/acp/__tests__/permissions.test.ts # still green after bridge split
|
||||
bun run precheck
|
||||
```
|
||||
|
||||
### Phase 2 risk callouts
|
||||
|
||||
- **Private method coupling**: keep the class intact in `AcpAgent.ts`; do not be tempted to extract methods even if the file approaches the budget.
|
||||
- **ToolUseCache shape coupling**: `maybeEmitSessionInfoUpdate` attaches `__sessionInfoTitleSent` to `session.toolUseCache` via a structural cast. Keep that logic inside `AcpAgent.ts` so no cross-module dependency on the extended shape is introduced.
|
||||
- **Test path stability**: `agent.test.ts` line 195 does `await import('../agent.js')`. With `agent/index.ts` re-exporting `AcpAgent` from `agent/AcpAgent.ts`, the specifier resolves under Bun/TS because directory imports map to `index.ts`. The barrel MUST use the `.js` extension (`export { AcpAgent } from './AcpAgent.js'`) to match the project's ESM convention.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — `packages/acp-link/src/server.ts`
|
||||
|
||||
### Directory structure
|
||||
|
||||
```
|
||||
packages/acp-link/src/
|
||||
├── server.ts ← DELETED (replaced by directory)
|
||||
└── server/
|
||||
├── index.ts ← barrel (public API)
|
||||
├── types.ts ← protocol/state types + JSON-RPC codes
|
||||
├── runtime-state.ts ← module-scoped mutable state container
|
||||
├── client-send.ts ← outbound message framing
|
||||
├── acp-client.ts ← createClient + permission helpers
|
||||
├── payload-decode.ts ← validation/decode utilities
|
||||
├── permission-mode.ts ← permission mode resolution
|
||||
├── handlers-agent.ts ← agent lifecycle handlers
|
||||
├── handlers-session.ts ← session-scoped handlers
|
||||
├── dispatch.ts ← dispatch + JSON-RPC wrappers + table
|
||||
├── testing-internals.ts ← __testing public object
|
||||
└── start-server.ts ← startServer orchestrator
|
||||
```
|
||||
|
||||
### Files, responsibilities, line budgets
|
||||
|
||||
| File | Responsibility | Exports | Budget |
|
||||
|------|----------------|---------|-------:|
|
||||
| `server/types.ts` | Shared protocol/state type definitions used across all server modules (`ServerConfig`, `PendingPermission`, `PromptCapabilities`, `SessionModelState`, `AgentCapabilities`, `ClientState`, `ContentBlock`, `PermissionResponsePayload`, `ProxyMessage`); `createClientState` factory; `DEFAULT_CLIENT_INFO` / `DEFAULT_CLIENT_CAPABILITIES` constants; JSON-RPC error code constants. | 16 symbols | ~200 |
|
||||
| `server/runtime-state.ts` | Module-scoped mutable state container for the running server: holds the `clients` Map, server config fields (`AGENT_*`, `SERVER_*`, `AUTH_TOKEN`, `DEFAULT_PERMISSION_MODE`), `rcsUpstream`, loggers, and accessor/mutator helpers. `createRelayWs` virtual `WSContext` factory. `generateRequestId` helper. **MUST NOT import any handler module** to avoid cycles. | `clients`, `getServerConfig`, `setServerConfig`, `getRcsUpstream`, `setRcsUpstream`, `getAgentConfig`, `getDefaultPermissionMode`, `setDefaultPermissionMode`, `logWs`, `logAgent`, `logSession`, `logPrompt`, `logPerm`, `logRelay`, `logServer`, `PERMISSION_TIMEOUT_MS`, `HEARTBEAT_INTERVAL_MS`, `createRelayWs`, `generateRequestId` | ~140 |
|
||||
| `server/client-send.ts` | Outbound message framing: `send`, `sendJsonRpcRaw`, `sendJsonRpcError`. `LEGACY_NOTIFICATION_TO_JSONRPC` mapping. Depends on `runtime-state` (`clients`, `rcsUpstream`) and `types` (`ClientState`). Reads `rcsUpstream` via runtime-state and the `clients` Map; `sendJsonRpcError` reads/writes `state.pendingJsonRpc`. | `send`, `sendJsonRpcRaw`, `sendJsonRpcError` | ~110 |
|
||||
| `server/acp-client.ts` | `createClient(ws, clientState)`: builds the `acp.Client` implementation that forwards `requestPermission` / `sessionUpdate` / `readTextFile` / `writeTextFile`. `handlePermissionResponse` and `cancelPendingPermissions`. Depends on `client-send` (`send`) and `runtime-state` (`logPerm`). Import graph: `client-send → runtime-state` (ok), `acp-client → client-send + runtime-state` (ok, no cycle). | `createClient`, `handlePermissionResponse`, `cancelPendingPermissions` | ~110 |
|
||||
| `server/payload-decode.ts` | Pure validation/decode utilities (`isRecord`, `optionalString`, `optionalStringField`, `payloadRecord`, `optionalPayloadRecord`, `optionalRecord`, `decodeContentBlocks`, `decodePermissionResponsePayload`). `decodeClientMessage` switch turning a raw record into a `ProxyMessage`. Public `decodeClientWsMessage` wrapper. `decodeClientMessage` is also consumed by `start-server.ts` (RCS relay path) — keep it exported here to avoid duplication. | 10 symbols | ~200 |
|
||||
| `server/permission-mode.ts` | `ACP_LINK_PERMISSION_MODE_ALIASES` + `resolveAcpLinkPermissionMode` + public `resolveNewSessionPermissionMode`. `buildAgentEnv` helper. | `resolveNewSessionPermissionMode`, `resolveAcpLinkPermissionMode`, `ACP_LINK_PERMISSION_MODE_ALIASES`, `buildAgentEnv` | ~90 |
|
||||
| `server/handlers-agent.ts` | Agent lifecycle + connection handlers: `handleConnect` and `handleDisconnect`. Spawns the agent child process, builds the ACP `ClientSideConnection`, surfaces status. Depends on `runtime-state`, `client-send`, `acp-client`, `types`. | `handleConnect`, `handleDisconnect` | ~160 |
|
||||
| `server/handlers-session.ts` | Session-scoped handlers: `handleNewSession`, `handleListSessions`, `handleLoadSession`, `handleResumeSession`, `handleCancel`, `handleSetSessionModel`, `handlePrompt`. All operate on `clients.get(ws)` state and forward to `ClientSideConnection`. | 7 symbols | ~360 |
|
||||
| `server/dispatch.ts` | `dispatchClientMessage` (legacy envelope switch). JSON-RPC wrappers `handleJsonRpcNewSession` / `Prompt` / `ListSessions` / `LoadSession` / `ResumeSession` / `SetSessionModel` / `SetSessionMode` / `CloseSession` / `CancelRequest`. `JSONRPC_METHOD_HANDLERS` table and `dispatchJsonRpcMessage` router. The JSON-RPC wrappers live **alongside** the table in this module (no cross-module forward reference). | `dispatchClientMessage`, `dispatchJsonRpcMessage`, `JSONRPC_METHOD_HANDLERS`, `handleJsonRpcSetSessionMode`, `handleJsonRpcCloseSession`, `handleJsonRpcCancelRequest` | ~290 |
|
||||
| `server/testing-internals.ts` | `__testing` public object (`dispatchClientMessage` / `dispatchJsonRpcMessage` / `registerClient` / `getClientSessionId` / `setDefaultPermissionMode`). `assertTestingInternalsEnabled` guard gated on `ACP_LINK_TEST_INTERNALS`. Co-locate the guard with the methods that call it. | `__testing`, `assertTestingInternalsEnabled` | ~80 |
|
||||
| `server/start-server.ts` | `startServer(config)`: configures runtime-state, wires `RcsUpstreamClient` relay, builds the Hono app with `/health` and `/ws` (token validation, `onOpen` / `onMessage` / `onClose`, heartbeat), HTTPS option, startup banner, SIGINT/SIGTERM graceful shutdown. Top-level orchestrator importing from `runtime-state`, `client-send`, `acp-client`, `dispatch`, `payload-decode`. All intervals/sockets MUST be created inside `startServer` (no top-level side effects). | `startServer` | ~280 |
|
||||
| `server/index.ts` | Barrel — see content below. | 8 re-exports | ~25 |
|
||||
|
||||
### Barrel content — `packages/acp-link/src/server/index.ts`
|
||||
|
||||
```ts
|
||||
// Barrel preserving the public API of the former packages/acp-link/src/server.ts.
|
||||
//
|
||||
// Re-exports of MAX_CLIENT_WS_PAYLOAD_BYTES / isJsonRpc2Message /
|
||||
// JsonRpc2ClientMessage MUST come from '../ws-message.js' (single source of
|
||||
// truth) — do NOT route them through a split module.
|
||||
export type { ServerConfig } from './types.js'
|
||||
export {
|
||||
MAX_CLIENT_WS_PAYLOAD_BYTES,
|
||||
isJsonRpc2Message,
|
||||
} from '../ws-message.js'
|
||||
export type { JsonRpc2ClientMessage } from '../ws-message.js'
|
||||
export { decodeClientWsMessage } from './payload-decode.js'
|
||||
export { resolveNewSessionPermissionMode } from './permission-mode.js'
|
||||
export { __testing } from './testing-internals.js'
|
||||
export { startServer } from './start-server.js'
|
||||
```
|
||||
|
||||
### Phase 3 verification
|
||||
|
||||
```bash
|
||||
bun test packages/acp-link/src/__tests__/server.test.ts
|
||||
bun test packages/acp-link/src/__tests__/types.test.ts
|
||||
bun run precheck
|
||||
bun run build # confirm chunk count is sane and dist/cli.js builds
|
||||
```
|
||||
|
||||
### Phase 3 risk callouts
|
||||
|
||||
- **Module-scoped mutable state**: `AGENT_COMMAND`, `AGENT_ARGS`, `AGENT_CWD`, `SERVER_PORT`, `SERVER_HOST`, `AUTH_TOKEN`, `DEFAULT_PERMISSION_MODE`, the `clients` Map, and `rcsUpstream` all live in `runtime-state.ts`. Every other module accesses them via the accessors/setters. Keep `runtime-state.ts` free of any handler import — it is the shared leaf that everything else depends on; importing handlers back into it creates a cycle.
|
||||
- **Single-flight invariant**: `sendJsonRpcError` reads/writes `state.pendingJsonRpc`. Do not parallelise handlers — the pendingJsonRpc invariant depends on serial mutation of `ClientState`.
|
||||
- **JSON-RPC wrappers co-located with the table**: `JSONRPC_METHOD_HANDLERS` references the `handleJsonRpc*` wrappers. To avoid cross-module forward references, the wrappers and the table MUST live in the same `dispatch.ts` module.
|
||||
- **Re-exports stay at source**: `MAX_CLIENT_WS_PAYLOAD_BYTES`, `isJsonRpc2Message`, `JsonRpc2ClientMessage` are re-exported from `'../ws-message.js'` directly. Do NOT re-export them from a split module.
|
||||
- **No top-level side effects**: the original file only declares module-scoped vars; loggers are created eagerly via `createLogger` (acceptable — pure construction). Do NOT start intervals or open sockets at module top level; keep them inside `startServer`.
|
||||
- **assertTestingInternalsEnabled gating**: the guard is gated on `ACP_LINK_TEST_INTERNALS` and is called by every `__testing` method. Co-locate it with `__testing` in `testing-internals.ts` and preserve the gating behavior verbatim.
|
||||
- **Biome lint surface**: 42 rules are disabled for decompiled code. Moving helpers like `optionalStringField` into their own module may surface `noUnusedVariables` if they are not re-exported. Export every helper that was previously file-local but is now cross-module, and run `bun run precheck` to catch new warnings.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting verification (run after ALL three phases)
|
||||
|
||||
```bash
|
||||
# 1. Full type + lint + test gate (REQUIRED zero errors per CLAUDE.md)
|
||||
bun run precheck
|
||||
|
||||
# 2. Targeted regression runs for the three refactored modules
|
||||
bun test packages/acp-link/src/__tests__/server.test.ts
|
||||
bun test src/services/acp/__tests__/bridge.test.ts
|
||||
bun test src/services/acp/__tests__/agent.test.ts
|
||||
bun test src/services/acp/__tests__/permissions.test.ts
|
||||
|
||||
# 3. Build sanity (new chunks are produced for the new sub-files)
|
||||
bun run build
|
||||
ls dist/chunks | wc -l # expect a modest increase over the previous count
|
||||
|
||||
# 4. Unused-export audit (catches accidentally-leaked internal exports)
|
||||
bun run check:unused
|
||||
```
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `bun run precheck` passes with zero errors.
|
||||
- [ ] All four target test files pass unmodified.
|
||||
- [ ] `from '../server.js'`, `from '../bridge.js'`, `from '../agent.js'` all resolve correctly (verified by the passing tests).
|
||||
- [ ] No new file exceeds 500 lines.
|
||||
- [ ] `permissions.test.ts` snapshot of `require('../bridge.ts')` still matches the original 8-symbol public surface.
|
||||
- [ ] `bun run build` succeeds with a sane chunk count.
|
||||
- [ ] No test file is modified in the diff.
|
||||
File diff suppressed because it is too large
Load Diff
102
packages/acp-link/src/server/acp-client.ts
Normal file
102
packages/acp-link/src/server/acp-client.ts
Normal 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()
|
||||
}
|
||||
89
packages/acp-link/src/server/client-send.ts
Normal file
89
packages/acp-link/src/server/client-send.ts
Normal 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
|
||||
}
|
||||
335
packages/acp-link/src/server/dispatch.ts
Normal file
335
packages/acp-link/src/server/dispatch.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
158
packages/acp-link/src/server/handlers-agent.ts
Normal file
158
packages/acp-link/src/server/handlers-agent.ts
Normal 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 })
|
||||
}
|
||||
435
packages/acp-link/src/server/handlers-session.ts
Normal file
435
packages/acp-link/src/server/handlers-session.ts
Normal 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}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
161
packages/acp-link/src/server/payload-decode.ts
Normal file
161
packages/acp-link/src/server/payload-decode.ts
Normal 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))
|
||||
}
|
||||
71
packages/acp-link/src/server/permission-mode.ts
Normal file
71
packages/acp-link/src/server/permission-mode.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
125
packages/acp-link/src/server/runtime-state.ts
Normal file
125
packages/acp-link/src/server/runtime-state.ts
Normal 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)}`
|
||||
}
|
||||
291
packages/acp-link/src/server/start-server.ts
Normal file
291
packages/acp-link/src/server/start-server.ts
Normal 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(() => {})
|
||||
}
|
||||
65
packages/acp-link/src/server/testing-internals.ts
Normal file
65
packages/acp-link/src/server/testing-internals.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
172
packages/acp-link/src/server/types.ts
Normal file
172
packages/acp-link/src/server/types.ts
Normal 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
404
src/services/acp/agent/AcpAgent.ts
Normal file
404
src/services/acp/agent/AcpAgent.ts
Normal 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 }
|
||||
}
|
||||
74
src/services/acp/agent/configOptions.ts
Normal file
74
src/services/acp/agent/configOptions.ts
Normal 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
|
||||
}
|
||||
291
src/services/acp/agent/createSessionMethod.ts
Normal file
291
src/services/acp/agent/createSessionMethod.ts
Normal 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,
|
||||
})
|
||||
54
src/services/acp/agent/internalAccessors.ts
Normal file
54
src/services/acp/agent/internalAccessors.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
115
src/services/acp/agent/permissionMode.ts
Normal file
115
src/services/acp/agent/permissionMode.ts
Normal 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'
|
||||
}
|
||||
293
src/services/acp/agent/promptFlow.ts
Normal file
293
src/services/acp/agent/promptFlow.ts
Normal 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,
|
||||
})
|
||||
36
src/services/acp/agent/promptQueue.ts
Normal file
36
src/services/acp/agent/promptQueue.ts
Normal 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
|
||||
}
|
||||
}
|
||||
280
src/services/acp/agent/sessionLifecycle.ts
Normal file
280
src/services/acp/agent/sessionLifecycle.ts
Normal 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,
|
||||
})
|
||||
35
src/services/acp/agent/sessionTypes.ts
Normal file
35
src/services/acp/agent/sessionTypes.ts
Normal 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
146
src/services/acp/bridge/contentBlocks.ts
Normal file
146
src/services/acp/bridge/contentBlocks.ts
Normal 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]' }
|
||||
}
|
||||
}
|
||||
}
|
||||
402
src/services/acp/bridge/forwarding.ts
Normal file
402
src/services/acp/bridge/forwarding.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/services/acp/bridge/modelUsage.ts
Normal file
27
src/services/acp/bridge/modelUsage.ts
Normal 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
|
||||
}
|
||||
351
src/services/acp/bridge/notifications.ts
Normal file
351
src/services/acp/bridge/notifications.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
17
src/services/acp/bridge/paths.ts
Normal file
17
src/services/acp/bridge/paths.ts
Normal 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)
|
||||
}
|
||||
239
src/services/acp/bridge/toolInfo.ts
Normal file
239
src/services/acp/bridge/toolInfo.ts
Normal 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: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
184
src/services/acp/bridge/toolResults.ts
Normal file
184
src/services/acp/bridge/toolResults.ts
Normal 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
|
||||
}
|
||||
188
src/services/acp/bridge/types.ts
Normal file
188
src/services/acp/bridge/types.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user