mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 08:15:53 +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