diff --git a/.qwen/skills/memory-leak-debug/scripts/find-leaf-node.sh b/.qwen/skills/memory-leak-debug/scripts/find-leaf-node.sh old mode 100755 new mode 100644 diff --git a/docs/design/daemon-acp-http/README.md b/docs/design/daemon-acp-http/README.md new file mode 100644 index 0000000000..84f4a05ea8 --- /dev/null +++ b/docs/design/daemon-acp-http/README.md @@ -0,0 +1,568 @@ +# Daemon ACP-over-HTTP → Official ACP Streamable HTTP Transport + +> Targets `daemon_mode_b_main`. Branch: `feat/daemon-acp-http-streamable`. +> Author: arnoo.gao. Date: 2026-05-24. Status: **Design v1 → implementation**. +> Design-first per repo workflow: this doc lands before/with the implementation PR so the wire contract is reviewable. + +--- + +## 0. TL;DR + +The daemon (`qwen serve`) today speaks a **bespoke REST + SSE** dialect to web/SDK +clients, while speaking **real ACP JSON-RPC over stdio** to the spawned `qwen --acp` +child. This proposal adds a **second northbound transport** that implements the +**official ACP Streamable HTTP transport** (RFD #721) at a single `/acp` endpoint, +so any ACP-native client (Zed, Goose, future SDKs) can drive the daemon directly +over the standard protocol — no qwen-specific REST knowledge required. + +**Decision: dual-transport, additive.** The new `/acp` endpoint is mounted +alongside the existing REST surface, reusing the same `HttpAcpBridge` + +`EventBus` underneath. The REST API is *not* removed. Rationale in §6. + +**Decision: extension namespace = `_qwen/…`** (single-underscore prefix, the +ACP-spec-reserved form for custom methods) for daemon features that have no +standard ACP method (model switch, workspace introspection, heartbeat, +multi-client permission policy, SSE backpressure tuning). Rationale in §5. + +A complete, locally-runnable reference implementation ships in this PR +(`packages/cli/src/serve/acpHttp/`) plus a verification harness +(`scripts/acp-http-smoke.mjs`). + +--- + +## 1. Background — what "ACP over HTTP" means today + +Three tiers (verified at commit `0c0430939`): + +``` +┌──────────────┐ bespoke REST + SSE (HTTP/1.1) ┌────────────┐ ACP JSON-RPC ┌──────────────┐ +│ web / SDK │ ───────────────────────────────► │ qwen │ (stdio NDJSON) │ qwen --acp │ +│ client │ ◄─── GET /session/:id/events ──── │ serve │ ◄─────────────► │ child (Agent)│ +│ (ACP client) │ (text/event-stream) │ (daemon) │ ndJsonStream │ │ +└──────────────┘ └────────────┘ └──────────────┘ + northbound: NOT ACP wire bridge southbound: real ACP +``` + +### 1.1 Northbound (client ↔ daemon) — bespoke, today + +- Express 5 app in `packages/cli/src/serve/server.ts` (~30 routes). +- Discrete REST verbs, **not** JSON-RPC: + - `POST /session` (create), `POST /session/:id/prompt`, `POST /session/:id/cancel`, + `POST /session/:id/load|resume`, `POST /session/:id/model`, + `POST /session/:id/permission/:requestId`, `POST /session/:id/heartbeat`, + `DELETE /session/:id`, plus `/workspace/*`, `/capabilities`, `/health`. +- Server→client streaming: `GET /session/:id/events` → `text/event-stream`. + - Frames: `id: \nevent: \ndata: \n\n` (`server.ts:formatSseFrame`, ~2626). + - Per-session **monotonic `id`** + `Last-Event-ID` resume backed by a + ring-buffer `EventBus` (`acp-bridge/src/eventBus.ts`). + - Event `type`s: `session_update`, `client_evicted`, `slow_client_warning`, + `state_resync_required`, `stream_error`, … +- Auth: `Authorization: Bearer ` (`serve/auth.ts`), CORS deny + host allowlist. +- Backpressure: per-connection serialized write chain + 15 s heartbeat comments. + +### 1.2 Southbound (daemon ↔ child) — already ACP + +- `acp-bridge/src/spawnChannel.ts` spawns `qwen --acp`, wraps stdin/stdout with + `ndJsonStream` from `@agentclientprotocol/sdk` (`^0.14.1`). +- `acp-bridge/src/bridge.ts:729` `new ClientSideConnection(() => client, channel.stream)` + — the daemon is the ACP **client**, the child is the ACP **agent**. +- Extension methods already in use on this leg: `unstable_setSessionModel`, + `unstable_resumeSession`, `unstable_listSessions` (`acp-integration/acpAgent.ts`). + +### 1.3 Why migrate the northbound + +- Every client (webui, TS SDK, Java SDK, Python SDK, VSCode companion) re-implements + the bespoke REST mapping. An ACP-standard endpoint lets ACP-native editors attach + with zero qwen-specific glue. +- Aligns the daemon's remote surface with the protocol it already speaks internally. + +--- + +## 2. Target: ACP Streamable HTTP (RFD #721) + +Merged **Draft** RFD (`agentclientprotocol/agent-client-protocol#721`, merged 2026-04-22). +Not yet normative; not yet in any SDK. We implement against the RFD wire design. + +### 2.1 Endpoint & verbs (single `/acp`) + +| Verb | Behavior | +|------|----------| +| `POST /acp` | Send JSON-RPC. `initialize` → **`200`** + JSON body (capabilities) and sets `Acp-Connection-Id`. All other requests/notifications → **`202 Accepted`**, empty body; the *response* (if any) is delivered on the matching long-lived SSE stream. | +| `GET /acp` | Open a long-lived **SSE** stream. (`Upgrade: websocket` → WebSocket; **deferred**, see §7.) | +| `DELETE /acp` | Terminate the connection → `202`. | + +### 2.2 Two-tier long-lived streams + +- **Connection-scoped stream**: `GET /acp` with header `Acp-Connection-Id`, no session + header. Carries connection-level responses (`session/new`, `session/load`, + `authenticate`) and connection-level notifications. +- **Session-scoped stream**: `GET /acp` with `Acp-Connection-Id` **and** `Acp-Session-Id`. + Carries `session/update` notifications, **agent→client requests** + (`session/request_permission`, `fs/read_text_file`, …), and responses to + session POSTs (`session/prompt`, `session/cancel`). + +### 2.3 Identity (3 layers) + +- `Acp-Connection-Id` (HTTP header) — transport binding, minted at `initialize`. +- `Acp-Session-Id` (HTTP header) — required on session-scoped GET + session POSTs. +- `sessionId` (JSON-RPC param) — inside method params (must match the header). + +### 2.4 Divergences from MCP StreamableHTTP + +ACP uses **long-lived** streams (not per-request SSE), **two** ID headers (connection +vs session), `202`-for-non-initialize, HTTP/2-required, WebSocket-required-client. We +borrow the single-endpoint + POST/GET-SSE + session-header skeleton but adapt to the +long-lived dual-ID model. We do **not** reuse `@modelcontextprotocol/sdk`'s +`StreamableHTTPServerTransport` (its per-request stream model and single +`Mcp-Session-Id` don't fit). + +### 2.5 Standard methods (confirmed from current schema) + +- Client→Agent requests: `initialize`, `authenticate`, `session/new`, `session/load`, + `session/prompt`, `session/resume`, `session/close`, `session/list`, + `session/set_mode`, `session/set_config_option`, `logout`. +- Client→Agent notification: `session/cancel`. +- Agent→Client requests: `fs/read_text_file`, `fs/write_text_file`, + `session/request_permission`, `terminal/create|output|wait_for_exit|kill|release`. +- Agent→Client notification: `session/update`. + +--- + +## 3. Architecture of the new transport + +The daemon must present an **ACP Agent surface over HTTP** northbound, while it +remains an ACP **client** to the child southbound. The `/acp` layer is therefore a +**JSON-RPC router** that terminates the HTTP transport and bridges into the existing +`HttpAcpBridge`. + +``` + POST /acp (JSON-RPC requests/responses/notifs) +client ──────────────────────────────────────────────► ┌───────────────────────────┐ +(editor) │ AcpHttpTransport │ + ◄── GET /acp (connection-scoped SSE) ────────── │ - connection registry │ + ◄── GET /acp (session-scoped SSE) ───────────── │ - JSON-RPC id correlation│ + │ - method dispatch │ + └────────────┬──────────────┘ + │ reuses + ┌────────────▼──────────────┐ + │ HttpAcpBridge + EventBus │ (unchanged) + └────────────┬──────────────┘ + │ ACP stdio (unchanged) + qwen --acp child +``` + +### 3.1 New module layout (`packages/cli/src/serve/acpHttp/`) + +| File | Responsibility | +|------|----------------| +| `index.ts` | `mountAcpHttp(app, bridge, opts)` — registers `/acp` routes on the existing Express app. | +| `connectionRegistry.ts` | `Acp-Connection-Id` → `AcpConnection` (connection SSE writer, `Map`, pending agent→client requests by JSON-RPC id, monotonic id allocator). TTL + DELETE cleanup. | +| `jsonRpc.ts` | JSON-RPC 2.0 parse/validate/serialize helpers; error codes (`-32600` etc.); `_qwen/` namespace guard. | +| `dispatch.ts` | Maps inbound JSON-RPC methods → `HttpAcpBridge` calls. Maps `BridgeEvent`s → outbound JSON-RPC frames. The translation table (§4). | +| `sseStream.ts` | Long-lived SSE writer (reuses the backpressure/heartbeat pattern from `server.ts`). Distinct from REST `/events` (different framing: full JSON-RPC objects, not qwen event envelopes). | + +No change to `bridge.ts` / `eventBus.ts` (additive consumer only). + +### 3.2 Connection & session lifecycle + +1. `POST /acp {initialize}` → mint `connectionId`, create `AcpConnection`, reply `200` + with `{protocolVersion, agentCapabilities, _meta:{qwen:{…}}}` + `Acp-Connection-Id` header. +2. Client opens `GET /acp` (connection-scoped) carrying `Acp-Connection-Id`. +3. `POST /acp {session/new}` → `202`; daemon calls `bridge.createSession(...)`; pushes + the JSON-RPC response (with `sessionId`) down the **connection** stream. +4. Client opens `GET /acp` (session-scoped) with `Acp-Connection-Id`+`Acp-Session-Id`; + daemon `bridge.subscribeEvents(sessionId)` and pipes translated frames. +5. `POST /acp {session/prompt}` → `202`; `bridge.sendPrompt(...)`; `session/update` + notifications stream live on the session stream; the final prompt **response** + (`{id, result:{stopReason}}`) is pushed on the session stream when it settles. +6. Agent→client request (e.g. `session/request_permission`) is emitted as a JSON-RPC + **request** on the session stream with a daemon-allocated id; the client answers via + `POST /acp {id, result}`; `dispatch` resolves it through the bridge's permission API. +7. `DELETE /acp` (or connection-stream close + TTL) tears down sessions/subscriptions. + +--- + +## 4. Translation table (bridge ⇄ ACP/HTTP) + +### 4.1 Inbound (client POST → bridge) + +| ACP method | Bridge call | Response routed to | +|------------|-------------|--------------------| +| `initialize` | (none; capabilities from `capabilities.ts`) | inline `200` | +| `authenticate` | existing auth provider (`serve/auth/*`) | connection stream | +| `session/new` | `bridge.createSession` | connection stream | +| `session/load` / `session/resume` | `bridge.restoreSession('load'|'resume')` | connection stream | +| `session/prompt` | `bridge.sendPrompt` | session stream (deferred until settle) | +| `session/cancel` (notif) | `bridge.cancel` | — | +| `session/list` | `bridge.listSessions` (`unstable_listSessions`) | connection stream | +| `session/set_mode` | approval-mode route logic | session stream | +| JSON-RPC **response** (to agent→client req) | resolve pending (`§4.3`) | — | +| `_qwen/session/set_model` | `bridge.setSessionModel` (`unstable_setSessionModel`) | session stream | +| `_qwen/workspace/list` etc. | workspace introspection routes | connection stream | +| `_qwen/session/heartbeat` | `bridge.heartbeat` | connection stream | + +### 4.2 Outbound (BridgeEvent → JSON-RPC on session stream) + +| BridgeEvent.type | Emitted as | +|------------------|-----------| +| `session_update` | `{method:"session/update", params:}` notification | +| permission request | `{id:, method:"session/request_permission", params}` request | +| `client_evicted` / `slow_client_warning` / `state_resync_required` | `{method:"_qwen/notify", params:{kind,…}}` notification | +| `stream_error` | JSON-RPC error response on the active prompt id (or `_qwen/notify`) | +| prompt settle | `{id:, result:{stopReason}}` | + +### 4.3 Pending agent→client requests + +`AcpConnection` keeps `Map`. +When the client POSTs a JSON-RPC response object, `dispatch` matches `id`, then calls the +bridge resolution path (e.g. permission `POST /session/:id/permission/:requestId` +internal equivalent). + +> **v1 status:** only the `session/request_permission` agent→client round-trip is +> implemented. `fs/*` and `terminal/*` agent→client forwarding is **deferred** (§7) — the +> daemon does not yet advertise `fs`/`terminal` client-capability negotiation on `/acp`, +> so ACP clients should not assume filesystem/terminal semantics over this transport in +> v1. The intended end state (forward `fs/*` to the client; fall back to the daemon's +> workspace FS when the client lacks the `fs` capability) is the follow-up described in §7. + +--- + +## 5. Extension strategy (requirement #2) + +ACP reserves any method starting with `_` for custom extensions and provides `_meta` +on every type. The codebase's southbound leg already uses `unstable_*` method names. + +**Northbound choice:** vendor-namespaced **`_qwen//`** method names +(spec-compliant `_` prefix). Capabilities advertised under +`agentCapabilities._meta.qwen` at `initialize` so clients feature-detect before use. + +| Need | No standard ACP method? | Extension | +|------|------------------------|-----------| +| Model switch | yes | `_qwen/session/set_model` | +| Workspace MCP/skills/providers/env introspection | yes | `_qwen/workspace/list`, `_qwen/workspace/` | +| Heartbeat / last-seen | yes | `_qwen/session/heartbeat` | +| Multi-client permission policy (consensus/designated) | partial | `session/request_permission` + `_meta.qwen.policy` | +| SSE backpressure tuning (`maxQueued`) | yes | `Acp-Qwen-Max-Queued` header on session GET | +| Resume cursor (ring `Last-Event-ID`) | RFD Phase 4 | `Last-Event-ID` header + `_meta.qwen.eventId` on frames | + +Standard methods are **never** renamed; extensions are strictly additive and ignorable. + +--- + +## 6. Dual-transport vs. replace (requirement #4) + +**Decision: dual-transport (additive).** + +- The official transport is a **Draft** RFD, not normative, and absent from every SDK — + hard-replacing would couple us to an unratified design and break webui + 3 SDKs + + VSCode companion at once. +- The REST surface carries features with no clean ACP mapping yet (workspace + introspection, multi-client permission mediation, ring-buffer resume, capability + registry). Those degrade to `_qwen/*` extensions on `/acp` but the REST surface stays + authoritative until the RFD ratifies. +- Both transports share **one** `HttpAcpBridge` + `EventBus` instance, so there is no + state duplication — `/acp` and `/session/*` can even drive the same live session + concurrently (multi-client is already supported by the bridge). +- Toggle (v1, shipped): on by default; **`QWEN_SERVE_ACP_HTTP=0`** disables the mount. A + `--no-acp-http` CLI flag and an `acp_http` tag in `/capabilities` for client feature- + detection are **deferred** to a follow-up (not in v1) — until then clients detect the + transport by probing `POST /acp {initialize}`. + +Migration path: once the RFD ratifies and SDKs ship, REST routes can be reframed as a +thin compat shim over `/acp` (separate, later PR). + +--- + +## 7. Scope of the implementation PR + +**In scope (runnable + verified locally):** +- `POST /acp` dispatch for `initialize`, `session/new`, `session/prompt`, + `session/cancel`, `session/load`, JSON-RPC response handling. +- Connection-scoped + session-scoped `GET /acp` SSE streams with JSON-RPC framing. +- `session/update` streaming + final prompt response correlation. +- `session/request_permission` agent→client round-trip. +- `_qwen/session/set_model` extension as the worked example of #2. +- Bearer-auth + host allowlist reuse (same middleware as REST). +- Unit tests (`acpHttp/*.test.ts`) + a black-box smoke script driving a real daemon. + +**Deferred (documented, not built now):** +- WebSocket upgrade path (RFD-required client cap; SSE suffices for local verify). +- HTTP/2 multiplexing (we run HTTP/1.1; POST and long-lived GET use separate sockets, + which works for CLI/Node clients and ≤6-connection browsers). Documented divergence. +- Full `fs/*` + `terminal/*` agent→client forwarding (permission path proves the + mechanism; rest is mechanical follow-up). +- SSE resumability hardening parity with the ring buffer (Phase 4 in RFD). + +--- + +## 8. Local verification plan + +1. `npm run build` (or workspace build of `cli` + `acp-bridge`). +2. Start daemon: `qwen serve --listen 127.0.0.1:0 --token ` (or env token). +3. Run `node scripts/acp-http-smoke.mjs`: + - `POST /acp {initialize}` → assert `200` + `Acp-Connection-Id`. + - Open connection SSE; `POST {session/new}` → assert response on stream. + - Open session SSE; `POST {session/prompt:"say hi"}` → assert ≥1 `session/update` + then a final `{result:{stopReason}}`. + - Trigger a tool needing permission → assert `session/request_permission` request, + POST a grant response → assert prompt completes. + - `POST {_qwen/session/set_model}` → assert model switch + `session/update`. +4. Vitest: `acpHttp/*.test.ts` green. + +--- + +## 9. Risks + +| Risk | Mitigation | +|------|-----------| +| RFD changes before ratification | Behind capability tag + `_qwen` namespace; isolated module; easy to revise. | +| HTTP/1.1 vs required HTTP/2 | Localhost/CLI clients unaffected; documented; h2 is a transport swap later. | +| Two transports on one bridge race | Bridge already supports multi-client; reuse its locking. | +| `fs/*` forwarding vs daemon-local FS | Capability-gated: forward when client declares `fs`, else local. | + +--- + +## 10. Implementation & verification log (v1) + +Implemented in `packages/cli/src/serve/acpHttp/` (`jsonRpc.ts`, `sseStream.ts`, +`connectionRegistry.ts`, `dispatch.ts`, `index.ts`), mounted from `server.ts` +via `mountAcpHttp(app, bridge, { boundWorkspace })`. + +### Automated (`packages/cli/src/serve/acpHttp/*.test.ts`) + +`transport.test.ts` boots a real Express server + the real `mountAcpHttp` over +a controllable fake bridge and drives it with `fetch` + manual SSE parsing. +15 tests green, covering: `initialize` 200 + `Acp-Connection-Id`; unknown-conn +400; `session/new` reply on the connection stream; prompt → `session/update` +stream + final result correlation; `session/request_permission` agent→client→ +agent round-trip; `_qwen/session/set_model`; method-not-found; `DELETE` teardown. + +### Live daemon (real model) + +Booted `qwen serve --port 8767 --token … --workspace …` (bundle entry so the +spawned `qwen --acp` child is self-contained) and ran `scripts/acp-http-smoke.mjs`: + +``` +✓ initialize: connectionId=… protocolVersion=1 +✓ session/new: sessionId=… +→ prompt: "Reply with the single word: pong" +pong +✓ prompt complete: 10 session/update frames, stopReason=end_turn +✓ DELETE /acp — connection closed +ALL CHECKS PASSED ✅ +``` + +Error-path was also confirmed live: when the child failed to start, the bridge +timeout surfaced to the client as a JSON-RPC error frame on the connection +stream (`{"id":2,"error":{"code":-32603,…}}`), proving id-correlation + the +202/SSE split under failure. + +### Review fold-in — bridge-issued clientId (found in live verify) + +First live run failed `session/prompt` with *"client id … is not registered for +session"*. Root cause: `spawnOrAttach`/`loadSession` **ignore** a caller-supplied +clientId the bridge has never issued and stamp a fresh one (returned in +`BridgeSession.clientId`); the dispatcher was echoing the connection's own +(unregistered) id on `sendPrompt`. Fix: persist the bridge-stamped id on the +`SessionBinding` and echo it on every per-session call (`sessionCtx`). Re-verified +green above. + +--- + +## 11. Review round 2 — fold-ins + +Two independent reviews (correctness/concurrency + protocol-conformance/security) plus a self-read. +All fixes verified by the expanded vitest suite (**18 tests**) + a fresh live smoke run +(21 `session/update` frames → `stopReason=end_turn`). + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| R1 | **P0** | Session-stream **reconnect was permanently dead**: `SessionBinding.abort` was created once and reused; on stream close it was aborted forever, so a reconnect's `subscribeEvents(signal)` got an already-aborted signal and received zero events. | `attachSessionStream` now installs a **fresh** `AbortController` per stream (and closes any prior stream); `index.ts` pumps on that fresh signal. | +| R2 | **P0** | `await dispatcher.handle()` ran **after** `res.end(202)`; a throwing bridge call (notably the un-try/caught `isResponse` path) would reject and surface as an unhandled rejection → possible daemon crash. | Wrapped the `isResponse` path in try/catch; `.catch()` on the awaited `handle(...)` and on `pumpSessionEvents(...)`. | +| R3 | **P1** | **No connection→session ownership**: any authenticated connection could open the session SSE for, or prompt, *any* sessionId in the workspace (read-eavesdrop; prompt was only blocked incidentally by the unregistered-clientId error). | `AcpConnection.ownedSessions` populated by `session/new`/`load`/`resume`; session stream returns `403` and per-session POSTs return `INVALID_PARAMS` for unowned ids (`requireOwned`). | +| R4 | **P1** | `mountAcpHttp` handle was discarded → TTL sweep timer + live SSE streams leaked on shutdown. | Handle parked on `app.locals`; `runQwenServe` close hook calls `dispose()` before `bridge.shutdown()` (mirrors the device-flow registry). | +| R5 | **P1** | **Pending permission leak**: closing a session/connection with a permission outstanding left the bridge blocked awaiting a vote. | `closeSessionStream`/`destroy` cancel matching pending requests via an injected `onAbandonPending` → `cancelAbandonedPermission`. | +| R6 | **P1** | Pre-attach frame buffers (`connBuffer`/`binding.buffer`) were unbounded. | Capped at 256 frames (drop-oldest), matching the EventBus `maxQueued`. | +| R7 | **P2** | `initialize` ignored the client's requested `protocolVersion`. | Negotiates `min(requested, 1)`. | +| R8 | **P2** | No `Acp-Session-Id` ↔ `params.sessionId` cross-check (RFD §2.3). | POST asserts they agree; mismatch → `INVALID_PARAMS`. | +| R9 | **P2** | `session/cancel` request-form (with id) never answered; duplicate top-level `_meta.qwen`. | Reply when an id is present; single `agentCapabilities._meta.qwen`. | + +### Accepted / documented (not fixed in v1) + +- **Prompt-result vs trailing `session/update` ordering** (P2): `handlePrompt` awaits `sendPrompt` then + writes the result frame, while updates stream concurrently. In practice the bridge publishes all + `session/update`s to the bus before `sendPrompt` resolves and both share one ordered SSE write + chain, so the result lands last (confirmed: 21 updates then result). A strict barrier is a possible + later hardening if a client reducer proves sensitive. +- **Browser `EventSource` can't set `Authorization`** — `/acp` GET streams require the bearer header, + so browsers need the deferred WebSocket path (§7); CLI/Node clients are unaffected. +- The daemon's real trust boundary remains the **bearer token + single-workspace bind** (same as the + REST surface); R3's ownership check is defense-in-depth + contract correctness, not a tenant boundary. + +--- + +## 12. Review round 3 — PR bot fold-ins (#4472) + +Two automated PR reviewers plus the summary bot. +All fixes verified by the suite (now **22 tests**) + a fresh live run (16 `session/update` → `end_turn`). + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| B1 | **P0** | `handlePrompt`'s `AbortController` was never aborted — a disconnecting/cancelling client left the agent running (burned model quota, blocked the session FIFO). Flagged by both bots + 5 sub-agents. | `promptAbort` parked on `SessionBinding`; aborted by `session/cancel` and by session/connection teardown (`closeSessionStream`/`destroy`). | +| B2 | **P0** | `sessionCtx` missing `fromLoopback` → every ACP permission vote treated as remote; `local-only` policy would reject loopback clients. | Capture loopback at `initialize` (kernel `remoteAddress`, not forgeable headers) → `AcpConnection.fromLoopback` → threaded through `sessionCtx`. | +| B3 | **P0** | SSE write failures silently swallowed → zombie streams (heartbeats fire, zero events delivered, no logs). | First write failure logs + closes the stream. | +| B4 | **P0** | Idle sweep destroyed connections with no log + no connection cap (initialize-flood). | Sweep logs each reap; `pumpSessionEvents` calls `touch()` (long quiet prompts aren't reaped); `maxConnections` cap (64) → `503`. | +| B5 | **P1** | `sessionCtx` silently fell back to the connection's unregistered clientId when the binding lacked one (untested, always-fired in `FakeBridge`). | Throw on missing stamped clientId (invariant violation); `FakeBridge` now stamps one. | +| B6 | **P1** | `session/new|load|resume` accepted `cwd` unvalidated (REST validates string/length/absolute — amplification DoS). | Shared `parseOptionalWorkspaceCwd` (string, ≤4096, absolute). | +| B7 | **P1** | `session/prompt` forwarded an unvalidated `prompt` to the bridge. | `validatePrompt` (non-empty array of objects), mirroring REST. | +| B8 | **P1** | Raw bridge error messages echoed to the client. | `toRpcError` maps known bridge errors to coded, client-safe shapes; unknown → generic `Internal error` (full detail still to stderr). | +| B9 | **P1** | `nextId` used sequential negatives — a client legally using negative ids could collide in `pending`. | Daemon-originated ids are now strings (`_qwen_perm_N`), disjoint from any client id. | +| B10 | **P2** | `resolveClientResponse` param type excluded `JsonRpcError`; conn-scoped SSE stream had no `onClose`; `DELETE` with no header was a silent 202; `SseStream.close` ran `onClose` outside try/catch; `session/load`·`resume`·`close` untested. | Widened param to `JsonRpcResponse`; conn stream logs on close; `DELETE` missing header → `400`; `onClose` wrapped in try/catch; added load/resume/close + DELETE-400 tests. | + +**Out of scope (base-branch `daemon_mode_b_main`, not this diff)** — the second reviewer flagged +typecheck errors in `acpAgent.ts` (`entryCount`/`entrySummary`/`sessionClose`) and other pre-existing +items it explicitly attributed to the base branch (introduced by #4353). Tracked separately; not +touched here. + +**Still deferred** (documented): per-connection secret for `DELETE`/connection ownership (token remains +the boundary); WebSocket + HTTP/2 (§7); strict prompt-result vs trailing-update barrier (§11). + +--- + +## 13. Review round 4 — PR fold-ins (rebased onto #4469) + +Branch rebased onto `daemon_mode_b_main` (#4353 + #4469) — **clean, no conflicts**. Two PR +reviewers (GPT-5 + qwen3.7-max). Suite now **25 tests**; live re-verified (125 `session/update` +→ `end_turn`). + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| C1 | **P0** | Round-3 "SSE write-failure handling" was documented but NOT implemented — `SseStream` still left it to discarding callers (zombie streams). | `writeRaw` now owns it: first write rejection logs once + `close()`s; `doWrite` also listens for `'error'` (rejects promptly instead of hanging to `'close'`); `onClose` wrapped in try/catch. | +| C2 | **P1** | `fromLoopback` captured only at `initialize` + helper narrower than REST → `local-only` votes from a later POST misjudged. | Per-request loopback threaded through `handle`→`sessionCtx`/`resolveClientResponse`; `isLoopbackReq` widened to `127.0.0.0/8` + `::ffff:127.*` + `::1` (matches REST). | +| C3 | **P1** | Error routing inferred stream from `params.sessionId` → conn-scoped method failures (`session/load`/`resume`/`close`/`heartbeat`) misrouted to a non-existent session stream (silent loss). | `CONN_ROUTED_METHODS` set; errors route the same way as the success path. | +| C4 | **P1** | `bridge.detachClient` never called on teardown → stale bridge-stamped client ids linger in `knownClientIds()`/voter sets. | Registry takes a `DetachSessionFn`; `closeSessionStream`/`destroy` detach each owned session (best-effort). | +| C5 | **P1** | `session/close` skipped local cleanup if `bridge.closeSession` threw. | `closeSessionStream` moved into a `finally`. | +| C6 | **P2** | Windows `cwd` (`C:\…`) rejected by `startsWith('/')`. | `path.isAbsolute` (platform-aware), matching REST. | +| C7 | **P2** | `protocolVersion` could negotiate `0`/negative. | Clamp `Math.max(1, Math.min(requested, 1))`; tests for 0/neg/huge/invalid. | +| C8 | **P2** | `session/load`/`resume` accepted empty `sessionId`. | Reject empty with `INVALID_PARAMS`. | +| C9 | **P2** | Notification-form `session/prompt` errors vanished silently. | Log on the no-id path. | +| C10 | **P2** | Session SSE flushed buffered frames before headers/`retry:`. | `open()` before `attachSessionStream`. | +| C11 | **P2** | Duplicate local `logStderr`. | Shared `writeStderrLine` from `utils/stdioHelpers`. | +| C12 | **P2** | Docs advertised `--no-acp-http` flag, `acp_http` capability tag, and `fs/*` forwarding not in v1. | Doc aligned to shipped surface (env-var toggle only; `fs/*`+`terminal/*` + flag + tag marked deferred). | + +Still deferred (unchanged): WebSocket + HTTP/2; per-connection secret for `DELETE`/ownership +(token + single-workspace remains the boundary); strict prompt-result ordering barrier; the +`as never` bridge-boundary casts (targeted, noted for an adapter-types follow-up). + +--- + +## 14. Review round 5 — PR fold-ins + +One more reviewer pass (qwen3.7-max). Suite **26 tests**, live re-verified. + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| D1 | **P0** | `resolveClientResponse` deleted the pending entry BEFORE calling `respondToSessionPermission`. A malformed vote (`result: {}`) makes the bridge mediator throw — and with the pending entry already gone, teardown's `abandonPendingForSession` can't cancel it, so the agent's prompt hangs on a vote that never resolves (a token-holder could stall a session with one bad POST). | Wrap the vote in try/catch; on any failure fall back to `cancelAbandonedPermission` so the mediator is always released. New test covers the malformed-vote path. | +| D2 | **P1** | Session-stream `onClose` aborted only the event pump, not `binding.promptAbort` — a client disconnect (tab close / network drop) left the in-flight prompt running (quota + FIFO) until idle TTL. | `onClose` now also aborts the session's `promptAbort`. | +| D3 | **P1** | When `pumpSessionEvents` rejected, the `.catch` only logged — the SSE stream stayed open heartbeating but delivering nothing (zombie, no reconnect signal). | `.catch` now also `closeSessionStream(sessionId)`. | + +--- + +## 15. Review round 6 — PR fold-ins + +Another reviewer pass (qwen3.7-max). Suite **28 tests**, live re-verified. + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| E1 | **P0** | `handlePrompt` overwrote `binding.promptAbort` without aborting the prior controller — two concurrent `session/prompt`s for one session orphaned the first (runs to completion in the bridge FIFO, unabortable by `session/cancel`). | Abort the prior `promptAbort` before installing the new one. Test added. | +| E2 | **P0** | The `subscribeEvents`-throws path sent a `stream_error` notify then `return`ed (resolved) — the caller's `.catch` never fired, leaving a zombie SSE stream (heartbeats, no events, no reconnect signal). | Re-throw after the notify so the caller's `.catch` closes the stream. Test asserts prompt closure. | +| E3 | **P1** | SSE heartbeat didn't mark the connection active — a long prompt with no intermediate events for >30 min got idle-reaped (streams + prompts killed). | `SseStream` takes an `onHeartbeat` hook; both GET handlers pass `() => conn.touch()`. | +| E4 | **P2** | `pumpSessionEvents` `.catch` closed by sessionId — a reconnect between the throw and the microtask could kill the NEW stream. | Identity-guard: only close if `binding.stream` is still this stream. | +| E6 | **P2** | `sendSession` auto-created a binding — a late pump/reply frame after `closeSessionStream` resurrected a ghost binding that buffered up to 256 frames forever. | `sendSession` is now lookup-only: drops frames when the session has no live binding. | +| E5 | accepted | `session/load`/`resume` don't reject when another live connection owns the session ("hijack"). | **Accepted, not changed:** the daemon's trust boundary is the bearer token + single-workspace bind, and multi-client attach is intentional (the bridge is multi-client by design; REST has the same property). A token-holder gains no capability they lack via REST. Tracked with the other token-boundary items (DELETE ownership, §13). | + +--- + +## 16. Review round 7 — PR fold-ins + +Another reviewer pass (qwen3.7-max). Suite **30 tests**, live re-verified. + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| F1 | **P0** | Concurrent `session/close` TOCTOU: `ownedSessions.delete` ran only in `finally` (after the await), so two concurrent closes both passed `requireOwned` → misleading error to the 2nd + redundant bridge close. | Delete the ownership gate SYNCHRONOUSLY before the await; bridge close runs once. Test added. | +| F2 | **P1** | Pump lifecycle: a CLEAN iterator end (subprocess ended, `done`) resolved → the `.catch` never fired → zombie stream; and a MID-STREAM iterator error sent no `stream_error`. | `pumpSessionEvents` wraps the whole loop (sync + mid-stream errors send `stream_error` then re-throw); the consumer `.then(onDone, onErr)` closes the stream on BOTH paths (identity-guarded). Tests added. | +| F3 | **P2** | 503 connection-cap rejection had no stderr log. | `writeStderrLine` with the cap value. | +| F4 | **P2** | `_qwen/notify stream_error` spread let `event.data.kind` shadow the discriminator. | Spread first, then `kind: 'stream_error'`. | +| F5 | **P2** | `MAX_WORKSPACE_PATH_LENGTH` redeclared (`= 4096`) vs the canonical `fs/paths.js`. | Import from `../fs/paths.js` (no divergence). | +| F6 | **P2** | `isObjectParams` duplicated `jsonRpc.isObject`. | Import `isObject`. | +| F7 | **P2** | Raw `process.stderr.write` in `index.ts`/`sseStream.ts` vs `writeStderrLine` elsewhere. | Unified on `writeStderrLine` across the module. | + +--- + +## 17. REST 等价对齐 + 扩展方案审计落地(round 8) + +目标:让 `/acp` 成为 REST+SSE 的**等价替代**。本批基于审计结论重构扩展方案,并补齐**所有 bridge 已暴露**的能力;bridge 尚未拥有的能力(文件 I/O、设备流、agents/memory CRUD)按架构正确性要求**先由 acp-bridge 补齐**(见 §17.3)。 + +### 17.1 扩展方案审计 → 落地(替换 §5 的旧方案) + +依据**仓库实装 SDK `@agentclientprotocol/sdk@0.14.1`**(非仅官网)核对: +- `session/set_config_option` 是**一等(非 `unstable_`)方法**,请求 `{sessionId, configId, value}`,`category` 含 `model`/`mode`/`thought_level`;而 `set_model` 仍走 `unstable_setSessionModel`。 +- 规范保留 `_` 前缀给扩展,示例为域风格 `_zed.dev/…`;厂商数据放 `_meta` 按域名分键。 + +落地: +- **命名空间 `_qwen/` → 反向域名 `_qwen/`**;`_meta` 统一 `_meta:{ "qwen": … }`(含 `initialize` 能力广告与 `session/request_permission` 的 requestId)。 +- **模型 + 审批模式 → 标准 `session/set_config_option`**(`configId:"model"|"mode"`),路由到现有 `bridge.setSessionModel`/`setSessionApprovalMode`;`session/new` 结果**广告 `configOptions`**(取自子进程会话状态 `getSessionContextStatus().state.configOptions`,已是 ACP 形状)。**删除**厂商 `_qwen/session/set_model`。 +- REST(http+sse) **无需同步修改**:两 transport 共用同一 bridge,状态天然一致。 + +### 17.2 本批新增的 `/acp` 方法(bridge 已支持,1:1 对齐 REST) + +| REST | `/acp` | bridge | +|---|---|---| +| `POST /session/:id/model` / `approval-mode` | **标准** `session/set_config_option`(model/mode) | setSessionModel / setSessionApprovalMode | +| `GET /session/:id/context` | `_qwen/session/context` | getSessionContextStatus | +| `GET /session/:id/supported-commands` | `_qwen/session/supported_commands` | getSessionSupportedCommandsStatus | +| `PATCH /session/:id/metadata` | `_qwen/session/update_metadata` | updateSessionMetadata | +| `GET /workspace/{mcp,skills,providers,env,preflight}` | `_qwen/workspace/{…}` | getWorkspace*Status | +| `POST /workspace/init` | `_qwen/workspace/init` | initWorkspace | +| `POST /workspace/tools/:name/enable` | `_qwen/workspace/set_tool_enabled` | setWorkspaceToolEnabled | +| `POST /workspace/mcp/:server/restart` | `_qwen/workspace/restart_mcp_server` | restartMcpServer | + +(既有:session/new·load·resume·close·list·prompt·cancel、heartbeat、permission、events 已对齐。) + +### 17.3 仍缺口 → 要求 acp-bridge 先补齐(架构正确性) + +REST 的 **文件 I/O**(`/file /glob /list /stat /file/write /file/edit`)、**设备流登录**(`/workspace/auth/*`)、**agents CRUD**(`/workspace/agents`)、**memory CRUD**(`/workspace/memory`)目前**不在 `HttpAcpBridge` 上**——REST 路由直接调 route 级服务(`WorkspaceFileSystemFactory`、`DeviceFlowRegistry`、`SubagentManager`、`writeWorkspaceContextFile`),绕过了 bridge。 + +**决策(采纳评审/owner 意见)**:不让 `/acp` transport 再去直连这些 route 级服务(那会复制 REST 的架构漂移、并使 transport 耦合翻倍)。**正确做法是先在 `@qwen-code/acp-bridge` 的 `HttpAcpBridge` 上补齐这些能力**(如 `readWorkspaceFile`/`writeWorkspaceFile`/`globWorkspace`、`startDeviceFlow`/`pollDeviceFlow`、`listAgents`/`upsertAgent`/`deleteAgent`、`readMemory`/`writeMemory`),让 REST 与 `/acp` 都经由 bridge。届时 `/acp` 再加 `_qwen/fs/*`、`_qwen/auth/*`、`_qwen/workspace/agent*`、`_qwen/workspace/memory*`(文件读因无标准 ACP client→agent 方法,属合法厂商扩展)。 + +**完整等价 = 本批(bridge 已有能力)+ acp-bridge 补齐缺口后的后续批**。 + +--- + +## 18. Review round 9 — PR fold-ins + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| G1 | **P1 (regression)** | Session-stream reconnect aborted the in-flight prompt: `attachSessionStream` closed the OLD stream before installing the new one, and the old stream's `onClose` unconditionally aborted `promptAbort` — so a reconnecting client (network glitch/roaming) lost its running prompt. | Install the new stream BEFORE closing the old; identity-guard `onClose`'s prompt-abort (only abort if THIS is still the session's live stream). Test added (prompt survives reconnect). | +| G2 | **P2** | `session/cancel` passed `undefined` as the `CancelNotification` body, dropping client-supplied cancel fields (reason/context) that REST forwards. | Forward `{ ...params, sessionId }` (mirrors REST). | + +Rebased onto latest `daemon_mode_b_main` (#4473/#4483/#4484/#4500), no conflicts. Suite **33 tests**, live re-verified. + +--- + +## 19. 路线图 / 后续 PR(防遗忘) + +本 PR(#4472)= ACP Streamable HTTP transport + **全部 bridge-backed 能力对齐** + 官方扩展方案。已转 **ready**。达到「`/acp` 完全等价 REST+SSE」尚需: + +1. **Follow-up PR 1 — acp-bridge 能力补齐(前置 / bridge-first)**:`HttpAcpBridge` 新增 文件 I/O、设备流、agents CRUD、memory CRUD 方法;REST 路由改走 bridge(消除直连 route 级服务的漂移)。 +2. **Follow-up PR 2 — `/acp` 剩余对齐(依赖 PR 1)**:`_qwen/fs/*`、`_qwen/auth/*`、`_qwen/workspace/agent*`、`_qwen/workspace/memory*` → 完全等价 REST。 + +跟踪:#3803(open decisions)、#4175(Mode B roadmap)均已 comment。 +Deferred 硬化项见 PR 描述「已知 deferred」。 + +--- + +## 20. Extension-namespace rename + SDK-transport analysis (round 11) + +- **Namespace `_qwen.ai/` → `_qwen/`**: ACP's only hard rule is the leading `_`; the `_zed.dev/` domain segment is convention-by-example, not a MUST. Since `qwen` is distinctive, we use the shorter bare form. `_meta` key likewise `"qwen"`. (Survey of real agents: Zed/gemini-cli mostly use `_meta`-on-standard-methods + ACP's own `unstable_*`; bare custom `_` methods are rare — our `_qwen/*` are genuinely-new workspace/session ops with no standard equivalent, so a `_` method is the right tool.) +- **Why hand-rolled transport (not SDK-based)**: the TS SDK ships only `ndJsonStream` (stdio); RFD #721 HTTP is SDK Phase-3 (not implemented). The SDK `Connection` is single-duplex-stream; our transport is multi-stream (POSTs + connection-SSE + per-session-SSE) and needs outbound demux by sessionId — which our dispatcher already knows at routing time. A full SDK rewrite fights that model and wouldn't remove the bulk (bridge translation, SSE lifecycle, ownership, EventBus→JSON-RPC). **Pragmatic improvement (candidate follow-up): adopt the SDK's Zod schema validators + types for param validation while keeping the hand-rolled transport.** SDK clients using `extMethod('_qwen/…')` interoperate with our handlers (identical wire shape). diff --git a/docs/design/f2-mcp-transport-pool.md b/docs/design/f2-mcp-transport-pool.md new file mode 100644 index 0000000000..4da25cf8a0 --- /dev/null +++ b/docs/design/f2-mcp-transport-pool.md @@ -0,0 +1,1457 @@ +# F2: Shared MCP Transport Pool — Design v2.2 + +> Targets `daemon_mode_b_main` (per #4175 branching strategy). Replaces #4175 Wave 5 PR 23. +> **Single-PR delivery** per maintainer's feature-cohesive batch guidance (2026-05-19). +> Author: doudouOUC. Date: 2026-05-20. Revised: 2026-05-20 (v2.2 — implementation review fold-ins). + +--- + +## 0. Changelog + +### v2.2 (2026-05-20) — PR #4336 implementation + 32 review fold-ins + +PR #4336 shipped F2 as 6 atomic commits + 6 fix commits over ~4 hours. Wenshao reviewed cumulatively in 3 batches; each batch produced inline + critical fixes that were folded back. The table below records what changed vs. v2.1, organized by review batch. + +#### v2.1 → first-review batch (commits 1-4, wenshao C1-C7 + S1-S4) + +| # | Site | What was wrong | Fold-in commit | +| --- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| C1 | `acpAgent.ts:269` — IDE-close path | Pool drain only ran in SIGTERM handler; IDE-initiated normal close leaked entries until OS reaped. Mirror SIGTERM's pool drain on `await connection.closed` | `ae0b296c4` | +| C2 | `mcp-pool-entry.ts:cancelDrainTimer` | `cancelDrainTimer` reset `maxIdleTimer` on every flap, defeating the §6.3 hard cap. Now only clears `drainTimer`; max-idle survives entire entry lifetime | `ae0b296c4` | +| C3 | `mcp-pool-entry.ts:doRestart` | Reconnect failure left entry in zombie state (`localStatus=CONNECTED`, `state='active'`, stale snapshot). Try/catch + transition to `'failed'` on failure | `ae0b296c4` | +| C4 | `mcp-pool-entry.ts:forceShutdown` | `state='closed'` set AFTER awaits, so concurrent `acquire` could observe `'active'` and hand out stale connection. Set synchronously at top | `ae0b296c4` | +| C5 | `mcp-transport-pool.ts:drainAll` | Concurrent `acquire` could spawn fresh entry mid-drain. Added `draining` mutex flag + `await Promise.allSettled(spawnInFlight)` before clearing | `ae0b296c4` | +| C6 | `mcp-pool-entry.ts:statusChangeListener` | Listener wasn't filtered by `serverName`; every entry got every server's status notifications + entry's own `markActive` write echoed back | `ae0b296c4` | +| C7 | `mcp-client-manager.ts:discoverAllMcpToolsIncremental` | Pool-mode gate added to `discoverAllMcpTools` but missed `Incremental` — `/mcp refresh` bypassed pool, spawned per-session client | `ae0b296c4` | +| S1 | `session-mcp-view.ts:passesSessionFilter` | Doc didn't call out that `excludeTools` uses direct equality (no parens-form support); divergence vs. `mcp-client.ts:isEnabled` | `ae0b296c4` | +| S2 | `pid-descendants.ts` docstring | Claimed Windows-specific `taskkill /F` branch that didn't exist — Node polyfills `process.kill('SIGTERM')` to `TerminateProcess` | `ae0b296c4` | +| S3 | `session-mcp-view.ts:applyTools` debug log | String contained literal `"N"` instead of interpolation — operators saw `applied 12 tools (filtered to N registered)` | `ae0b296c4` | +| S4 | `mcp-transport-pool.ts:createUnpooledConnection` status cb | Hardcoded to `() => CONNECTED` so `aggregateStatusByName` lied after disconnect. Now `() => client.getStatus()` | `ae0b296c4` | + +#### Commit-5 self-review batch (R1-R3 small) + +| # | Site | What was wrong | Fold-in commit | +| --- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| R1 | `server.test.ts:918` `/capabilities` envelope | Test asserted `getAdvertisedServeFeatures()` (no toggles) but server.ts passes `mcpPoolActive: opts.mcpPoolActive !== false` (default-on). Anchor toggle | `3e68c00bc` | +| R2 | `server.test.ts` capability default-on coverage | No test booted with default options to verify pool tags advertise. Added explicit `mcpPoolActive: false` test | `3e68c00bc` | +| R3 | `events.ts:DaemonMcpServerRestartRefusedData` | Doc said pre-PR SDKs would "see new value as unknown and surface generically" — actually `MCP_RESTART_REFUSED_REASONS.has(...)` rejects → silent drop | `3e68c00bc` | + +#### Second-review batch (commits 1-5, wenshao R1-R10) + +| # | Site | What was wrong | Fold-in commit | +| --- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| WR1 | `mcp-pool-entry.ts:maxIdleTimer` | C2 fix correctly preserved `maxIdleTimer` across flap, but fire-action force-closed regardless of `refs.size`. Active session with re-attach inside grace would lose tools after 5min | `72399f109` | +| WR2 | `mcp-client-manager.ts:discoverAllMcpToolsViaPool` | `releaseAllPooledConnections` + re-acquire ALL on every pass left brief window with zero MCP tools registered AND bounced every drain timer. Diff against desired `(name, fingerprint)` | `72399f109` | +| WR3 | `mcp-pool-entry.ts:doRestart` snapshot fan-out | Restart updated `toolsSnapshot`/`promptsSnapshot` and emitted typed events — but no `SessionMcpView` instance subscribed to that stream. Iterate `subscribers` directly post-snapshot | `72399f109` | +| WR4 | `mcp-transport-pool.ts:getSnapshot subprocessCount` | Counted websocket toward `subprocessCount` — websocket dials remote, no local child. Restricted to `'stdio'` only | `72399f109` | +| WR5 | `pid-descendants.ts` PowerShell `-Filter` | Interpolated `${pid}` directly into `-Filter` string. Entry-point `Number.isInteger` guard prevents injection today; bind to `$p` for defense-in-depth against future guard relaxations | `72399f109` | +| WR6 | `mcp-pool-entry.ts` ctor `cfg` field | `readonly cfg: MCPServerConfig` was implicitly public, exposing env API keys / header auth / OAuth fields. Made `private`; new `transportKind` getter for the only external reader | `72399f109` | +| WR7 | `mcp-pool-events.ts` premature exports | 5 PoolEvent type guards + `Prompt` re-export + `PoolEntryConnectionStatus` had zero callers. Removed; kept `MCPCallInterruptedError` (design §13.4 mandate) | `72399f109` | +| WR8 | `acpAgent.ts:269,300` pool drain duplication | SIGTERM + IDE-close had identical `if (agentInstance) { try { await shutdownMcpPool(8_000) } catch... }` blocks. Extracted `drainPoolBeforeExit(label)` helper | `72399f109` | + +#### Commit-6 self-review batch (R1-R3 critical race) + +| # | Site | What was wrong | Fold-in commit | +| --- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| 6R1 | `mcp-transport-pool.ts:onClosed` | Slot-release race: A finishes spawn, B (different fingerprint, same name) starts spawn, A drains. Close-cb checked only `entries` (B not yet registered) → premature release | `0e58a098f` | +| 6R2 | `events.ts:mcpBudgetWarningCount` JSDoc | Workspace-scoped events fan to N sessions → N reducer increments; consumers aggregating across sessions double-count. Docstring updated to call out the multiplier | `0e58a098f` | +| 6R3 | `acpAgent.ts:broadcastBudgetEvent` | Iterated `this.sessions.keys()` directly during async fan-out; concurrent `killSession` could corrupt iterator. Snapshot via `Array.from(...)` | `0e58a098f` | + +#### Third-review batch (commits 1-6, wenshao W1-W15) + +| # | Site | What was wrong | Fold-in commit | +| --- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| W1 | `mcp-transport-pool.ts:spawnEntry` catch | Spawn failure leaked `statusChangeListener` permanently — only `forceShutdown` removes it. Added `entry.forceShutdown('manual')` to catch | `4a3c5cd90` | +| W2 | `mcp-pool-entry.ts:statusChangeListener` cross-check | Module-level `serverStatuses` map shared across multi-fingerprint entries. A's transport error wrote DISCONNECTED, B's listener corrupted B's `localStatus`. Added `client.getStatus()` check | `4a3c5cd90` | +| W3 | `mcp-pool-entry.ts:doRestart` pid sweep | Restart skipped `listDescendantPids` + `sigtermPids` — every restart of `npx`/`uvx`-wrapped stdio orphaned the actual MCP grandchild. Added sweep before disconnect | `4a3c5cd90` | +| W4 | `mcp-pool-entry.ts:doRestart` drain timer race | Drain timer could fire mid-restart yield → `forceShutdown` removes entry → `client.connect` spawns orphan. Added `cancelDrainTimer` + `state→active` at top of `doRestart` | `4a3c5cd90` | +| W5 | `mcp-client-manager.ts:pooledConnections` dead handles | When entry transitioned to `'failed'`, manager held dead `PooledConnection` forever. Subscribe to entry events; evict on `'failed'` (idempotent via `get(name) === conn` guard) | `4a3c5cd90` | +| W6 | `mcp-client-manager.ts:discoverAllMcpToolsViaPool` re-entrancy | Two passes interleaving could both `set(name, conn)` → first conn leaked. Added `discoveryInFlight` mutex; second caller awaits same promise. New regression test | `4a3c5cd90` | +| W9 | `acpAgent.ts:parsePoolDrainMs` strictness | `Number.parseInt` accepted `'30000ms'` / `'30000abc'`. Strict `^\d+$` regex; reject with stderr warning + default fallback | `4a3c5cd90` | +| W10 | `mcp-transport-pool.ts:acquire` indexAttach order | `indexAttach` mutated `sessionToEntries` BEFORE `entry.attach()`. If `attach` threw, stale reverse-index mapping. Moved `indexAttach` after `attach` succeeds (both fast + in-flight paths) | `4a3c5cd90` | +| W13 | `mcp-transport-pool.ts:subprocessCount` JSDoc | Doc still claimed `stdio + websocket` after WR4 restricted to stdio. Updated | `4a3c5cd90` | +| W14 | `mcp-transport-pool.ts:createUnpooledConnection` catch | Same `statusChangeListener` leak as W1 in the unpooled path. Same mirror: `forceShutdown` before disconnect | `4a3c5cd90` | +| W15 | `bridge.ts:restartMcpServer` response | `as PoolEntries` cast was unsound — untyped JSON from ACP child. `Array.isArray` check + per-entry shape guard; malformed entries skipped with stderr breadcrumb | `4a3c5cd90` | + +#### Declined-with-reply (filed as F2 follow-ups) + +| # | Site | Reason for declining | +| --- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| W7 | Test coverage gaps (4 untested critical paths) | 1/4 added (W6 regression test); rest deferred to focused test-coverage PR after F2 series merges | +| W8 | `maxReconnectAttempts` / `reconnectStrategy` unused | Forward-compat placeholders for the deferred health-monitor-driven reconnect (design §6.6); removing + re-adding churns the public type | +| W11 | Duplicate fast-path / in-flight-path attach blocks | ✅ Done in PR A: `attachPooledSession` + `rollbackReservationOnSpawnFailure` private helpers (commit `2d546efca`) | +| W12 | `passesSessionFilter` O(M×N) per `applyTools` | ✅ Done in PR A: `applyTools` / `applyPrompts` precompute filter `Set`s once per pass; predicate becomes O(1) per tool (commit `a4a855ab3`) | +| R9 | `McpClientManager` ctor 7-positional sentinels | ✅ Done in PR A: options-object ctor + `mkManager` test factory (commit `0cb1eaa27`) | +| R10 | `pgrep -P ` per-PID-per-level cost | ✅ Done in PR A: single `ps -A -o pid=,ppid=` snapshot + in-memory BFS walk; pgrep BFS retained as fallback for BusyBox >` reverse index in pool (§6) | `releaseSession` O(N entries) → O(refs of session); needed for 1000-session scale | +| V21-3 | `?fingerprint=` query param on restart route (§13.1) | Operator may want to restart only one entry when same name has multiple fingerprints; near-zero cost to add now | +| V21-4 | Spawn-failure path explicitly releases reserved slot (§6.1, §6.5) | Otherwise slot leaks until next health-monitor pass; subtle real bug | +| V21-5 | New §13.4: in-flight tool call during reconnect semantics | `MCPCallInterruptedError`; pool does NOT auto-replay (writes unsafe) | +| V21-6 | New §10.4: `/mcp disable X` triggers `SessionMcpView` re-apply | Otherwise mid-session disable doesn't drop already-registered tools | +| V21-7 | Status route exposes `entryIndex` not raw fingerprint (§8.3) | Avoids side-channel exposure of OAuth token rotation via fingerprint change | +| V21-8 | Reconnect backoff spec'd: stdio fixed 5s × 3, HTTP/SSE exponential 1/2/4/8/16s × 5 (§6.6) | v2 didn't say; HTTP needs longer retry budget for network flap | +| V21-9 | `canonicalOAuth(o)` normalizes `{enabled: false}` ≡ `undefined` ≡ `null` (§5.1) | Otherwise functionally equivalent configs produce distinct entries | +| V21-10 | Renamed pool fallback helper from "legacy in-process acquire" to `createUnpooledConnection` (§5.3, §6.1) | SDK MCP bypass is permanent, not legacy | +| V21-11 | `drainAll(opts?)` returns `Promise` with `timeoutMs` wall-clock budget (§17) | Caller needs to know when drain finishes for shutdown ordering | +| V21-12 | Locked SDK reducer field names (Q1 resolved): keep `mcpBudgetWarningCount` etc. with scope semantics in JSDoc | No public-API rename mid-PR | +| V21-13 | Locked Q3 (default pool-on, `--no-mcp-pool` kill switch), Q4 (HTTP/SSE opt-in), Q6 (eager construction) | Single-PR delivery; no flag gating needed | +| V21-14 | Added R9/R10/R11 single-PR risks (§23) | Review fatigue, daemon_mode_b_main merge conflict, CI time | +| V21-15 | Extension uninstall orphan entry handling deferred to `MAX_IDLE_MS` natural reap (§16.3) | No explicit `invalidateByExtension`; keeps model uniform | + +### v2 (2026-05-20) — initial review fold-ins from v1 sketch + +| # | What | Why | +| --- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| C1 | Pool fans out **Tools + Prompts** (was: tools only) | `McpClient` ctor takes both registries; prompts otherwise silently lost in pool mode | +| C2 | New section on **global state coexistence** (`serverStatuses` / `mcpServerRequiresOAuth` module Maps) | Cross-session sharing already exists today; pool inherits + formalizes | +| C3 | `connectToMcpServer` factory path **unified** with `McpClient` class in F2-1 | v1 only refactored the class; would leave a parallel non-pooled path | +| C4 | Snapshot replay on attach (earlyEvents-style) added to `PoolEntry.attach()` | New race: session-B attaches → server emits `tools/list_changed` before subscription wired | +| C5 | `spawnInFlight: Map>` for concurrent-acquire dedupe | v1 mentioned in test matrix but missed in implementation contract | +| C6 | Cross-platform descendant-pid sweep (Linux/macOS pgrep, Windows wmic/PowerShell) | v1 said "copy opencode's `pgrep -P`" — that's Unix-only | +| C7 | `trust` field per-session **copy** of tool object | trust lives on `DiscoveredMCPTool`; shared instance would mix per-session trust | +| C8 | HTTP/SSE transports **opt-in** to pooling (default: stdio + websocket only) | Some MCP HTTP servers maintain per-transport session state; sharing risks state-bleed | +| C9 | SDK MCP server (`isSdkMcpServerConfig`) explicit bypass | `sendSdkMcpMessage` is per-session by design | +| C10 | OAuth path explicitly **deferred to F3** | OAuth flow needs PermissionMediator-style routing; not F2 scope | +| C11 | Restart route semantics spec'd (name → all matching entries) | PR 17's `POST /workspace/mcp/:server/restart` previously unambiguous (1 entry); now 1..N | +| C12 | Status route refactor section (new path: `QwenAgent.getMcpPoolAccounting()`) | `httpAcpBridge.ts:733-770` currently reads bootstrap session's manager — must change | +| C13 | Generation counter on `PoolEntry` for stale `tools/list_changed` handler guard | Opencode pattern: `if (s.clients[name] !== client) return` | +| C14 | Sub-PR breakdown 4 → **6** | v1 underestimated; A2/B1/B3/C6 each add real work | +| C15 | Lazy pool construction (only when N≥2 sessions seen) — optional | `qwen serve --foreground` single-session won't benefit; saves init cost | + +--- + +## 1. Goals / Non-goals + +**Goals** + +- N sessions in 1 workspace sharing 1 process per unique-server-config — fingerprint-keyed +- Per-session `ToolRegistry` / `PromptRegistry` views preserved (filtering, trust) +- Refcount + grace-drain lifecycle resilient to reattach +- Cross-platform descendant-pid cleanup +- Budget guardrails graduate from per-session to per-workspace (PR 14 promised this) +- Backward compat with non-daemon standalone qwen (pool not constructed there) + +**Non-goals (F2 scope)** + +- Cross-workspace pooling (1 daemon = 1 workspace invariant from PR #4113 stands) +- Cross-daemon pooling (out of scope — multi-process orchestrator territory) +- OAuth routing rework (F3 with `PermissionMediator`) +- Pool persistence across daemon restart (in-memory only) +- Auto-detection of "pool-safe" HTTP servers (opt-in flag only) +- Live `MCPServerConfig` diff to in-place mutate entries (config change → new entry, old drains) + +--- + +## 2. Current State (replacement target) + +``` +acpAgent.newSession(sessionId) + → newSessionConfig(cwd, mcpServers) // acpAgent.ts:1771 + → loadCliConfig → new Config → config.initialize() + → ToolRegistry ctor → new McpClientManager(config, ...) // tool-registry.ts:199 + → for (name, cfg) in config.getMcpServers(): + new McpClient(name, cfg, toolRegistry, promptRegistry, workspaceContext, ...) + → client.connect() → client.discover(config) +``` + +**Coupling map (what must be broken or threaded through):** + +| Coupling | Location | Action in F2 | +| -------------------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `McpClient` ctor binds 1 ToolRegistry + 1 PromptRegistry | mcp-client.ts:106-119 | Pool owns transport; `SessionMcpView` (per session) owns the per-session registries | +| `McpClient.discover()` calls `toolRegistry.registerTool()` inline | mcp-client.ts:178-198 | Split: `discoverAndReturn()` returns snapshot; view registers | +| `ListRootsRequestSchema` handler closes over `workspaceContext.getDirectories()` | mcp-client.ts:142-153 + connectToMcpServer.ts:893 | Pool's single workspace-bound context | +| `workspaceContext.onDirectoriesChanged` listener registered per connect | mcp-client.ts:907 | Pool registers once per entry | +| `McpClientManager` `new`'d inside ToolRegistry | tool-registry.ts:199 | Add optional `pool?` ctor param; injection from Config | +| Budget enforcement per-session | mcp-client-manager.ts:91-95 comment | Move state machine into pool | +| `serverDiscoveryPromises` dedupe in-flight per server | mcp-client-manager.ts:350 | Pool has `spawnInFlight: Map>` | +| `setMcpBudgetEventCallback` per-session registration | acpAgent.ts:1851-1899 | Pool emits → `QwenAgent` broadcasts to all sessions | + +**Already-shared state (pool inherits, does not introduce):** + +| State | Location | Note | +| ---------------------------------------------- | -------------------------------- | ----------------------------------------------------------------- | +| `serverStatuses: Map` | mcp-client.ts:292 (module-level) | Process-wide today; pool key still by name → "any-CONNECTED-wins" | +| `mcpServerRequiresOAuth: Map` | mcp-client.ts:302 (module-level) | Same | +| `MCPOAuthTokenStorage` on-disk tokens | `~/.qwen/mcp-oauth/.json` | Daemon-host shared; pool just exploits more efficiently | + +--- + +## 3. Reference Findings + +| Project | Pool? | Key | Lifecycle | Patterns to steal | +| --------------- | ------------------ | --------------------------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **claude-code** | No, per-process | `name + JSON.stringify(cfg)` (lodash.memoize) | `clearServerCache` + remote backoff×5; stdio crash → `failed` | Sorted-key SHA-256 `hashMcpConfig` for invalidation/keying | +| **opencode** | Yes, per workspace | server **name only** (no config hash) | No refcount / no eviction / no restart; Effect finalizer + `pgrep -P` recursive SIGTERM | Descendant-pid sweep, stale-handler guard (`if (s.clients[name] !== client) return`), `tools/list_changed` fan-out via event bus | + +**What F2 inherits from each:** config-hash from claude-code (handles per-session env/auth divergence opencode doesn't), descendant-pid sweep from opencode (npx/uvx wrappers leak). What we add: refcount + drain (multi-client daemon), auto-restart (long-running daemon), prompt fan-out, generation guard. + +--- + +## 4. Architecture + +### 4.1 Process layout + +``` +HTTP daemon (packages/cli/src/serve, qwen serve) + │ spawns + ▼ +ACP child (qwen --acp, single process per workspace) + │ + QwenAgent (acpAgent.ts) + ├── McpTransportPool ◄── new, workspace-scoped, 1 instance + │ ├── entries: Map + │ ├── spawnInFlight: Map> + │ ├── workspaceContext (bound to daemon workspace) + │ └── budget guardrails (PR 14 state machine, graduated to workspace) + │ + └── sessions: Map + └── Session.Config → ToolRegistry → McpClientManager(pool?) + │ + ┌────────┴────────┐ + │ pool injected │ + ▼ ▼ + pool.acquire(name,cfg,sid) legacy in-process + → SessionMcpView (standalone qwen) + .applyTools/Prompts + (filter + register into + session's own registries) +``` + +**Pool lives in the ACP child**, not the HTTP daemon. The HTTP daemon queries pool state via the existing `bridge.client` extMethod surface (`getMcpPoolAccounting`, `restartMcpServer`). F2 code lives in **`packages/core/src/tools/`** (peer of `mcp-client-manager.ts`), not `packages/acp-bridge/`. + +### 4.2 Class diagram + +``` +McpTransportPool + ├─ acquire(name, cfg, sid) → PooledConnection + ├─ release(connectionId, sid) → void + ├─ releaseSession(sid) → void (bulk release for session teardown) + ├─ restartByName(name) → RestartResult[] + ├─ getAccounting() → McpClientAccounting (workspace-scope) + ├─ getBudgetMode/Budget() + ├─ drainAll() → Promise (shutdown) + └─ onBudgetEvent: (event) => void (set by QwenAgent) + +PoolEntry (internal) + ├─ refs: Set + ├─ client: McpClient + ├─ toolsSnapshot: DiscoveredMCPTool[] + ├─ promptsSnapshot: Prompt[] + ├─ generation: number (++ on reconnect; stale-event guard) + ├─ state: 'spawning' | 'active' | 'draining' | 'closed' | 'failed' + ├─ drainTimer?: NodeJS.Timeout + ├─ healthMonitor: { intervalTimer, consecutiveFailures, isReconnecting } + ├─ subscribers: Map + ├─ attach(sid, view) → PooledConnection + └─ detach(sid) → void + +PooledConnection (handle returned to caller) + ├─ id: ConnectionId + ├─ on('toolsChanged' | 'promptsChanged' | 'disconnected' | 'reconnected' | 'failed', cb) + ├─ callTool(name, args, { sessionId }) → CallToolResult + ├─ readResource(uri, { sessionId, signal }) + └─ release() + +SessionMcpView (per session, per server) + ├─ ctor(toolRegistry, promptRegistry, sessionId, serverName, cfg) + ├─ applyTools(snapshot) → void (filters by include/exclude, decorates trust) + ├─ applyPrompts(snapshot) → void + └─ teardown() → void (removes its registrations) +``` + +--- + +## 5. Pool Key (Fingerprint) + +### 5.1 Hashed canonical fields + +```ts +type PoolKey = string; // sha256 hex, first 16 chars sufficient (collision-free for realistic N) +type ConnectionId = `${serverName}::${PoolKey}`; + +function fingerprint(cfg: MCPServerConfig): PoolKey { + const canonical = { + transport: mcpTransportOf(cfg), + command: cfg.command ?? null, + args: cfg.args ?? [], + cwd: cfg.cwd ?? null, + env: sortedEntries(cfg.env ?? {}), // [[k,v],...] sorted by k + url: cfg.url ?? null, + httpUrl: cfg.httpUrl ?? null, + headers: sortedEntries(cfg.headers ?? {}), + timeout: cfg.timeout ?? null, + oauth: canonicalOAuth(cfg.oauth), + }; + return sha256(JSON.stringify(canonical)).slice(0, 16); +} + +/** + * V21-9: normalize functionally-equivalent OAuth configs so they + * collapse to the same fingerprint. `{enabled: false}`, `undefined`, + * `null`, and `{}` all mean "no OAuth" → all return `null`. + */ +function canonicalOAuth(o?: OAuthConfig | null): OAuthConfig | null { + if (!o || !o.enabled) return null; + return { + enabled: true, + clientId: o.clientId ?? null, + scopes: o.scopes ? [...o.scopes].sort() : null, + authorizationUrl: o.authorizationUrl ?? null, + tokenUrl: o.tokenUrl ?? null, + }; +} + +// Excluded fields (per-session filters, NOT transport-level): +// includeTools, excludeTools, trust, description, extensionName +``` + +### 5.2 Transport-class gating + +```ts +const POOLED_TRANSPORTS_DEFAULT = new Set(['stdio', 'websocket']); + +function isPoolable(cfg: MCPServerConfig, opts: PoolOptions): boolean { + if (isSdkMcpServerConfig(cfg)) return false; + const transport = mcpTransportOf(cfg); + return opts.pooledTransports.has(transport); +} +``` + +**Default `pooledTransports = {stdio, websocket}`**. Operators opt HTTP/SSE in via: + +- CLI: `--mcp-pool-transports=stdio,websocket,http,sse` +- Env: `QWEN_SERVE_MCP_POOL_TRANSPORTS=stdio,websocket,http` + +**Why default exclude HTTP/SSE**: some MCP HTTP server implementations bind state (auth context, conversation memory) to the TCP/SSE stream; multiple ACP sessions sharing it would bleed state. stdio + websocket are true OS processes whose state is observable and isolatable. + +### 5.3 SDK MCP bypass + +`isSdkMcpServerConfig(cfg)` true → pool returns a thin `PooledConnection` wrapper via `createUnpooledConnection(name, cfg, sid)` that constructs an `McpClient` immediately, no sharing, no entry stored in pool. Reason: `sendSdkMcpMessage` is per-session by design (routes through ACP control plane back to the originating session). Same path used for HTTP/SSE when transport not in `pooledTransports` (§10.3). + +V21-10: name is `createUnpooledConnection`, not `legacyInProcessAcquire` — SDK MCP and HTTP-opt-out are permanent design choices, not legacy code. + +--- + +## 6. Lifecycle + +### 6.1 acquire / release + +```ts +class McpTransportPool { + private entries = new Map(); + private spawnInFlight = new Map>(); + + /** V21-2: reverse index, O(refs) releaseSession instead of O(entries). */ + private sessionToEntries = new Map>(); + + async acquire( + name: string, + cfg: MCPServerConfig, + sid: string, + ): Promise { + if (!isPoolable(cfg, this.opts)) { + return this.createUnpooledConnection(name, cfg, sid); + } + const id: ConnectionId = `${name}::${fingerprint(cfg)}`; + + if (this.entries.has(id)) { + this.indexAttach(sid, id); + return this.entries.get(id)!.attach(sid); + } + let inFlight = this.spawnInFlight.get(id); + if (!inFlight) { + const slot = this.tryReserveSlot(name); + if (slot === 'refused') { + throw new BudgetExhaustedError( + name, + this.clientBudget!, + this.reservedSlots.size, + ); + } + inFlight = this.spawnEntry(name, cfg, id) + .catch((err) => { + // V21-4: release reserved slot on spawn failure. Without + // this, slot leaks until health monitor's release path + // runs (which it doesn't, because there's no entry to monitor). + if (slot === 'reserved') this.releaseSlotName(name); + throw err; + }) + .finally(() => this.spawnInFlight.delete(id)); + this.spawnInFlight.set(id, inFlight); + } + const entry = await inFlight; + this.indexAttach(sid, id); + return entry.attach(sid); + } + + release(id: ConnectionId, sid: string): void { + const entry = this.entries.get(id); + if (!entry) return; + entry.detach(sid); + this.indexDetach(sid, id); + if (entry.refs.size === 0) entry.startDrainTimer(this.opts.drainDelayMs); + } + + /** V21-2: O(refs of this session), not O(all entries). */ + releaseSession(sid: string): void { + const ids = this.sessionToEntries.get(sid); + if (!ids) return; + for (const id of ids) { + const entry = this.entries.get(id); + if (!entry) continue; + entry.detach(sid); + if (entry.refs.size === 0) entry.startDrainTimer(this.opts.drainDelayMs); + } + this.sessionToEntries.delete(sid); + } + + private indexAttach(sid: string, id: ConnectionId): void { + let ids = this.sessionToEntries.get(sid); + if (!ids) { + ids = new Set(); + this.sessionToEntries.set(sid, ids); + } + ids.add(id); + } + + private indexDetach(sid: string, id: ConnectionId): void { + const ids = this.sessionToEntries.get(sid); + if (!ids) return; + ids.delete(id); + if (ids.size === 0) this.sessionToEntries.delete(sid); + } +} +``` + +### 6.2 Concurrent-acquire dedupe (`spawnInFlight`) + +Mirrors `McpClientManager.serverDiscoveryPromises` (mcp-client-manager.ts:350). Without it, 5 sessions spawning at boot all see `entries.has(id) === false` and race to spawn 5 child processes. + +### 6.3 Drain grace + idle cap + +```ts +const DRAIN_DELAY_MS_DEFAULT = 30_000; // grace after last release +const MAX_IDLE_MS_DEFAULT = 5 * 60_000; // hard cap (defense against drain cancellation loop) +``` + +State machine in `PoolEntry`: + +``` +spawning ──spawn ok──► active ──last detach──► draining ──timeout──► closed + │ │ │ + │ │ └──attach──► active (cancel timer) + spawn fail───────────►failed + │ + └──manual restart──► spawning +``` + +Hard idle cap: drain timer can be cancelled+restarted indefinitely (acquire/release flap). `MAX_IDLE_MS` is a separate timer started **at first idle** and never reset; when it fires, force-close even if drain is currently in active grace. Prevents zombie pool entries from buggy clients that thrash acquire/release. + +### 6.4 Cross-platform descendant-pid sweep + +**R10 / R23 T7 / PR A update (2026-05-22)**: switched from per-pid BFS (one `pgrep -P ` / `Get-CimInstance -Filter` subprocess per node) to a single process-table snapshot followed by in-memory tree walk. Two motivations: (1) one fork instead of B^D forks on the hot pool-shutdown path; (2) snapshot consistency — pre-fix BFS could miss descendants that forked between adjacent BFS levels. Per-pid path retained as fallback for BusyBox `ps` { + if (!Number.isInteger(rootPid) || rootPid <= 0) return []; + try { + if (process.platform === 'win32') + return await listDescendantPidsWin(rootPid); + return await listDescendantPidsUnix(rootPid); + } catch { + return []; // OS reaps orphans; pool shutdown still proceeds. + } +} + +async function listDescendantPidsUnix(root: number): Promise { + let tree: Map | undefined; + try { + tree = await snapshotProcessTreeUnix(); // ps -A -o pid=,ppid= + } catch { + /* fall through to fallback */ + } + if (tree) return walkDescendants(tree, root); // O(descendants), 1 fork + return await listDescendantPidsUnixPgrepFallback(root); // legacy BFS +} + +async function snapshotProcessTreeUnix(): Promise> { + // -A: all processes (POSIX, equivalent to -e but unambiguous on BSD). + // -o pid=,ppid=: pid + ppid columns, trailing `=` suppresses headers. + const { stdout } = await execFile('ps', ['-A', '-o', 'pid=,ppid='], { + timeout: 2000, + maxBuffer: 8 * 1024 * 1024, // covers >250k-process pathological hosts + }); + const childrenByPpid = new Map(); + for (const line of stdout.split('\n')) { + const m = line.trim().match(/^(\d+)\s+(\d+)$/); + if (!m) continue; + /* parse, push into childrenByPpid */ + } + return childrenByPpid; +} + +// Windows: single Get-CimInstance Win32_Process | ConvertTo-Csv snapshot +// of all (ProcessId, ParentProcessId) rows + in-memory walk; per-pid +// `Get-CimInstance -Filter "ParentProcessId=$p"` retained as fallback. +``` + +Called from `PoolEntry.shutdown()` before `client.disconnect()`. Handles `npx @modelcontextprotocol/server-X`, `uvx ...`, `pnpm dlx ...` wrapper leaks. MAX_DESCENDANTS=256 / MAX_DEPTH=8 caps preserved. + +### 6.5 Spawn failure handling + +If `spawnEntry` rejects after multiple subscribers attached (via `spawnInFlight`): + +- All awaiters get the rejection +- `tryReserveSlot` released **via explicit `.catch` arm in `acquire`** (V21-4); without this fix the slot leaked until next health-monitor pass, which never ran because no entry existed to monitor. +- Failed entry NOT stored in `entries` +- Subscribers' code paths handle as if `acquire` originally failed (existing per-session `discoverMcpToolsForServer` catch logic remains valid) + +### 6.6 Reconnect backoff (V21-8) + +When a `PoolEntry` enters reconnect after transport drop: + +| Transport family | Strategy | Cap | +| ---------------- | -------------------------------------------- | ---------------------------------------------------------------- | +| stdio | Fixed 5s × 3 attempts | Per existing `DEFAULT_HEALTH_CONFIG.reconnectDelayMs` | +| websocket | Fixed 5s × 3 attempts | Same as stdio | +| http (opt-in) | Exponential 1s, 2s, 4s, 8s, 16s × 5 attempts | Remote endpoints flap on transient network issues; longer budget | +| sse (opt-in) | Exponential 1s, 2s, 4s, 8s, 16s × 5 attempts | Same as http | + +After cap exhaustion: entry transitions to `failed` state; subscribers receive `failed` event; new `acquire` for same `ConnectionId` retries spawn once, then throws. Operator restart (§13) resets state. + +--- + +## 7. Discovery / SessionMcpView + +### 7.1 Tools + Prompts dual fan-out + +```ts +// packages/core/src/tools/mcp-client.ts — split discover into pure +async discoverAndReturn(cliConfig: Config): Promise<{ + tools: DiscoveredMCPTool[]; + prompts: Prompt[]; +}> { + if (this.status !== MCPServerStatus.CONNECTED) throw new Error('Client is not connected.'); + try { + const [prompts, tools] = await Promise.all([ + discoverPrompts(this.serverName, this.client, /* no registry */), + discoverTools(this.client, this.serverConfig, this.serverName, this.debugMode, this.workspaceContext), + ]); + if (prompts.length === 0 && tools.length === 0) { + throw new Error('No prompts or tools found on the server.'); + } + return { tools, prompts }; + } catch (e) { + this.updateStatus(MCPServerStatus.DISCONNECTED); + throw e; + } +} + +// Legacy discover() retained, delegates to discoverAndReturn + registers (for standalone qwen) +async discover(cliConfig: Config): Promise { + const { tools, prompts } = await this.discoverAndReturn(cliConfig); + for (const t of tools) this.toolRegistry.registerTool(t); + for (const p of prompts) this.promptRegistry.registerPrompt(p); +} +``` + +```ts +class SessionMcpView { + applyTools(snapshot: DiscoveredMCPTool[]) { + this.sessionToolRegistry.removeToolsByServer(this.serverName); + for (const tool of snapshot) { + if (!this.passesFilter(tool)) continue; + // C7: per-session copy of trust (don't mutate shared snapshot) + const localTool = tool.withTrust(this.cfg.trust); + this.sessionToolRegistry.registerTool(localTool); + } + } + applyPrompts(snapshot: Prompt[]) { + this.sessionPromptRegistry.removePromptsByServer(this.serverName); + for (const p of snapshot) this.sessionPromptRegistry.registerPrompt(p); + } +} +``` + +### 7.2 Snapshot replay on attach (earlyEvents-style) + +```ts +class PoolEntry { + attach(sid: string): PooledConnection { + this.refs.add(sid); + this.cancelDrainTimer(); + const view = new SessionMcpView(...); + this.subscribers.set(sid, view); + // Immediately replay current snapshot so subscriber doesn't miss + // updates that landed between in-flight discover completion and + // attach. + if (this.state === 'active') { + view.applyTools(this.toolsSnapshot); + view.applyPrompts(this.promptsSnapshot); + } + return this.makeHandle(sid, view); + } +} +``` + +Mirrors PR 14b fix #1's `BridgeClient.earlyEvents` pattern — solves analogous race for pool attachment. + +### 7.3 Stale-handler guard (generation counter) + +```ts +class PoolEntry { + private generation = 0; + + private async reconnect(): Promise { + this.generation += 1; + const myGen = this.generation; + await this.client.disconnect(); + await this.client.connect(); + if (myGen !== this.generation) return; // superseded by another reconnect + const snap = await this.client.discoverAndReturn(this.cfg); + if (myGen !== this.generation) return; + this.toolsSnapshot = snap.tools; + this.promptsSnapshot = snap.prompts; + this.fanOut('toolsChanged'); + this.fanOut('promptsChanged'); + } + + private onServerToolsListChanged = () => { + const myGen = this.generation; + this.client + .discoverAndReturn(this.cfg) + .then((snap) => { + if (myGen !== this.generation) return; + this.toolsSnapshot = snap.tools; + this.fanOut('toolsChanged'); + }) + .catch(/* swallow + log */); + }; +} +``` + +Without this, a stale handler from a pre-reconnect Client instance could overwrite the post-reconnect snapshot with stale data. + +**Monotonicity invariant** (V21 clarification): `generation` only increments, never resets. Any in-flight operation captures `myGen` at entry, then post-`await` checks `myGen === this.generation`. Equivalent to "no superseding event has happened since I started". Bounded by Number.MAX_SAFE_INTEGER (~285k years at 1Hz reconnect), no overflow concern. + +### 7.4 Path unification (F2-1 scope expansion) + +`packages/core/src/tools/mcp-client.ts` has TWO connect-to-server paths: + +1. `McpClient` class (mcp-client.ts:100) — used by `McpClientManager` +2. `connectToMcpServer` factory function (mcp-client.ts:875) — used by `discoverMcpTools` (line 560) and `connectAndDiscover` (line 607) + +F2-1 must converge both behind `McpClient.discoverAndReturn` (with `connectToMcpServer` becoming a private helper of `McpClient` or both calling a shared `establishConnection()` primitive). Otherwise pool only covers the class path; the factory path remains per-session and undermines the whole effort. + +--- + +## 8. Global State Coexistence + +### 8.1 `serverStatuses` (mcp-client.ts:292) — collision-tolerant write + +Module-level `Map`. Pool's `ConnectionId` is `name::hash`, but `updateMCPServerStatus(name, status)` writes by name. **Multiple pool entries for same name (different fingerprints, e.g. token-divergence) would clobber each other's status.** + +**Resolution**: pool intercepts status writes: + +```ts +class PoolEntry { + updateStatus(s: MCPServerStatus) { + this.localStatus = s; + const aggregated = this.pool.aggregateStatusByName(this.serverName); + updateMCPServerStatus(this.serverName, aggregated); + } +} + +class McpTransportPool { + aggregateStatusByName(name: string): MCPServerStatus { + // Any CONNECTED ⇒ CONNECTED + // Else any CONNECTING ⇒ CONNECTING + // Else DISCONNECTED + const entries = [...this.entries.values()].filter( + (e) => e.serverName === name, + ); + if (entries.some((e) => e.localStatus === CONNECTED)) return CONNECTED; + if (entries.some((e) => e.localStatus === CONNECTING)) return CONNECTING; + return DISCONNECTED; + } +} +``` + +Status route surfaces `entryCount: number` so operators see when name → multiple entries. + +### 8.2 OAuth token storage + +`MCPOAuthTokenStorage` writes to `~/.qwen/mcp-oauth/.json` — already daemon-host-shared. Pool benefits incidentally (first session's OAuth completes → token on disk → pool entry's reconnect picks up token → all other sessions piggy-back). + +**Caveat — multi-fingerprint case**: 2 entries for same name (different headers/env) but same OAuth provider → both read the same token file. If tokens are server-scoped (OAuth typical), this works. If tokens are env-scoped (rare), explicit storage key extension needed. **Punt to F3** with a documented known-limitation. + +### 8.3 `entryCount` in snapshot + +`GET /workspace/mcp` per-server cell adds: + +```ts +{ + kind: 'mcp_server', + name: 'github', + status: 'ok', + mcpStatus: 'connected', + entryCount: 2, // NEW — N pool entries for this name + entrySummary?: [ // NEW — opaque per-entry breakdown + { entryIndex: 0, refs: 2, status: 'connected' }, + { entryIndex: 1, refs: 1, status: 'connecting' }, + ], + ... +} +``` + +**V21-7**: `entrySummary[].entryIndex` is a **stable opaque integer** assigned at entry creation (insertion order within name group), NOT the raw fingerprint. Reasoning: fingerprint changes when OAuth tokens or env vars rotate, which would leak that information through snapshot diffs (operator could infer "token rotated at T+5min" from `'a3b1' → 'f972'` transition). `entryIndex` is monotonic within name group but stays stable across rotations because old entry drains and new entry gets next index. + +Old SDK clients ignore unknown fields per PR 14 contract; new clients use `entryCount` for badges. Internal restart-by-fingerprint path uses an opaque token returned only via privileged extMethod, not exposed in HTTP snapshot. + +--- + +## 9. WorkspaceContext / ListRoots + +### 9.1 Single registration + +Pool's `McpClient` instances share **one** `WorkspaceContext` — the daemon's bound workspace context (PR #4113 invariant). `connectToMcpServer`'s `ListRootsRequestSchema` handler closes over this single context. + +`onDirectoriesChanged` listener registered **once per entry**, not once per `acquire`. Detached on entry shutdown. + +### 9.2 `roots/list_changed` fan-up + +Server notifies client of new roots → pool fans out: + +- Pool re-discovers (server may report different tool set under new roots) → `toolsChanged` event → all subscriber views re-apply + +### 9.3 Per-session `updateWorkspaceDirectories` + +**Contract**: in Mode B, per-session directory additions are a soft hint, not authoritative. Pool's `WorkspaceContext` is daemon-level. + +Two implementations choices: + +- **v1 simple**: ignore per-session adds, log warning when detected +- **v2 union**: pool maintains `extraRoots: Map>`, ListRoots handler returns union of bound workspace + all extras. Per-session removal triggers `roots/list_changed`. Adds 50-80 LOC complexity. + +**Pick v1 simple for F2**; v2 union as follow-up if user pain materializes. + +--- + +## 10. Per-session Injection + +### 10.1 `mcpServers` from `newSession({mcpServers})` + +`newSessionConfig(cwd, mcpServers, ...)` merges injected list with `settings.merged.mcpServers` (acpAgent.ts:1778-1831). Pool consumes the **per-session merged view**: + +```ts +async newSessionConfig(...) { + const config = await loadCliConfig(...); + if (this.mcpPool) config.setMcpTransportPool(this.mcpPool); + // ...existing setMcpBudgetEventCallback REMOVED — pool handles broadcast directly +} +``` + +When two sessions inject same-name server with different env/headers → different fingerprints → two pool entries. Pool sharing kicks in only when sessions agree exactly. + +### 10.2 Auth divergence + +Static `~/.qwen/settings.json` mcpServers are identical across sessions → all share → 80% case. Per-session injected mcpServers with per-user tokens → unique fingerprints → no sharing. Both safe. + +### 10.3 HTTP transport opt-in (recap from §5.2) + +Default `pooledTransports = {stdio, websocket}`. HTTP/SSE servers go through `createUnpooledConnection` path (one McpClient per session) unless operator opts in. + +### 10.4 `/mcp disable X` mid-session (V21-6) + +When operator runs `/mcp disable github` against a live session: + +1. `Config.disableMcpServer('github')` adds to per-Config `disabledMcpServers` set +2. **F2 hook**: `Config.onDisabledMcpServersChanged` fires; `SessionMcpView` for that name calls `teardown()` (removes its tool/prompt registrations from session registries) +3. Pool entry **may stay alive** if other sessions still reference it (refcount > 0) — only the disabling session's view detaches +4. If all sessions disable → refcount → 0 → drain timer starts + +Without step 2, mid-session disable would leave already-registered tools in the session's `ToolRegistry` until next session restart. Test 21.4 covers this. + +`/mcp enable github` is the inverse: triggers fresh `pool.acquire` for the session, attaches new view, re-applies snapshot. + +--- + +## 11. Budget Guardrails Graduation + +### 11.1 State machine moves to pool + +`tryReserveSlot` / `releaseSlotName` / 75% hysteresis / refused_batch coalescing / `bulkPassDepth` / `pendingRefusalNames` — all migrate from `McpClientManager` to `McpTransportPool`. `McpClientManager` retains the state only when running standalone (no pool injected). + +### 11.2 Snapshot cell scope + +```ts +{ + kind: 'mcp_budget', + scope: 'workspace', // NEW value (PR 14 v1 returned 'session') + liveCount: 5, + clientBudget: 10, + budgetMode: 'enforce', + status: 'ok', +} +``` + +Per PR 14 contract: "Consumers MUST tolerate additional entries with unrecognized scope values (drop, don't fail)." Old SDK clients see `scope: 'workspace'`, render as unknown (or fallback to top-level numbers). New SDK adds `isWorkspaceScopedBudget(cell)` helper. + +### 11.3 Event fan-out + +```ts +class QwenAgent { + constructor() { + this.mcpPool = new McpTransportPool({ + onBudgetEvent: (event) => this.broadcastBudgetEvent(event), + }); + } + + private broadcastBudgetEvent(event: McpBudgetEvent) { + for (const [sid, session] of this.sessions) { + const enriched = { + ...event, + scope: 'workspace' as const, + sessionId: sid, + }; + session.connection + .extNotification('qwen/notify/session/mcp-budget-event', enriched) + .catch((err) => + debugLogger.debug('budget event delivery failed', { sid, err }), + ); + } + } +} +``` + +### 11.4 SDK type contract changes + +PR 14b exported these (must extend additively): + +- `DaemonMcpBudgetWarningData` — add `scope?: 'workspace' | 'session'` (optional for backward compat; absent = 'session') +- `DaemonMcpChildRefusedBatchData` — same `scope?` extension +- `DaemonMcpGuardrailEvent` — discriminator unchanged + +New SDK helpers: + +```ts +export function isWorkspaceScopedBudgetEvent( + e: DaemonMcpGuardrailEvent, +): boolean; +``` + +Reducer state on `DaemonSessionViewState`: + +- **No new fields** — `mcpBudgetWarningCount` / `mcpChildRefusedBatchCount` increment regardless of scope (scope is a property of each event, not a separate stream) +- Document that under F2 these counts reflect workspace-level events fanned to every session — they will increment **simultaneously across all attached sessions** when budget pressure occurs + +**V21-12 (Q1 resolved, locked in v2.1)**: keep existing field names (`mcpBudgetWarningCount`, `mcpChildRefusedBatchCount`, `lastMcpBudgetWarning`, `lastMcpChildRefusedBatch`) with extended scope semantics documented in JSDoc: + +```ts +/** + * Count of `mcp_budget_warning` events the session has observed. + * Under F2 (`scope: 'workspace'`), this increments simultaneously + * across all attached sessions because budget events fan out at + * workspace level. Use `isWorkspaceScopedBudgetEvent(lastMcpBudgetWarning)` + * to inspect scope of the most recent event. + */ +mcpBudgetWarningCount: number; +``` + +Rationale: PR 14b already shipped these names as public SDK surface; renaming is a breaking change worse than the slightly imprecise semantics. + +--- + +## 12. OAuth — Explicit F3 Deferral + +OAuth 401 fallback in `connectToMcpServer` (mcp-client.ts:950-1010) needs interactive resolution (browser open or device-flow). Mode B daemon **must not spawn a browser** (per PR 21 design — static-source grep test fails build on `open`/`xdg-open`/`shell.openExternal`). + +**F2 behavior on OAuth-requiring server**: + +1. First acquire triggers `connectToMcpServer` → 401 detected +2. Pool catches OAuth-required exception, marks entry as `failed_auth_required` +3. Status route surfaces `errorKind: 'auth_env_error'` (existing PR 13 errorKind) +4. Pool **does not retry automatically** +5. Operator runs `/mcp auth ` (existing CLI) OR uses PR 21's device-flow route to get a token on disk → next session acquire re-attempts and succeeds + +**F3 will replace step 4-5** with `PermissionMediator` routing OAuth completion request to attached sessions for first-responder. + +This avoids F2 mixing into auth state-machine work. + +--- + +## 13. Restart Route Semantics + +### 13.1 `POST /workspace/mcp/:server/restart` under pool + +Today (PR 17): restart in bootstrap session's manager = restart the single entry for that name. + +Under pool: name → possibly multiple entries (different fingerprints for same name = different sessions with different configs). + +**Spec'd behavior**: + +| Request | Behavior | +| -------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `POST /workspace/mcp/:server/restart` | Restart **all** entries matching `serverName` (parallel via `Promise.allSettled`) | +| `POST /workspace/mcp/:server/restart?entryIndex=0` | V21-3: restart only entry #0 (the opaque index from snapshot §8.3); 404 if not found | +| `POST /workspace/mcp/:server/restart?entryIndex=*` | Explicit "all" (same as no param) | + +Response shape: + +```ts +type RestartResult = { + entryIndex: number; // V21-7: opaque index, not raw fingerprint + restarted: boolean; + durationMs?: number; + reason?: string; // 'budget_would_exceed' | 'not_connected' | 'in_flight' +}; +POST /workspace/mcp/:server/restart → { entries: RestartResult[] } +``` + +Old shape `{restarted: true, durationMs}` retained when `entries.length === 1` AND no `entryIndex` query param for backward compat; clients can detect new shape by checking `'entries' in response`. + +### 13.2 In-flight restart dedupe + +```ts +class PoolEntry { + private restartInFlight?: Promise; + async restart(): Promise { + if (this.restartInFlight) return this.restartInFlight; + this.restartInFlight = this.doRestart().finally(() => { + this.restartInFlight = undefined; + }); + return this.restartInFlight; + } +} +``` + +### 13.3 Budget check (preserves PR 17 behavior) + +Pre-restart, pool checks budget: if disconnect+reconnect would still fit, OK. The current PR 17 `{restarted:false, skipped:true, reason:'budget_would_exceed'}` semantic preserved (just now applied per-entry). + +### 13.4 In-flight tool call during reconnect (V21-5, new) + +Session A invokes `pool.callTool('git.commit', args)` → request hits stdin of underlying child → child process crashes mid-write → entry transitions to reconnect: + +```ts +class MCPCallInterruptedError extends Error { + readonly serverName: string; + readonly entryIndex: number; + readonly clientGeneration: number; // pre-reconnect generation + readonly args: unknown; // original args, for caller to retry if safe + constructor(serverName, entryIndex, clientGeneration, args) { ... } +} +``` + +**Spec**: + +- The in-flight call promise rejects with `MCPCallInterruptedError` as soon as transport drop detected (don't wait for reconnect) +- Pool **does NOT auto-retry** the call; semantics unsafe for writes (commit, file edit, etc.) and pool can't distinguish read from write +- Caller (typically tool execution layer in agent loop) catches this error and decides: retry / surface to user / abort +- After reconnect: session A can re-call (same `PooledConnection.callTool`); pool routes to the new transport instance transparently +- `MCPCallInterruptedError.clientGeneration` lets caller correlate with subsequent `reconnected` event if needed + +Test 21.6 must cover: spawn a long-running stdio MCP, send tool call, kill the child mid-call, assert `MCPCallInterruptedError` rejection with non-zero `clientGeneration`. + +--- + +## 14. Status Route Refactor + +### 14.1 New query path + +```ts +// httpAcpBridge.ts:733 buildWorkspaceMcpStatus — replace data source +let accounting: McpClientAccounting | undefined; +try { + // NEW: query pool directly via bridge extMethod, not bootstrap session + accounting = await this.bridge.client.getMcpPoolAccounting(); +} catch (err) { + // Fallback to legacy bootstrap session path for non-pool daemon + const manager = config.getToolRegistry()?.getMcpClientManager(); + if (manager) accounting = manager.getMcpClientAccounting(); +} +``` + +`QwenAgent` exposes `getMcpPoolAccounting()`: + +```ts +class QwenAgent { + getMcpPoolAccounting(): McpClientAccounting | undefined { + return this.mcpPool?.getAccounting(); + } +} +``` + +ACP child bridges through `extMethod` for the daemon to call. + +### 14.2 entryCount + entrySummary + +Per §8.3. + +### 14.3 No-bootstrap-session case + +Today (PR 12), when daemon is idle (no sessions yet), `GET /workspace/mcp` returns `initialized: false` because there's no bootstrap session to query. + +Under pool: pool exists from `QwenAgent` ctor → status route can return live accounting **even with zero sessions**. Cell `initialized: true` even pre-first-session. **Documented behavior change** in PR description; not a regression. + +--- + +## 15. loadSession / resume Interaction (PR 6 #4222) + +### 15.1 Drain cancellation on resume + +``` +session-A active, holds entry-X ref +session-A disconnect (no explicit close) → eventually killSession → pool.releaseSession(A) → entry-X.refs.size === 0 → drain timer starts (30s) +session-A resume within 30s → new newSessionConfig → pool.acquire returns entry-X → attach cancels drain +session-A resume after 30s → entry-X already closed → pool spawns new entry (cold start) +``` + +### 15.2 `restoreState` cache window (5min, from PR 6) + +`acpAgent.restoreState` is held 5 min after disconnect. Pool drain (30s default) < restore window (5min) → resume between 30s and 5min pays MCP cold start. Acceptable trade-off (resume itself is rare path). + +Alternative: pool reads daemon's restore-window config and extends drain to match. Adds coupling between pool and session state machine; **defer to follow-up unless user reports cold-start pain**. + +### 15.3 `pendingRestoreIds` interaction + +`acpAgent.killSession()` must call `pool.releaseSession(sid)` AFTER cleaning `pendingRestoreIds`. Order: + +1. Session marked as restorable (`pendingRestoreIds.add(sid)`) +2. Session.close() — but pool ref still held +3. After `RESTORE_WINDOW_MS` elapses without resume: `killSession` permanently cleans → `pool.releaseSession(sid)` triggers drain + +Avoids drain firing during a restore window. + +--- + +## 16. Hot Config Reload + +### 16.1 Implicit reload via fingerprint change + +User edits `~/.qwen/settings.json` mid-flight, changes a server's env: + +1. Old sessions keep old `Config`/`McpServers` snapshot → keep acquiring old fingerprint → entry-OLD ref persists +2. New session reads fresh settings → new fingerprint → entry-NEW created → coexists with entry-OLD +3. Old sessions naturally close → entry-OLD drains → eventually closed +4. Steady state: only entry-NEW remains + +**No live-mutation of running connections** — clean separation between sessions on different config versions. + +### 16.2 Forced reload route (optional) + +``` +POST /workspace/mcp/reload-all + → for each session: re-load settings, swap Config.mcpServers + → for each entry no longer referenced: schedule eviction +``` + +Useful for "I changed env vars and want immediate effect across all sessions." Defer to F2 follow-up (not blocking). + +### 16.3 Extension uninstall orphan entries (V21-15) + +Scenario: extension `foo-ext` registers MCP server `foo-server`. Operator runs `/extension uninstall foo-ext`. Extension lifecycle removes `foo-server` from `extensionMcpServers` so future `loadCliConfig` calls don't include it. But: + +- Live sessions hold `Config` snapshots that still include `foo-server` → those sessions keep using the entry +- New sessions after uninstall don't acquire (server no longer in their merged mcpServers) → no refcount increase + +**Resolution**: rely on natural drain. As old sessions close, refcount drops; eventually entry hits `MAX_IDLE_MS = 5min` and is force-closed. **No explicit `pool.invalidateByExtension(name)` API** — keeps the model uniform with hot config reload (§16.1). + +Trade-off: extension's server may run up to 5min after uninstall if a long session keeps it alive. Acceptable; operators can `/mcp restart foo-server` then kill the session if urgency requires. + +--- + +## 17. Shutdown Ordering + +`QwenAgent.close()` sequence (must be enforced): + +``` +1. Set acceptingNewSessions = false; reject new POST /session +2. For each in-flight prompt: signal cancel, await completion (existing PR 11 lifecycle) +3. For each session: trigger close → pool.releaseSession(sid) +4. await pool.drainAll({ force: true, timeoutMs: 10_000 }) ← bypasses 30s grace + ├── For each entry: cancel drain + health timers, mark draining + ├── For each entry in parallel: listDescendantPids → SIGTERM children + ├── For each entry in parallel: client.disconnect() + └── Promise.race against timeoutMs; abandoned entries get SIGKILL +5. Bridge channel close +6. Process exit +``` + +**V21-11**: `drainAll` signature: + +```ts +async drainAll(opts?: { + force?: boolean; // default false; true bypasses 30s grace timer + timeoutMs?: number; // default 10_000; wall-clock budget; SIGKILL stragglers after +}): Promise; + +type DrainResult = { + drained: number; // entries that disconnected cleanly + forced: number; // entries SIGKILLed after timeout + errors: Array<{ entryIndex: number; serverName: string; error: string }>; +}; +``` + +Caller uses `DrainResult` for shutdown logging; on `forced > 0` log a warning so operator knows a server didn't shut down cleanly. + +--- + +## 18. File Layout + +**New files:** + +``` +packages/core/src/tools/ + mcp-transport-pool.ts # McpTransportPool main (~700 LOC) + mcp-pool-key.ts # fingerprint + canonicalize helpers (~150 LOC) + mcp-pool-entry.ts # PoolEntry: refcount + drain + health + generation (~500 LOC) + session-mcp-view.ts # SessionMcpView: filter + register tools/prompts (~200 LOC) + mcp-pool-events.ts # PoolEvent discriminated union (~80 LOC) + pid-descendants.ts # listDescendantPids cross-platform (~150 LOC, incl. tests) + +packages/core/src/tools/ + mcp-transport-pool.test.ts # ~900 LOC + mcp-pool-entry.test.ts # ~400 LOC + session-mcp-view.test.ts # ~250 LOC + mcp-pool-key.test.ts # ~150 LOC + pid-descendants.test.ts # ~200 LOC (Unix + Windows skip-gated) +``` + +**Changed files:** + +``` +packages/core/src/tools/mcp-client.ts # discoverAndReturn() split; connectToMcpServer unified +packages/core/src/tools/mcp-client-manager.ts # optional pool param; budget state conditional +packages/core/src/tools/tool-registry.ts # threads pool from config into McpClientManager +packages/core/src/config/config.ts # setMcpTransportPool / getMcpTransportPool +packages/cli/src/acp-integration/acpAgent.ts # QwenAgent.mcpPool construction; broadcastBudgetEvent; + # newSessionConfig wires pool into Config; + # killSession calls pool.releaseSession +packages/cli/src/serve/runQwenServe.ts # pass --mcp-pool-transports + budget env to ACP child +packages/cli/src/serve/httpAcpBridge.ts # buildWorkspaceMcpStatus reads pool; + # restartMcpServer extMethod returns RestartResult[] +packages/cli/src/serve/capabilities.ts # advertise mcp_workspace_pool +packages/sdk/src/daemon/mcpEvents.ts # scope?: optional field; isWorkspaceScopedBudgetEvent helper +``` + +--- + +## 19. Single-PR Delivery — Commit Breakdown (V21-1) + +Per maintainer's feature-cohesive batch guidance (#4175 branching strategy 2026-05-19), F2 ships as **one PR with 6 atomic commits**. Reviewer can step through with `git log -p HEAD~6..HEAD` and review commit-by-commit. + +| Commit # | Title | Scope | Touches | +| -------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| 1 | `refactor(core): split McpClient.discover into pure tool/prompt list and unify connect paths` | Add `discoverAndReturn()`; extract shared `establishConnection()` used by both `McpClient.connect()` and `connectToMcpServer()` factory; legacy `discover()` becomes thin wrapper that registers (preserves standalone qwen behavior). Zero observable behavior change. | `mcp-client.ts`, `mcp-client.test.ts` | +| 2 | `feat(core): McpTransportPool + SessionMcpView` | Pool core: `fingerprint`, refcount, `spawnInFlight` dedupe, `sessionToEntries` reverse index, drain state machine, snapshot replay on attach, generation guard, tool+prompt dual fan-out, per-session trust copy. Mock McpClient for unit tests. No production wiring. | new `mcp-transport-pool.ts`, `mcp-pool-key.ts`, `mcp-pool-entry.ts`, `session-mcp-view.ts`, `mcp-pool-events.ts` + tests | +| 3 | `feat(core): cross-platform descendant pid sweep + pool health monitor` | `listDescendantPids` (Unix `pgrep -P` recursive, Windows PowerShell CIM); unified health monitor inside `PoolEntry` (interval check + failure count + reconnect backoff per §6.6); subprocess-spawn integration tests gated on `QWEN_INTEGRATION === '1'`. | new `pid-descendants.ts` + tests; `mcp-pool-entry.ts` | +| 4 | `feat(serve): wire McpTransportPool into QwenAgent daemon mode` | `Config.setMcpTransportPool` + `getMcpTransportPool`; `ToolRegistry` threads pool into `McpClientManager`; `McpClientManager` optional `pool?` ctor param; `acpAgent.QwenAgent` constructs pool at init; `newSessionConfig` injection; `killSession` calls `pool.releaseSession`; SDK MCP + HTTP/SSE bypass via `createUnpooledConnection`; CLI flags `--mcp-pool-transports`, `--mcp-pool-drain-ms`, `--no-mcp-pool`. | `config.ts`, `tool-registry.ts`, `mcp-client-manager.ts`, `acpAgent.ts`, `runQwenServe.ts` | +| 5 | `feat(serve): pool-aware status + restart routes` | `QwenAgent.getMcpPoolAccounting` extMethod; `httpAcpBridge.buildWorkspaceMcpStatus` pool-first + bootstrap-session fallback; `restartMcpServer` accepts `?entryIndex=` and returns `RestartResult[]`; `entryCount` + `entrySummary[].entryIndex` on cell; capability tags `mcp_workspace_pool` + `mcp_pool_restart`. | `httpAcpBridge.ts`, `capabilities.ts`, SDK types | +| 6 | `feat(serve): graduate MCP budget guardrails to workspace scope` | Move `tryReserveSlot`/`releaseSlotName`/hysteresis state machine from `McpClientManager` to pool; remove per-session `setMcpBudgetEventCallback` wiring in `acpAgent.newSessionConfig`; `QwenAgent.broadcastBudgetEvent` fan-out; snapshot cell `scope: 'workspace'`; SDK `scope?` additive field; `isWorkspaceScopedBudgetEvent` helper; inline doc updates. | `mcp-transport-pool.ts`, `mcp-client-manager.ts`, `acpAgent.ts`, `httpAcpBridge.ts`, SDK | + +**Total LOC estimate**: ~4100 production + ~1900 tests = ~6000 LOC (v2 estimate ~3850; growth absorbs V21 corrections). + +**Merge target**: single PR into `daemon_mode_b_main`. Periodic batch merge to `main` per #4175 strategy. + +**Self-review process before opening PR**: + +1. After each commit, run `code-reviewer` agent on the commit diff; fold adopted findings into the same commit +2. For commit 2/4/6 (highest design risk), additionally run `silent-failure-hunter` + `type-design-analyzer` +3. After all 6 commits land: 3 full review passes by different agent combinations on the full PR diff +4. Run full test suite + typecheck + lint across all touched packages + +Mirror PR 21's specialist pre-review pattern. + +--- + +## 20. Capability Tags + SDK Contract Changes + +### 20.1 New capability tags (advertised atomically in v0.16, V21-1) + +Because F2 ships as one PR, all three tags advertise together. Pool consumers may assume **`mcp_workspace_pool` advertise ⇒ `entryCount`/`entrySummary`/`scope?` fields all present**; no per-field capability check needed. + +| Tag | When advertised | Meaning | +| -------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| `mcp_workspace_pool` | When `QwenAgent.mcpPool !== undefined` (always true in daemon mode unless `--no-mcp-pool` kill switch) | `GET /workspace/mcp` reflects pool-level state; `entryCount` + `entrySummary` fields present | +| `mcp_pool_restart` | Always when `mcp_workspace_pool` is on | `POST /workspace/mcp/:server/restart` accepts `?entryIndex=` and may return `entries: RestartResult[]` | +| (extends `mcp_guardrails`) | unchanged | Same tag, payload extended with `scope` (`'workspace'` under F2) | + +### 20.2 SDK additive surface + +```ts +// @qwen-code/sdk — additive only +export interface DaemonMcpBudgetWarningData { + // existing fields... + scope?: 'workspace' | 'session'; // NEW — absent on old daemons (means 'session') +} + +export interface DaemonMcpChildRefusedBatchData { + // existing fields... + scope?: 'workspace' | 'session'; +} + +export interface ServeWorkspaceMcpServerStatus { + // existing fields... + entryCount?: number; + entrySummary?: Array<{ + fingerprint: string; + refs: number; + status: MCPServerStatus; + }>; +} + +export function isWorkspaceScopedBudgetEvent( + e: DaemonMcpGuardrailEvent, +): boolean; +``` + +`EVENT_SCHEMA_VERSION` stays at `1` (additive). + +--- + +## 21. Test Matrix + +### 21.1 Pool key (F2-2) + +- Same cfg → same key (env-key permutation stable, header-key permutation stable) +- env value diff 1 byte → different key +- header `Authorization` value diff → different key +- `includeTools`/`excludeTools`/`trust` mutated → SAME key (per-session filter) +- Two `new MCPServerConfig(...)` with identical content → same key (canonical hash, not identity) + +### 21.2 Lifecycle (F2-2) + +- 3 sessions acquire same key → 1 spawn (verify via spy on `client.connect`) +- Release sequence n,n-1,...,1 → drain timer starts only on 1→0 +- 30s drain: acquire at 25s cancels timer; acquire at 35s spawns new entry +- `MAX_IDLE_MS` (5min) hard close even if drain flapping +- Spawn fails during in-flight: all awaiters get error; slot released; no entry stored + +### 21.3 Concurrent acquire (F2-2) + +- 5 simultaneous `acquire(sameKey)` while no entry exists → exactly 1 `spawnEntry` call, all 5 get same entry +- Spawn rejects → all 5 awaiters reject with same error; subsequent acquire re-spawns + +### 21.4 Per-session isolation (F2-2) + +- Session A `excludeTools: ['foo']`, Session B no exclusion → A's ToolRegistry omits foo, B has it; both from same `toolsSnapshot` +- Session A `trust: true`, Session B `trust: false` → Session A's `DiscoveredMCPTool.trust === true`, B's `false`; verify NOT shared reference (mutating one doesn't affect other) +- Session A acquires prompt-only server → A's PromptRegistry populated, ToolRegistry empty for that server + +### 21.5 Tool/Prompt list change (F2-2) + +- Server emits `notifications/tools/list_changed` → all subscribers' `applyTools` called with new snapshot +- Stale handler from pre-reconnect generation does NOT overwrite snapshot +- `notifications/prompts/list_changed` analog + +### 21.6 Crash + reconnect (F2-2) + +- Kill subprocess via `process.kill` → subscribers receive `disconnected` event +- 3 reconnect attempts (using existing `MCPHealthMonitorConfig`) → success → `reconnected` + fresh snapshot +- Exhausted retries → all subscribers receive `failed`; entry transitions to `failed` state; new acquires retry once then throw + +### 21.7 Descendant pid sweep (F2-2b) + +- Linux/macOS: spawn `bash -c "sleep 60 & sleep 60"` as stdio command → kill root → verify both descendants reaped (`/proc//status` poll, or `kill(0, pid) === false`) +- Windows: spawn `cmd /c "ping -t localhost"` wrapper → kill → verify ping subprocess gone +- `pgrep` unavailable (PATH missing) → graceful degradation: log warning, just SIGTERM root, don't crash + +### 21.8 Budget at workspace scope (F2-4) + +- 4 sessions × `--mcp-client-budget=2` with 3 static MCP servers → workspace total = 3 (not 12); snapshot cell `scope: 'workspace'`, `liveCount: 3` +- Budget warning fires once per 75% upward crossing across whole workspace; broadcasts to all 4 sessions simultaneously +- Hysteresis re-arm: drop to 37.5% → next crossing fires again + +### 21.9 Backward compat (F2-3) + +- Standalone `qwen` (no daemon) → `mcpPool === undefined` → all existing `mcp-client-manager.test.ts` tests pass unchanged +- `--no-mcp-pool` daemon flag → falls back to per-session, all existing daemon e2e tests pass + +### 21.10 Credential isolation (F2-3) + +- Session A injects `{name: 'github', headers: {Authorization: 'Bearer tokenA'}}`, Session B `tokenB` → 2 separate processes; verify by snapshot `entryCount: 2`; verify A's tool calls go through A's transport (by header inspection in stdin/log) + +### 21.11 LoadSession / resume (F2-3) + +- Session close → drain starts → resume within 30s → pool entry reused (no cold start, asserted via `client.connect` spy count) +- Resume after 30s but before restore-window expiry → pool cold start; restoreState content still preserved + +### 21.12 Restart route (F2-3b) + +- 1 entry for name → `POST /workspace/mcp/foo/restart` returns legacy `{restarted: true, durationMs}` shape +- 2 entries for name (different fingerprints) → returns `{entries: [{fingerprint, restarted, ...}, ...]}` +- Restart while another restart in-flight → second call returns same promise (deduped) +- Restart when budget would exceed → `{restarted: false, skipped: true, reason: 'budget_would_exceed'}` per entry + +### 21.13 Status route (F2-3b) + +- Idle daemon (no sessions) but pool has cached entries from previous session → `GET /workspace/mcp` returns `initialized: true` with live accounting +- Bootstrap session DNE → fallback to pool-direct path; no error +- Pool query throws → falls back to bootstrap-session path; never crashes snapshot + +### 21.14 SDK reducer (F2-4) + +- `mcpBudgetWarningCount` increments simultaneously across all subscriber sessions when workspace event broadcasts +- `isWorkspaceScopedBudgetEvent(e)` correctly identifies scope from payload +- Old daemon (no `scope` field) → defaults to 'session' interpretation + +### 21.15 Hot config reload (F2-3) + +- Mid-flight settings.json change → old session keeps old entry, new session creates new entry, both coexist; old drains naturally when last old session closes +- 0 sessions after old session closes → drain timer fires → old entry GC'd → only new entry remains + +### 21.16 Shutdown ordering (F2-3) + +- `QwenAgent.close()` triggers in order: stop accepting → drain prompts → close sessions → `pool.drainAll` → no zombie pids in `pgrep -P ` after exit + +--- + +## 22. Open Questions + +V21 locked Q1/Q3/Q4/Q6 in design defaults (single-PR delivery). Q2/Q5/Q7/Q8/Q9 remain. + +| # | Question | F2 design default | Decision needed before | +| ----- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------- | +| Q1 ✅ | SDK reducer field names — rename or keep? | **LOCKED v2.1**: keep `mcpBudgetWarningCount` etc. with extended scope semantics in JSDoc | resolved | +| Q2 | `mcp_workspace_pool` capability — bump `protocolVersions` ('v1' → 'v1.1'), or stay 'v1' additive? | **Stay 'v1' additive** (consistent with PR 14b precedent) | commit 5 | +| Q3 ✅ | `--no-mcp-pool` flag — default on or opt-in? | **LOCKED v2.1**: default on; `--no-mcp-pool` is kill switch | resolved | +| Q4 ✅ | HTTP/SSE default — pool off or on? | **LOCKED v2.1**: pool off; opt-in via `--mcp-pool-transports` | resolved | +| Q5 | `POST /workspace/mcp/reload-all` — include in F2 or follow-up? | **Follow-up** | n/a (deferred) | +| Q6 ✅ | Lazy pool construction — worth the conditional? | **LOCKED v2.1**: eager (always construct in `QwenAgent` ctor) | resolved | +| Q7 | `restoreState` window vs pool drain — keep separate, align, or read from settings? | **Keep separate 30s default** + config knob `--mcp-pool-drain-ms` | commit 4 | +| Q8 | OAuth handling — confirm F3 deferral, document workaround? | **Deferred to F3**, document `/mcp auth ` workaround | commit 4 | +| Q9 | `entrySummary` exposure — always include, or behind verbose flag? | **Always include** (small payload, useful for ops) | commit 5 | +| Q10 | Update `codeagents/qwen-code-daemon-design/02-architectural-decisions.md` decision #3 — coordinate with @wenshao? | F2 PR description links codeagents PR; two PRs reviewed independently | PR open | + +--- + +## 23. Risks + +### High + +- **R1 (A2 global state)**: `serverStatuses` collision on multi-entry same-name. Mitigated by aggregate-status function; remaining risk is SDK consumers reading the raw global Map (unlikely — only used via `getMCPServerStatus(name)` accessor). +- **R2 (PromptRegistry symmetry)**: forgetting prompt fan-out in any code path silently drops prompts. Mitigated by F2-2 test 21.4 third bullet + integration test asserting prompt parity vs pre-F2. +- **R3 (HTTP transport state-bleed)**: opting in HTTP pool for a server that maintains per-transport state corrupts session contexts. Mitigated by default-off + documentation; cannot detect automatically. + +### Medium + +- **R4 (path unification F2-1)**: `connectToMcpServer` factory and `McpClient` class have subtle behavioral diffs (e.g. capabilities advertised at construct time vs connect time). Mitigated by F2-1 being a pure refactor PR with full regression coverage before pool work begins. +- **R5 (Windows descendant pid)**: PowerShell `Get-CimInstance` may be slow (spawn cost) or blocked by AppLocker. Mitigated by 2s timeout + graceful degradation. +- **R6 (Pool event broadcast amplification)**: budget warning fanning out to 100 sessions causes 100 extNotification calls in tight loop. Mitigated by `Promise.all` parallelization + per-session catch (existing PR 14b pattern). + +### Low + +- **R7 (Fingerprint stability across MCPServerConfig versions)**: future fields added to `MCPServerConfig` not included in fingerprint would silently allow incorrect sharing. Mitigated by explicit canonicalization function + test that enumerates all `MCPServerConfig` fields and asserts coverage. +- **R8 (Generation counter races)**: rapid restart cycles could exhaust JS number precision (≈ 2^53 = ~285k years at 1/sec). Not a practical concern. + +### Single-PR-specific (V21-14) + +- **R9 (Review fatigue on ~6000 LOC single PR)**: Reviewer bandwidth becomes critical path. F3 blocked on F2 merge → blocking other contributors. Mitigation: (a) pre-review with 3 specialist agents and fold P0/P1 before opening, mirroring PR 21's pattern; (b) structure as 6 atomic commits so reviewer can step through; (c) coordinate review window with @wenshao in advance via #4175 comment. +- **R10 (`daemon_mode_b_main` merge conflict accumulation)**: F2 touches `acpAgent.ts`, `httpAcpBridge.ts`, `capabilities.ts`, `mcp-client*.ts` — all hot paths. F3 / F4 contributors landing concurrently risk conflicts during F2's 1–2 week review window. Mitigation: daily `git rebase origin/daemon_mode_b_main`; coordinate via #4175 update that F2 is in-flight + asks F3/F4 to defer hot-file changes until F2 merges. +- **R11 (CI execution time)**: ~1900 LOC of new tests including subprocess spawn + cross-platform pid sweep could push CI from 30min → 50min. Mitigation: (a) gate subprocess tests behind `process.env.QWEN_INTEGRATION === '1'`, run subset in PR CI + full set in nightly; (b) Vitest parallelism ≥ 4; (c) Windows pid sweep tests skip-gated on GHA Windows runner only. + +--- + +## 24. Documentation Updates + +| Doc | Update | When | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `codeagents/qwen-code-daemon-design/02-architectural-decisions.md` | Decision #3 "MCP server lifetime": currently "per-session"; update to "workspace-pooled with config-hash key under daemon mode; per-session standalone" | F2-3 merges (coordinate with @wenshao codeagents PR) | +| `codeagents/qwen-code-daemon-design/06-roadmap.md` | Wave 5 PR 23 → mark as F2 series; link to PRs | F2-3 merges | +| `packages/cli/src/serve/README.md` (if exists) or new `docs/serve/mcp-pool.md` | New section: pool semantics, fingerprint key, transport opt-in, restart semantics, status snapshot interpretation | F2-3b | +| `packages/sdk/README.md` | `scope?` field on guardrail events, `entryCount` on server status, helper `isWorkspaceScopedBudgetEvent` | F2-4 | +| Issue #4175 body | Update F2 entry with sub-PR table, link to design v2 (this doc) | Before F2-1 opens | +| Issue #3803 body | Decision #3 row: update "Currently per-session" → "Workspace-pooled under daemon (F2)" | After F2-3 merges | +| `acpAgent.ts:869-936` inline comment | Remove "Wave 5 PR 23" forward reference; update to "graduated by F2 to `scope: 'workspace'`" | F2-4 PR | +| CHANGELOG / release notes (Wave 6 / F5) | "MCP processes now shared across sessions in a workspace" headline | F5 release | + +--- + +## 25. PR Description Template (single-PR delivery) + +```markdown +## feat(serve): shared MCP transport pool (workspace-scoped) [F2] + +Single feature-cohesive PR per #4175 branching strategy (2026-05-19). +Replaces what was originally planned as Wave 5 PR 23 + sub-PRs F2-1..F2-4. + +### Scope + +~4100 LOC production + ~1900 LOC tests across 6 atomic commits. +Step through with `git log -p HEAD~6..HEAD` for commit-by-commit review. + +### Design doc + +See `docs/design/f2-mcp-transport-pool.md` (v2.1). + +### Pre-review specialist agents (per PR 21 pattern) + +Folded into first commit before opening: + +- code-reviewer: N findings, all adopted +- silent-failure-hunter: N findings, all adopted +- type-design-analyzer: N findings, all adopted + +### Closes + +(none — F2 entry in #4175 stays open until PR merges into main batch) + +### Related + +- #3803 decision #3 update (codeagents PR ) +- PR 14b (#4271 merged) — budget guardrail base; F2 graduates scope to workspace +- F1 (#4319 merged) — acp-bridge package; F2 depends on injection seams + +### Backward compatibility + +- Standalone `qwen` (non-daemon): pool not constructed; existing behavior preserved +- Daemon `qwen serve --no-mcp-pool`: kill switch falls back to per-session +- SDK: all new fields additive (`entryCount`, `scope?`); EVENT_SCHEMA_VERSION stays at 1 +- Old SDK clients: unknown `scope: 'workspace'` ignored per PR 14 contract +- Old daemons: SDK consumers can detect absence of `mcp_workspace_pool` capability and fall back + +### Test plan + +- [ ] Pool key: env permutation stability, header divergence, per-session filter exclusion +- [ ] Lifecycle: 3-session sharing, drain grace, concurrent acquire dedupe, spawn failure slot release +- [ ] Tools + Prompts dual fan-out, per-session trust copy, snapshot replay on attach +- [ ] Generation guard: pre-reconnect handler doesn't overwrite post-reconnect snapshot +- [ ] Crash + reconnect with stdio backoff (5s × 3) and HTTP backoff (1/2/4/8/16s × 5) +- [ ] Descendant pid sweep: Linux/macOS pgrep recursion, Windows PowerShell CIM +- [ ] Budget at workspace scope: 4 sessions × budget=2 → 3 max (not 12); fan-out to all attached +- [ ] LoadSession resume within drain window: pool entry reused, no cold start +- [ ] Hot config reload: old/new entries coexist; old drains naturally +- [ ] Restart route: `?entryIndex=` selectivity; legacy single-entry response shape preserved +- [ ] In-flight tool call during reconnect: `MCPCallInterruptedError` rejection +- [ ] Standalone qwen: all existing mcp-client-manager tests pass unchanged +``` + +## Summary + +F2 v2.1 = single PR with 6 atomic commits (~6000 LOC), targeting `daemon_mode_b_main`. Key design pillars: + +1. **`McpTransportPool`** in `packages/core` (ACP child side), workspace-scoped, refcount + 30s drain +2. **Fingerprint key** SHA-256 over canonical config including env/headers (claude-code pattern), excluding per-session filters (includeTools/trust) +3. **`SessionMcpView`** per-session tool+prompt registry projection with trust copy +4. **Snapshot replay + generation guard** for attach race and stale notifications +5. **Cross-platform descendant pid sweep** (opencode pattern + Windows port) +6. **HTTP/SSE opt-in**, SDK MCP bypass, OAuth deferred to F3 +7. **Budget state machine** graduates to workspace scope; snapshot cell + push events extend additively (`scope?`) +8. **Status + restart routes** refactor: pool-first with bootstrap-session fallback; `entryCount` + `RestartResult[]` + +**Open questions Q1–Q10** in §22 need maintainer decisions before respective sub-PRs open. Recommend resolving Q1–Q4 before F2-3 starts (those gate the broad direction); Q5–Q10 can resolve incrementally. diff --git a/docs/design/session-idle-reaper/README.md b/docs/design/session-idle-reaper/README.md new file mode 100644 index 0000000000..b7a724ac77 --- /dev/null +++ b/docs/design/session-idle-reaper/README.md @@ -0,0 +1,434 @@ +# Session Idle Reaper — Design Document + +**Status:** Draft +**Author:** qinqi +**Date:** 2026-06-08 +**Scope:** `packages/acp-bridge/src/bridge.ts`, `packages/cli/src/serve/server.ts` + +--- + +## 1. Problem Statement + +### 1.1 Current behavior + +Once created, a bridge session lives in memory (`byId: Map`) +indefinitely. It is only destroyed when: + +1. A client explicitly calls `DELETE /session/:id` (`closeSession`) +2. The shared `qwen --acp` child process crashes (`channel.exited` handler) +3. The daemon process receives `SIGTERM` / `SIGINT` (`shutdown`) + +There is **no automatic idle timeout** for sessions. The heartbeat timestamps +(`sessionLastSeenAt`, `clientLastSeenAt`) are recorded by `recordHeartbeat` but +never consumed for eviction purposes (the field comment references a future +"revocation policy (PR 24)" that has not landed). + +### 1.2 Impact + +| Scenario | Symptom | +| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| User opens multiple browser tabs, closes them without calling `DELETE /session` | Sessions accumulate in `byId`, each holding an EventBus ring (~2-4 MB) | +| 20 sessions (default `maxSessions`) accumulate | `SessionLimitExceededError` on new `spawnOrAttach` — user locked out | +| Long-lived daemon with tab churn | Unbounded memory growth in the EventBus replay rings and ACP-side session state | +| IDE extension restarts / crashes | Orphaned sessions never cleaned up | + +### 1.3 Why now + +The daemon is increasingly used as a long-running workspace server (desktop app, +IDE extensions, web UI). Client crashes and network blips are normal — relying on +explicit `DELETE` for cleanup is untenable. + +--- + +## 2. Design Goals + +1. **Automatically reclaim idle sessions** whose clients are gone and that have + no active work in progress. +2. **Never destroy a session that has an active prompt** — doing so would + silently kill user-visible work. +3. **Preserve persisted session data** — only in-memory bridge state is released; + disk transcripts (`SessionService`) are untouched. Users can `session/load` or + `session/resume` to restore. +4. **Observable** — emit a distinct SSE event so clients know WHY the session + closed (idle timeout vs. explicit close vs. crash). +5. **Configurable** — operators and tests can tune timeouts or disable the + reaper entirely. +6. **Zero new dependencies / components** — implement entirely within the + existing bridge closure. + +### Non-goals + +- Cross-workspace session management (that would be a gateway concern). +- LRU eviction at `maxSessions` boundary (valuable but separate work — tracked + as a follow-up). +- EventBus ring compaction for idle sessions (low priority given the 20-session + cap; tracked as a follow-up). +- RSS-based adaptive pressure (requires `process.memoryUsage()` polling and + policy design; tracked as a follow-up). + +--- + +## 3. Architecture + +### 3.1 Overview + +``` +Bridge closure (createHttpAcpBridge) +│ +├─ byId: Map ← existing +├─ channelInfo: ChannelInfo ← existing +├─ idleTimer (channel-level) ← existing +│ +└─ sessionReaper: NodeJS.Timeout ← NEW + │ + ├─ scans byId every REAP_INTERVAL_MS + ├─ skips sessions with active prompt + ├─ skips sessions with live SSE subscribers + ├─ closes sessions exceeding idle TTL + └─ emits session_closed { reason: 'idle_timeout' } +``` + +### 3.2 Relationship to existing mechanisms + +| Mechanism | Scope | What it manages | +| ----------------------------------------- | ------------------------- | -------------------------------------------------------------------------------- | +| `channelIdleTimeoutMs` + `startIdleTimer` | Channel (child process) | Kills the `qwen --acp` child when ALL sessions are gone | +| **Session reaper** (this design) | Session (in-memory entry) | Closes individual sessions when idle | +| `ConnectionRegistry` sweep | ACP-over-HTTP connection | Reaps `/acp` transport-layer connections (different layer) | +| `writerIdleTimeoutMs` | SSE subscriber | Evicts a single stuck SSE subscriber | +| Disconnect reaper (server.ts) | Spawn handshake | Reaps sessions whose spawn-owner disconnected DURING the POST /session handshake | + +Two mechanisms work together to cover session lifecycle cleanup: + +1. **Close-on-last-detach** (primary) — when `detachClient` removes the last + registered client AND no SSE subscribers remain, the session is closed + immediately via `closeSessionImpl`. This handles the normal path: user + closes a tab → React cleanup → `POST /session/:id/detach`. + +2. **Session idle reaper** (backstop) — periodic scan for sessions with no + active prompt and no SSE subscribers that haven't received a heartbeat + within the configured TTL. This catches the crash path: browser killed, + network dropped, `kill -9` — the detach request was never sent, so + `clientIds` still shows registered clients but the session is effectively + orphaned. + +--- + +## 4. Detailed Design + +### 4.1 New configuration options (`BridgeOptions`) + +```typescript +interface BridgeOptions { + // ... existing fields ... + + /** + * How often the session reaper scans `byId` for idle sessions, in + * milliseconds. Default: 60_000 (1 minute). Set to 0 or Infinity to + * disable the reaper entirely. The timer is `.unref()`'d. + */ + sessionReapIntervalMs?: number; + + /** + * A session with ZERO live SSE subscribers AND ZERO registered clients + * that has not received a heartbeat for this many milliseconds is + * considered idle and will be reaped. + * + * Default: 30 * 60_000 (30 minutes). + * Set to 0 or Infinity to disable idle reaping. + */ + sessionIdleTimeoutMs?: number; +} +``` + +**CLI surface** (`qwen serve` flags): + +``` +--session-reap-interval-ms Reaper scan interval (default 60000, 0=disable) +--session-idle-timeout-ms Idle threshold (default 1800000, 0=disable) +``` + +### 4.2 Session idle predicate + +A session is eligible for reaping when **all** of the following hold: + +1. **No active prompt**: `entry.promptActive === false` +2. **No live SSE subscribers**: `entry.events.subscriberCount === 0` +3. **Idle duration exceeded**: `now - lastActivity(entry) > sessionIdleTimeoutMs` + +Note: the reaper intentionally does NOT check `clientIds.size`. It covers +the crash path where detach was never sent — `clientIds` still shows +registered clients but the session is effectively orphaned. The normal +path (client sends detach) is handled by close-on-last-detach instead. + +Where `lastActivity(entry)` is defined as: + +```typescript +function lastActivity(entry: SessionEntry): number { + // `sessionLastSeenAt` is epoch-ms (from Date.now()); + // `createdAt` is an ISO 8601 string — parse to epoch-ms as fallback. + return entry.sessionLastSeenAt ?? Date.parse(entry.createdAt); +} +``` + +Note: `entry.createdAt` is typed as `string` (ISO 8601), not a number. +`Date.parse` is safe here — the format is always `new Date().toISOString()` +(see `createSessionEntry`, bridge.ts:1883). + +**Rationale for each guard:** + +| Guard | Why | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------- | +| No active prompt | A headless / autonomous prompt (e.g. CLI pipe, cron job) may be running with no SSE subscriber. Reaping it would kill work. | +| No SSE subscribers | A connected client is actively listening. Even if it hasn't sent a heartbeat, the SSE connection itself proves liveness. | +| Idle duration | Grace period so briefly-disconnected clients can reconnect without losing their session. | + +### 4.3 Reap action + +For each session that passes the idle predicate, the reaper calls: + +```typescript +await closeSession(sessionId, { reason: 'idle_timeout' }); +``` + +This reuses the existing `closeSession` path which: + +1. Removes from `byId` / `defaultEntry` +2. Cancels pending permissions via `permissionMediator.forgetSession` +3. Publishes `session_closed` event (with `reason: 'idle_timeout'`) +4. Closes the EventBus +5. Sends `connection.cancel()` to the ACP child (best-effort) +6. Triggers `startIdleTimer` on the channel if it was the last session + +**Why `closeSession` and not `killSession`?** + +`killSession` is the internal force-reap path designed for the spawn-handshake +disconnect race (`requireZeroAttaches` guard, `spawnOwnerWantedKill` tombstone). +`closeSession` is the documented client-facing path that publishes +`session_closed` (not `session_died`) and handles telemetry correctly. The reaper +is a "graceful close on behalf of an absent client", so `closeSession` is the +right semantic. + +### 4.4 Extending `closeSession` to accept a close reason + +Currently `closeSession` hardcodes `reason: 'client_close'` in the +`session_closed` event. We need to make this parameterizable. + +**Approach:** Add a new optional `opts` parameter to `closeSession` rather than +overloading `BridgeClientRequestContext` (which is a client-request-scoped +type — adding `reason` to it would be a layer violation since "reason" is a +server-side decision, not something a client passes in a header). + +```typescript +// bridgeTypes.ts — new type + signature change: +export interface CloseSessionOpts { + /** Override the default 'client_close' reason in the session_closed event. */ + reason?: string; +} + +closeSession( + sessionId: string, + context?: BridgeClientRequestContext, + opts?: CloseSessionOpts, +): Promise; +``` + +```typescript +// bridge.ts — implementation change: +async closeSession(sessionId, context, opts) { + // ... + const reason = opts?.reason ?? 'client_close'; + entry.events.publish({ + type: 'session_closed', + data: { sessionId, reason, ... }, + }); +} +``` + +Existing callers (`DELETE /session/:id` route) pass no `opts`, defaulting to +`'client_close'`. The reaper passes `{ reason: 'idle_timeout' }`. + +### 4.5 Reaper lifecycle + +```typescript +// Inside createHttpAcpBridge closure: + +const resolvedReapIntervalMs = resolvePositiveMs( + opts.sessionReapIntervalMs, + 60_000, +); +const resolvedIdleTimeoutMs = resolvePositiveMs( + opts.sessionIdleTimeoutMs, + 30 * 60_000, +); + +let sessionReaper: ReturnType | undefined; + +function startSessionReaper(): void { + if (resolvedReapIntervalMs <= 0 || resolvedIdleTimeoutMs <= 0) return; + sessionReaper = setInterval(() => { + if (shuttingDown) return; + const now = Date.now(); + for (const [id, entry] of byId) { + if (entry.promptActive) continue; + if (entry.events.subscriberCount > 0) continue; + const lastActive = entry.sessionLastSeenAt ?? Date.parse(entry.createdAt); + const idle = now - lastActive; + if (idle < resolvedIdleTimeoutMs) continue; + writeStderrLine( + `qwen serve: reaping idle session ${JSON.stringify(id)} ` + + `(idle for ${Math.round(idle / 1000)}s, threshold ${Math.round(resolvedIdleTimeoutMs / 1000)}s)`, + ); + // Pass `undefined` context (no client) and `{ reason }` opts. + bridgeImpl + .closeSession(id, undefined, { reason: 'idle_timeout' }) + .catch((err) => { + writeStderrLine( + `qwen serve: session reaper failed to close ${JSON.stringify(id)}: ${String(err)}`, + ); + }); + } + }, resolvedReapIntervalMs); + sessionReaper.unref(); +} + +function stopSessionReaper(): void { + if (sessionReaper !== undefined) { + clearInterval(sessionReaper); + sessionReaper = undefined; + } +} +``` + +Note: `bridgeImpl` refers to the bridge object returned by `createHttpAcpBridge` +so `closeSession` has full access to the closure-scoped state. In practice, this +is implemented as a direct call to the closure-internal `closeSessionImpl` +function. + +**Lifecycle integration:** + +- `startSessionReaper()` is called at bridge construction time (after + option validation, alongside the existing `channelIdleTimeoutMs` setup). +- `stopSessionReaper()` is called in both `shutdown()` and `killAllSync()`. + +### 4.6 Interaction with existing `closeSession` callers + +| Caller | Impact | +| ---------------------------- | ------------------------------------------------------------------ | +| `DELETE /session/:id` route | None — no `opts` passed, defaults to `reason: 'client_close'` | +| Session reaper (this design) | Passes `opts: { reason: 'idle_timeout' }` | +| `detachClient` deferred reap | Calls `killSession` (not `closeSession`), unaffected | +| `channel.exited` handler | Publishes `session_died`, unaffected | +| `shutdown()` | Publishes `session_died` with reason `daemon_shutdown`, unaffected | + +### 4.7 Concurrency safety + +The reaper callback runs on the Node.js event loop. Key considerations: + +- **`for...of` iteration is synchronous.** The reaper evaluates each entry's + idle predicate synchronously, then fires `closeSession(...).catch(...)` for + matching entries. No `await` in the loop body — all closes are dispatched + in a single microtask boundary, then the loop exits. +- **`byId.delete` is deferred.** Inside `closeSession`, `byId.delete` runs + AFTER the first `await` (`notifyAgentSessionClose`). This means deletions + happen in microtasks after the `for...of` loop has completed. Since each + `closeSession` operates on a distinct key, there is no aliasing. And `for...of` + has already finished iterating, so mid-iteration deletion is not a concern. +- **Double-close race.** If a client calls `DELETE /session/:id` for the same + session between the reaper's predicate check and the async `closeSession` + execution, the reaper's `closeSession` will throw `SessionNotFoundError` + (caught by `.catch()`). Safe. +- **Reconnect race.** If a client reconnects to a session (registers clientId / + opens SSE) between the reaper's predicate check and `closeSession` execution, + `closeSession` will still proceed and close the session. The client receives + `session_closed` and must re-load. This window is extremely narrow (one + synchronous `setInterval` tick) and the consequence is benign — no data loss, + just a re-load prompt. The 30-minute default TTL makes this vanishingly rare. +- A concurrent `spawnOrAttach` that creates a new session while the reaper + is scanning won't be seen (we iterate `byId` entries at the start of each + tick). This is safe — new sessions are fresh and won't meet the idle threshold. + +### 4.8 Wire-format change + +The `session_closed` event's `data.reason` field already exists with value +`'client_close'`. We add two new values: + +- `'idle_timeout'` — emitted by the idle reaper (backstop for crashed clients) +- `'last_client_detached'` — emitted by close-on-last-detach (normal tab close) + +This is backward-compatible — existing SDK code that checks +`reason === 'client_close'` will simply not match the new values, and the +generic terminal-frame handler (`isTerminalLifecycleEvent`) already handles +`session_closed` regardless of reason. + +--- + +## 5. Test Plan + +### 5.1 Unit tests (`bridge.test.ts`) + +| # | Test | Description | +| --- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Idle session is reaped after timeout | Create a session, advance time past `sessionIdleTimeoutMs`, trigger reaper tick, verify session removed from `byId` and `session_closed` event published with `reason: 'idle_timeout'` | +| 2 | Session with active prompt is NOT reaped | Create a session, start a prompt, advance time, verify session survives reaper tick | +| 3 | Session with live SSE subscriber is NOT reaped | Create a session, subscribe to its EventBus, advance time, verify session survives | +| 4 | Session with registered client is NOT reaped | Create a session, register a clientId, advance time, verify session survives | +| 5 | Reaper disabled when interval = 0 | Pass `sessionReapIntervalMs: 0`, verify no `setInterval` is armed | +| 6 | Reaper disabled when timeout = 0 | Pass `sessionIdleTimeoutMs: 0`, verify no `setInterval` is armed | +| 7 | Reaper stopped on shutdown | Call `shutdown()`, verify `clearInterval` was called | +| 8 | closeSession reason defaults to 'client_close' | Call `closeSession` without explicit reason, verify published event has `reason: 'client_close'` | +| 9 | closeSession with explicit reason | Call `closeSession` with `reason: 'idle_timeout'`, verify published event | +| 10 | Multiple idle sessions reaped in one tick | Create 3 idle sessions, advance time, trigger tick, verify all 3 reaped | +| 11 | Session with heartbeat within TTL survives | Create a session, record heartbeat, advance time to just under TTL, verify session survives | +| 12 | Channel idle timer triggered after last session reaped | Create 1 session (last on channel), reap it, verify `startIdleTimer` is called on the channel | + +### 5.2 Integration tests (`server.test.ts`) + +| # | Test | Description | +| --- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| 1 | `GET /health?deep=1` reflects reaper-cleaned session count | Start daemon, create sessions, advance time, verify health endpoint shows reduced count | +| 2 | SSE subscriber receives `session_closed` with `reason: 'idle_timeout'` | Open SSE, disconnect, reconnect before TTL, then let TTL expire, verify event | + +--- + +## 6. Configuration Defaults + +| Option | Default | Rationale | +| ----------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------- | +| `sessionReapIntervalMs` | 60,000 (1 min) | Frequent enough to prevent long accumulation, cheap enough (simple Map scan) to run often | +| `sessionIdleTimeoutMs` | 1,800,000 (30 min) | Generous grace period for reconnection. Matches `ConnectionRegistry.idleTtlMs` for mental model consistency | + +--- + +## 7. Observability + +- **stderr log**: `qwen serve: reaping idle session "" (idle for Nms)` on + each reap, matching existing `qwen serve:` prefix convention. +- **Telemetry event**: `session.close` with operation + `qwen-code.daemon.bridge.operation: 'session.close'` (reuses existing + `closeSession` telemetry path). +- **Telemetry metric**: `sessionLifecycle('close')` (reuses existing counter). +- **SSE event**: `session_closed` with `data.reason: 'idle_timeout'`. + +--- + +## 8. Follow-up Work (Out of Scope) + +| Item | Description | Priority | +| ------------------------------- | ------------------------------------------------------------------------------- | -------- | +| LRU eviction at `maxSessions` | Instead of rejecting new sessions, evict the least-recently-active idle session | P1 | +| EventBus ring compaction | Shrink the ring for sessions with 0 subscribers to save memory | P2 | +| RSS-based adaptive pressure | Monitor `process.memoryUsage().rss` and lower the idle TTL when memory is tight | P2 | +| Heartbeat-based client liveness | Auto-unregister clients that miss N consecutive heartbeat windows | P2 | + +--- + +## 9. Risks and Mitigations + +| Risk | Mitigation | +| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Reaper closes a session that a headless client is about to reconnect to | 30-minute default TTL is generous; headless clients should send heartbeats. Disk transcript is preserved — `session/load` restores it. | +| `closeSession` inside reaper throws, poisoning the scan loop | Each close is in its own `.catch()` — one failure doesn't block others | +| Reaper iteration over `byId` during concurrent `closeSession` from another path | ES2015 Map iteration tolerates deletion of current/previous keys. Double-close is idempotent (`byId.get` returns undefined → `SessionNotFoundError` caught by reaper's `.catch`). | +| Performance of scanning 20 sessions every 60s | Trivial — 20 Map reads + 4 field checks each. No I/O. | +| Channel idle timer interaction | When the last session is reaped, `closeSession` already calls `startIdleTimer` on the channel. No additional logic needed. | diff --git a/docs/design/session-recap/session-recap-design.md b/docs/design/session-recap/session-recap-design.md index af93fcc813..03fdce7b65 100644 --- a/docs/design/session-recap/session-recap-design.md +++ b/docs/design/session-recap/session-recap-design.md @@ -21,16 +21,42 @@ returns: ## Triggers -| Trigger | Conditions | Implementation | -| ---------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| **Manual** | User runs `/recap` | `recapCommand.ts` calls the same underlying service | -| **Auto** | Terminal blurred (DECSET 1004 focus protocol) for ≥ 5 min + focus returns + stream is `Idle` | `useAwaySummary.ts` — 5min blur timer + `useFocus` event listener | +| Trigger | Conditions | Implementation | +| --------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Manual** | User runs `/recap` | `recapCommand.ts` calls the same underlying service | +| **Auto** | Terminal blurred (DECSET 1004 focus protocol) for ≥ 5 min + focus returns + stream is `Idle` | `useAwaySummary.ts` — 5min blur timer + `useFocus` event listener | +| **Daemon HTTP** | Remote client calls `POST /session/:id/recap` | `server.ts` route → `bridge.generateSessionRecap` (ext-method roundtrip) → `acpAgent.ts` calls `generateSessionRecap(session.getConfig(), signal)` | -Both paths funnel into a single function — `generateSessionRecap()` — to -guarantee identical behavior. The auto-trigger is gated by -`general.showSessionRecap` (default: off — explicit opt-in, so ambient -LLM calls are never silently added to a user's bill); the manual -command ignores that setting. +All three paths funnel into the same `generateSessionRecap()` function +in `core/services/sessionRecap.ts` to guarantee identical behavior. The +auto-trigger is gated by `general.showSessionRecap` (default: off — +explicit opt-in, so ambient LLM calls are never silently added to a +user's bill); the manual command and daemon HTTP route ignore that +setting (the caller is making an explicit request). + +### Daemon access path + +The daemon route is non-strict-gated (mirrors `/session/:id/prompt`'s +posture — recap costs tokens but mutates no state). Capability tag +`session_recap` advertises the route on `/capabilities.features`. SDK +helpers: `DaemonClient.recapSession(sessionId, opts)` and +`DaemonSessionClient.recap(opts)`. See +`docs/developers/qwen-serve-protocol.md` § `POST /session/:id/recap` +for the wire contract and error envelope. + +Cancellation is **absent in v1**. The route does not listen for HTTP +client disconnect, no `AbortSignal` is threaded into +`bridge.generateSessionRecap`, and the ACP child handler passes a +never-aborting `AbortController().signal` to the core helper (no +cross-process abort plumbing yet). The only ceilings are the bridge's +60s `SESSION_RECAP_TIMEOUT_MS` backstop and the transport-closed race +against ACP channel death. Wiring an HTTP-side AbortController in +isolation would be cosmetic — the child-side LLM call would still run +to completion, so e2e cancel is not achievable without the cross- +process abort piece. This is acceptable for v1 because recap is short +(single-attempt side-query, `maxOutputTokens: 300`, ~1–5s typical). +A future request-id-based cancel ext-method can plumb full end-to-end +cancellation if/when the bandwidth cost justifies it. ## Architecture diff --git a/docs/developers/daemon-client-adapters/web-ui.md b/docs/developers/daemon-client-adapters/web-ui.md new file mode 100644 index 0000000000..2aa022fa37 --- /dev/null +++ b/docs/developers/daemon-client-adapters/web-ui.md @@ -0,0 +1,118 @@ +# Daemon Web UI Adapter + +## Goal + +Web chat and web terminal clients should consume `qwen serve` through the +daemon HTTP/SSE APIs and render a client-side transcript. Native local TUI, +channel, and IDE integrations keep their existing default paths for now. + +## Shared UI Contract + +Use the TypeScript SDK daemon UI exports as the common boundary: + +```ts +import { + DaemonClient, + DaemonSessionClient, + createDaemonTranscriptStore, + normalizeDaemonEvent, +} from '@qwen-code/sdk/daemon'; +``` + +The split is: + +- `DaemonClient` handles daemon HTTP routes. +- `DaemonSessionClient` owns session creation/attachment and SSE replay. +- `normalizeDaemonEvent()` converts daemon wire events into UI events. +- `createDaemonTranscriptStore()` reduces UI events into transcript blocks. + +React clients can use the optional `@qwen-code/webui` binding: + +```tsx +import { + DaemonSessionProvider, + useDaemonActions, + useDaemonConnection, + useDaemonPendingPermissions, + useDaemonTranscriptBlocks, +} from '@qwen-code/webui'; +``` + +Minimal React shape: + +```tsx +function App() { + return ( + + + + + ); +} + +function Transcript() { + const blocks = useDaemonTranscriptBlocks(); + return blocks.map((block) => ); +} +``` + +The provider creates or attaches a daemon session, subscribes to SSE, keeps the +last event id on `DaemonSessionClient`, and reconnects the stream by default. +Callers can disable that with `autoReconnect={false}` for tests or custom +connection management. + +## Browser Deployment Shapes + +### Same-Origin Local POC + +A daemon-served page can call the daemon directly because the page and API share +one origin. This is the preferred early POC shape for local web chat and web +terminal validation. + +### Remote Web Chat / Web Terminal + +A production remote web app should normally talk to a backend-for-frontend. The +BFF owns daemon URL, token, workspace routing, and session metadata, then +forwards browser-safe app events to the browser. This keeps bearer tokens out of +browser storage and lets the deployment decide which daemon/workspace a user is +allowed to reach. + +### Local Browser Against Local Daemon + +A separate local dev server is cross-origin from `qwen serve`; it must either +proxy daemon routes through the same origin or be served by the daemon. The +daemon intentionally rejects arbitrary browser `Origin` requests. + +## Rendering Responsibilities + +The shared transcript model is semantic, not visual. UI clients decide how to +render: + +- user and assistant message blocks +- collapsed thought blocks +- tool status cards +- shell output blocks +- permission request controls +- status/error/debug blocks + +The web terminal is a browser-native semantic renderer. It should look and feel +terminal-like with monospace layout, scrollback, prompt input, shortcuts, and +streaming blocks, but it is not a raw PTY proxy and does not require server-side +Ink rendering. + +## Merge Safety + +- The native `qwen` TUI remains direct and unchanged. +- `--acp`, channel, and IDE paths remain unchanged by default. +- The SDK UI core is additive. +- The WebUI React binding is optional and only runs in clients that import it. +- Removed daemon TUI spike code should not be treated as a product migration. + +## Follow-Ups + +- Add a daemon-served local `/web` POC or equivalent same-origin web app. +- Build first-class chat and terminal renderers on top of transcript blocks. +- Add richer typed events only where existing daemon events are too low-level + for stable browser UI behavior. +- Consider a dedicated `@qwen-code/daemon-ui-core` package if non-SDK consumers + need the UI core as an independent dependency. diff --git a/docs/developers/daemon-ui/MIGRATION.md b/docs/developers/daemon-ui/MIGRATION.md new file mode 100644 index 0000000000..210b530b51 --- /dev/null +++ b/docs/developers/daemon-ui/MIGRATION.md @@ -0,0 +1,337 @@ +# Migrating to `@qwen-code/sdk/daemon` v2 + +PR #4328 shipped the v1 daemon UI layer. PR #4353 (this PR) ships v2 with +seven additive feature commits. This guide walks through the changes for web +chat and web terminal adapter authors first. Native local TUI, channel, and IDE +maintainers can reuse the same primitives later, but those default product paths +are not migrated by this PR. + +## TL;DR for existing consumers + +**No breaking changes.** Every commit in this PR is additive: + +- v1 fields still work (`createdAt` preserved as `@deprecated` alias for + `clientReceivedAt`) +- v1 normalizer still maps the same 13 event types the same way +- v1 reducer still produces the same blocks for chat events +- New API is opt-in via additional parameters and helpers + +The PR is safe to merge without any consumer changes. **Adoption of the +new features is incremental.** + +## Recommended adoption order + +For each adapter, in order of effort/value ratio: + +### 1. Ordering: switch sort key from `createdAt` to `eventId` + +**Before:** + +```ts +const ordered = [...state.blocks].sort((a, b) => a.createdAt - b.createdAt); +``` + +**After:** + +```ts +import { selectTranscriptBlocksOrderedByEventId } from '@qwen-code/sdk/daemon'; +const ordered = selectTranscriptBlocksOrderedByEventId(state); +``` + +**Why**: `eventId` is daemon-monotonic; survives SSE replay-after-reconnect. +`createdAt` is client clock and shifts under replay. + +### 2. Display: switch `createdAt` to `serverTimestamp ?? clientReceivedAt` + +**Before:** + +```tsx + +``` + +**After:** + +```tsx +import { formatBlockTimestamp } from '@qwen-code/sdk/daemon'; +; +``` + +**Why**: Multiple clients see consistent "X minutes ago" only when both +read daemon clock. Renderer plus `formatBlockTimestamp` handles tz + +locale. + +**Note**: Daemon needs to stamp `_meta.serverTimestamp` on envelopes for +this to take effect. SDK forward-compat-ready; falls back to +`clientReceivedAt` until then. + +### 3. Listen for new event types — pick subset to render + +The 16 new event types (session-meta, workspace, auth) don't push transcript +blocks. They are sidechannel observations. Each adapter picks which to surface: + +```ts +// In your SSE consumer +const uiEvents = normalizeDaemonEvent(envelope, { + clientId, + suppressOwnUserEcho: true, +}); +store.dispatch(uiEvents); + +// Then in your UI side +for (const event of uiEvents) { + switch (event.type) { + case 'session.approval_mode.changed': + myApprovalModeBadge.update(event.next); + break; + case 'workspace.mcp.budget_warning': + myToast.show( + `MCP servers approaching budget: ${event.liveCount}/${event.budget}`, + ); + break; + case 'auth.device_flow.started': + myAuthModal.show({ + deviceFlowId: event.deviceFlowId, + providerId: event.providerId, + expiresAt: event.expiresAt, + }); + break; + // ... etc, opt into what your UI needs + } +} +``` + +Or use selectors for state-mirrored sidechannels: + +```ts +import { selectApprovalMode, selectCurrentTool } from '@qwen-code/sdk/daemon'; + +const mode = selectApprovalMode(state); // mirrored from approval_mode.changed +const currentTool = selectCurrentTool(state); // current in-flight tool +``` + +### 4. Render contract: use `daemonBlockToMarkdown` (or HTML / plainText) + +**Before** (each adapter does its own projection): + +```ts +function blockToString(block: DaemonTranscriptBlock): string { + switch (block.kind) { + case 'user': + return `You: ${block.text}`; + case 'assistant': + return block.text; + case 'tool': + return `[${block.title}]\n${block.status}`; + // ... etc + } +} +``` + +**After** (delegate to SDK): + +```ts +import { daemonBlockToMarkdown } from '@qwen-code/sdk/daemon'; +const md = daemonBlockToMarkdown(block); +``` + +For HTML SSR: + +```ts +import MarkdownIt from 'markdown-it'; +import DOMPurify from 'dompurify'; +const html = DOMPurify.sanitize(md.render(daemonBlockToMarkdown(block))); +``` + +For plain text: + +```ts +import { daemonBlockToPlainText } from '@qwen-code/sdk/daemon'; +const plain = daemonBlockToPlainText(block); +``` + +### 5. Conformance test + +Add to your adapter's test suite: + +```ts +import { runAdapterConformanceSuite } from '@qwen-code/sdk/daemon'; + +it('adapter projects daemon UI corpus correctly', () => { + const result = runAdapterConformanceSuite({ + reduce: (events) => myReduce(events), + renderToText: (state) => myRender(state), + }); + expect(result.failed).toEqual([]); +}); +``` + +This will run your adapter against 10 fixture scenarios and surface any +projection drift before it reaches users. + +### 6. Tool icon dispatch via `provenance` + +**Before** (string match on toolName): + +```tsx +const isMcp = toolName?.startsWith('mcp__'); +const isBuiltin = ['Bash', 'Edit', 'Read'].includes(toolName); +``` + +**After** (typed provenance from PR-A): + +```tsx +import type { DaemonUiToolUpdateEvent } from '@qwen-code/sdk/daemon'; + +function toolIcon(event: DaemonUiToolUpdateEvent): React.ReactNode { + switch (event.provenance) { + case 'mcp': + return ; + case 'subagent': + return ; + case 'builtin': + return ; + case 'unknown': + default: + return ; + } +} +``` + +SDK has a `mcp____` naming heuristic fallback — works today +even when daemon doesn't explicitly stamp provenance. + +### 7. Error categorization via `errorKind` + +**Before** (regex on text): + +```ts +if (error.text.includes('auth')) showAuthRetry(); +else if (error.text.includes('file not found')) showFilePicker(); +``` + +**After** (closed enum from PR-A): + +```ts +import type { DaemonErrorKind } from '@qwen-code/sdk/daemon'; + +function errorAction(errorKind?: DaemonErrorKind): React.ReactNode { + switch (errorKind) { + case 'auth_env_error': return ; + case 'missing_file': return ; + case 'blocked_egress': return ; + case 'init_timeout': return ; + default: return null; + } +} +``` + +**Note**: Daemon needs to stamp `data.errorKind` on session_died / +stream_error for this to populate. SDK already reads it. + +### 8. Cancellation handling — already automatic + +In v1, cancelled prompts left in-flight tool blocks spinning forever. +In v2 (PR-E), `propagateCancellationToInFlightTools` runs automatically +on `assistant.done.reason === 'cancelled'`. Sub-agent children are +cancelled together with their parent. + +**No adapter changes needed** — your spinners will resolve correctly. + +### 8a. Sub-agent nesting — opt in to nested rendering (PR-K) + +Tool blocks invoked inside a sub-agent delegation now carry +`parentToolCallId`, `subagentType`, and (when the parent is in state) +`parentBlockId`. Adapters can opt in to nested rendering: + +**Before** (flat list, sub-agent calls visually indistinguishable from +top-level): + +```tsx +state.blocks.map((b) => ); +``` + +**After** (recursive nested rendering): + +```tsx +import { + selectSubagentChildBlocks, + isSubagentChildBlock, +} from '@qwen-code/sdk/daemon'; + +function renderTool(block) { + const children = selectSubagentChildBlocks(state, block.toolCallId); + return ( + + {block.subagentType && } + {children.length > 0 && {children.map(renderTool)}} + + ); +} + +const topLevel = state.blocks.filter((b) => !isSubagentChildBlock(b)); +return topLevel.map(renderTool); +``` + +**No adapter changes needed if you prefer the flat view** — the new +fields are additive and ignored by code that doesn't read them. + +### 9. Tool preview taxonomy — pick subset to render with custom components + +PR-D + PR-F bring 13 preview kinds: + +- 4 file-shaped: `file_diff`, `file_read`, `web_fetch`, `mcp_invocation` +- 5 content-shaped: `code_block`, `search`, `tabular`, `image_generation`, `subagent_delegation` +- 2 control: `ask_user_question`, `command` +- 2 generic: `key_value`, `generic` + +Each adapter dispatches on `preview.kind`: + +```tsx +function ToolPreviewComponent({ preview }: { preview: DaemonToolPreview }) { + switch (preview.kind) { + case 'file_diff': + return ( + + ); + case 'mcp_invocation': + return ( + + ); + case 'tabular': + return ; + case 'image_generation': + return ( + + ); + // ... or fall back to: + default: + return ; + } +} +``` + +Adapters without custom components for all 13 kinds can fall back to the +SDK's `daemonToolPreviewToMarkdown` for any unhandled kind. + +## Backward-compat checklist + +| Concern | Status | +| ------------------------------------------------------ | --------------------------------------------- | +| Existing `block.createdAt` reads | ✅ still works (alias for `clientReceivedAt`) | +| Existing reducer event handling | ✅ unchanged for v1 event types | +| `daemonTranscriptToUnifiedMessages(blocks)` call sites | ✅ new options param is optional | +| Existing `selectTranscriptBlocks` consumers | ✅ unchanged | +| New event types in v1 reducer | ✅ no-op, `lastEventId` still advances | + +## Cross-references + +- [PR #4353 SUMMARY](https://github.com/QwenLM/qwen-code/pull/4353) +- [Daemon UI README](./README.md) — full API reference +- [PR #4328](https://github.com/QwenLM/qwen-code/pull/4328) — base PR with shared UI transcript layer diff --git a/docs/developers/daemon-ui/README.md b/docs/developers/daemon-ui/README.md new file mode 100644 index 0000000000..808f96a26b --- /dev/null +++ b/docs/developers/daemon-ui/README.md @@ -0,0 +1,391 @@ +# Daemon UI SDK — Developer Guide + +The `@qwen-code/sdk/daemon` subpath ships shared UI primitives for daemon +clients. The current adoption target is web chat and web terminal; native local +TUI, channel, and IDE integrations keep their existing default paths while the +daemon UI contract stabilizes. This guide covers the API surface introduced by +PR #4353 (the unified follow-up to PR #4328's shared UI transcript layer). + +## Three-layer model + +``` +Daemon SSE wire (NDJSON envelopes) + │ + ▼ +normalizeDaemonEvent(envelope) → DaemonUiEvent[] + │ + ▼ +reduceDaemonTranscriptEvents(state, events) → DaemonTranscriptState + │ { blocks, currentToolCallId, + │ approvalMode, toolProgress, ... } + ▼ +daemonBlockToMarkdown(block) / ToHtml / ToPlainText ← your renderer plugs here +``` + +- **Normalizer**: takes raw daemon SSE envelopes, returns typed UI events +- **Reducer**: accumulates events into a transcript state machine +- **Render helpers**: project state blocks to renderable strings + +## Quick start + +```ts +import { + DaemonSessionClient, + createDaemonTranscriptStore, + normalizeDaemonEvent, + daemonBlockToMarkdown, + selectCurrentTool, + selectApprovalMode, +} from '@qwen-code/sdk/daemon'; + +const session = await DaemonSessionClient.createOrAttach(client, { + workspaceCwd, +}); +const store = createDaemonTranscriptStore(); + +for await (const envelope of session.events({ signal })) { + const events = normalizeDaemonEvent(envelope, { + clientId: session.clientId, + suppressOwnUserEcho: true, + }); + store.dispatch(events); +} + +// Read state from any subscriber +store.subscribe(() => { + const state = store.getSnapshot(); + const currentTool = selectCurrentTool(state); + const mode = selectApprovalMode(state); + const markdown = state.blocks.map(daemonBlockToMarkdown).join('\n\n'); + myRenderer.render({ markdown, currentTool, mode }); +}); +``` + +## Event taxonomy (28+ types) + +`DaemonUiEvent` is a discriminated union of all UI-facing events: + +### Chat-stream events + +| Event | When | +| ---------------------------- | ----------------------------------------------------- | +| `user.text.delta` | User message chunk arrives from daemon | +| `assistant.text.delta` | Assistant streaming chunk | +| `assistant.done` | Prompt completion (from sendPrompt resolve) | +| `thought.text.delta` | Agent reasoning chunk | +| `tool.update` | Tool call lifecycle (running / completed / cancelled) | +| `shell.output` | Shell tool stdout/stderr chunk | +| `permission.request` | Tool needs user authorization | +| `permission.resolved` | Permission decision arrived | +| `model.changed` | Session model switched | +| `status` / `debug` / `error` | Status / debug / error blocks | + +### Session-meta events (PR-A) + +| Event | When | +| ------------------------------- | ------------------------------------------------ | +| `session.metadata.changed` | Session title / display name updated | +| `session.approval_mode.changed` | Mode toggled (plan / default / yolo / auto-edit) | +| `session.available_commands` | Slash command list refreshed | + +### Workspace events (PR-A, Wave 3-4) + +| Event | When | +| -------------------------------------- | ------------------------------------- | +| `workspace.memory.changed` | QWEN.md / memory file modified | +| `workspace.agent.changed` | Sub-agent created / updated / deleted | +| `workspace.tool.toggled` | Builtin tool enabled / disabled | +| `workspace.initialized` | `qwen init` completed | +| `workspace.mcp.budget_warning` | MCP child count approaching cap | +| `workspace.mcp.child_refused` | MCP server refused due to budget | +| `workspace.mcp.server_restarted` | Manual MCP restart succeeded | +| `workspace.mcp.server_restart_refused` | Manual restart blocked | + +### Auth device-flow events (PR-A, Wave 4 OAuth) + +`auth.device_flow.{started,throttled,authorized,failed,cancelled}` + +Each carries the daemon's `deviceFlowId`. Failed events carry a closed-enum +`errorKind` (closed enum — see `KNOWN_DEVICE_FLOW_ERROR_KINDS` exported from `@qwen-code/sdk/daemon` for the canonical list, currently: `expired_token` / `access_denied` / `invalid_grant` / `upstream_error` / `persist_failed` / `not_found_or_evicted`). + +## Render contract (PR-D) + +Three projection helpers, one preview helper. All discriminate on `block.kind` +or `preview.kind`: + +```ts +daemonBlockToMarkdown(block, { sanitizeUrls?, maxFieldLength?, locale? }) +daemonBlockToHtml(block, { sanitizer?, ...renderOpts }) +daemonBlockToPlainText(block, renderOpts) +daemonToolPreviewToMarkdown(preview, renderOpts) +``` + +### Cookbook: render a transcript to markdown + +```ts +const markdown = state.blocks + .map((b) => daemonBlockToMarkdown(b, { sanitizeUrls: true })) + .join('\n\n'); +``` + +### Cookbook: render to sanitized HTML for SSR + +```ts +import DOMPurify from 'dompurify'; +import MarkdownIt from 'markdown-it'; +const md = new MarkdownIt(); + +const html = state.blocks + .map((b) => { + // Two-stage pipeline: markdown → HTML → DOMPurify + const rawHtml = md.render(daemonBlockToMarkdown(b)); + return DOMPurify.sanitize(rawHtml); + }) + .join('\n'); +``` + +Or use the built-in conservative HTML renderer (no markdown parsing, just +HTML escape): + +```ts +const html = state.blocks + .map((b) => daemonBlockToHtml(b, { sanitizer: DOMPurify.sanitize })) + .join('\n'); +``` + +### Cookbook: copy-paste plain text + +```ts +const plain = state.blocks.map(daemonBlockToPlainText).join('\n'); +navigator.clipboard.writeText(plain); +``` + +## Tool preview taxonomy (13 kinds) + +| Kind | Surface | +| --------------------- | ------------------------------------------------- | +| `ask_user_question` | Multi-choice question with options | +| `command` | Bash-style command + cwd | +| `file_diff` | File edit with oldText/newText or patch | +| `file_read` | Path + optional line range | +| `web_fetch` | URL + HTTP method | +| `mcp_invocation` | MCP server + tool + args summary | +| `code_block` | Language-tagged code snippet | +| `search` | Query + result count + top results | +| `tabular` | Columns + rows (capped at 50, truncation flagged) | +| `image_generation` | Prompt + optional thumbnail URL | +| `subagent_delegation` | Agent name + task | +| `key_value` | Generic label/value rows | +| `generic` | Fallback summary | + +Each has a `daemonToolPreviewToMarkdown` projection. Custom renderers can +dispatch on `preview.kind` for rich per-type display (file diff with +syntax highlighting, MCP server badge, image thumbnail, etc.). + +## State selectors (PR-E) + +```ts +selectCurrentTool(state); // → DaemonToolTranscriptBlock | undefined +selectApprovalMode(state); // → 'plan' | 'default' | 'auto-edit' | 'yolo' | undefined +selectToolProgress(state, toolCallId); // → { ratio?, step? } | undefined +selectPendingPermissionBlocks(state); // → ReadonlyArray +selectTranscriptBlocks(state); // → ReadonlyArray +selectTranscriptBlocksOrderedByEventId(state); // sorted by daemon-monotonic id + +// PR-K — sub-agent nesting +selectSubagentChildBlocks(state, parentToolCallId); // direct children only +isSubagentChildBlock(block); // type guard: was this tool invoked inside a sub-agent? +``` + +`currentToolCallId` is automatically maintained by the reducer: + +- Set when a tool enters in-flight status (`running` / `in_progress` / `pending` / `confirming`) +- Cleared when tool enters terminal status (`completed` / `failed` / `cancelled` / etc.) +- Unknown statuses leave it untouched (forward-compat) + +## Cancellation propagation (PR-E) + +When `assistant.done.reason === 'cancelled'`, the reducer walks every +in-flight tool block and force-sets its status to `'cancelled'`. Daemon +does not guarantee a terminal `tool_call_update` for every in-flight +tool when the parent prompt is cancelled — this propagation prevents UI +spinners from spinning forever. + +Sub-agent children are cancelled together with their parent because +cancellation iterates every in-flight tool block in `toolBlockByCallId`, +not just the current pointer. + +## Sub-agent nesting (PR-K) + +When the main agent delegates to a sub-agent (the `Task` tool, or +equivalent), the daemon stamps `parentToolCallId` and `subagentType` on +the **child** tool calls via `tool_call._meta`. The reducer reads both +and: + +- Mirrors `parentToolCallId` + `subagentType` onto + `DaemonToolTranscriptBlock` +- Resolves `parentBlockId` (the parent's transcript block `id`) when the + parent block is already in state; otherwise leaves it `undefined` and + back-fills when the parent block later appears + +Out-of-order arrival (child before parent) is handled transparently. A +child whose parent gets trimmed by `maxBlocks` keeps `parentToolCallId` +for selector queries, but `parentBlockId` is nulled (the dangling id +would no longer resolve via `blockIndexById`). + +```ts +import { + selectSubagentChildBlocks, + isSubagentChildBlock, +} from '@qwen-code/sdk/daemon'; + +// Render a parent tool block, then walk children: +function renderToolBlock(state, block) { + if (block.kind !== 'tool') return renderOther(block); + const children = selectSubagentChildBlocks(state, block.toolCallId); + return ( + + {children.length > 0 && ( + + {children.map((c) => renderToolBlock(state, c))} + + )} + + ); +} + +// Or filter top-level vs. nested at render time: +const topLevel = state.blocks.filter((b) => !isSubagentChildBlock(b)); +``` + +`selectSubagentChildBlocks` returns **direct** children only. Walk +recursively to render nested sub-agents (a sub-agent inside a +sub-agent). Daemon does not emit cycles, but renderers walking up via +`parentBlockId` should still detect them defensively (e.g., depth cap or +visited set). + +Self-references (`parentToolCallId === toolCallId`) are dropped by the +normalizer before reaching the reducer. + +## Time semantics (PR-B) + +```ts +interface DaemonTranscriptBlockBase { + eventId?: number; // PRIMARY sort key — daemon-monotonic + serverTimestamp?: number; // PREFERRED display — daemon-authoritative + clientReceivedAt: number; // FALLBACK — local clock + createdAt: number; // @deprecated alias for clientReceivedAt +} +``` + +**Always sort by `eventId`** (use `selectTranscriptBlocksOrderedByEventId`) +when displaying long sessions. The daemon-monotonic cursor is preserved +across SSE replay-after-reconnect; client clocks are not. + +**Always format display timestamps from `serverTimestamp`** (with +fallback to `clientReceivedAt`). Multiple clients viewing the same session +see the same "5 minutes ago" only when both read from the daemon clock. + +```ts +import { formatBlockTimestamp } from '@qwen-code/sdk/daemon'; + +const label = formatBlockTimestamp(block, { + locale: 'zh-CN', + timeZone: 'Asia/Shanghai', + timeStyle: 'short', +}); +``` + +## Adapter conformance (PR-G) + +Validate your adapter projects the SDK's reference corpus to semantically +equivalent output: + +```ts +import { runAdapterConformanceSuite } from '@qwen-code/sdk/daemon'; + +it('my adapter conforms to daemon UI corpus', () => { + const result = runAdapterConformanceSuite({ + reduce: (events) => myReducer(events), + renderToText: (state) => myRenderer(state), + }); + expect(result.failed).toEqual([]); +}); +``` + +The fixture corpus (`DAEMON_UI_CONFORMANCE_FIXTURES`) covers chat, tool +lifecycle, file edits, MCP, permissions, MCP budget warning, cancellation, +malformed payload redaction, OAuth, command updates, and sub-agent +nesting. (Count is derivable at runtime — read +`DAEMON_UI_CONFORMANCE_FIXTURES.length`.) + +**Format-agnostic** — your adapter can render to ANSI / HTML / markdown / +JSX; the framework only checks semantic content via `expectedContains` and +`expectedAbsent`. + +## Error categorization (PR-A) + +`DaemonUiErrorEvent.errorKind` is a closed-enum propagated from the +daemon's typed-error taxonomy (when the daemon stamps it): + +```ts +import type { DaemonErrorKind } from '@qwen-code/sdk/daemon'; +// 'missing_binary' | 'blocked_egress' | 'auth_env_error' | 'init_timeout' +// | 'protocol_error' | 'missing_file' | 'parse_error' | 'budget_exhausted' +``` + +Renderers should branch on `errorKind` for actionable affordances: + +```ts +function errorAffordance(errorKind?: DaemonErrorKind): React.ReactNode { + switch (errorKind) { + case 'auth_env_error': return ; + case 'missing_file': return ; + case 'blocked_egress': return Network blocked — check proxy; + default: return null; + } +} +``` + +## Tool provenance dispatch (PR-A) + +`DaemonUiToolUpdateEvent.provenance` is a closed-enum (`builtin` / `mcp` / +`subagent` / `unknown`). With `serverId?: string` when `mcp`. Use it for +icon dispatch and badging: + +```ts +function toolIcon(event: DaemonUiToolUpdateEvent): React.ReactNode { + switch (event.provenance) { + case 'mcp': return ; + case 'subagent': return ; + case 'builtin': return ; + default: return ; + } +} +``` + +The SDK has a `mcp____` naming heuristic fallback — even +when daemon doesn't explicitly stamp provenance, MCP tools are detectable. + +## Forward-compat principles + +Every layer in the daemon UI SDK follows the **forward-compat principle**: +unknown values do NOT throw; they degrade gracefully. + +- Unknown daemon event types → `debug` event with the raw type name +- Unknown tool status → `currentToolCallId` left untouched (no clear) +- Unknown error kind → `errorKind` undefined (renderer falls back to text) +- Missing serverTimestamp → falls back to `clientReceivedAt` +- Unrecognized preview shape → `generic` kind with `summary` + +This means **SDK can ship ahead of daemon emission**. PR-A's tool +provenance heuristic, PR-B's three-location timestamp extraction, and +PR-E's unknown-status preservation are all examples of "ready when daemon +sends; safe when it doesn't." + +## Cross-references + +- [PR #4328](https://github.com/QwenLM/qwen-code/pull/4328) — base PR with the shared UI transcript layer +- [PR #4353](https://github.com/QwenLM/qwen-code/pull/4353) — this PR (unified completeness follow-up) +- [Issue #3803](https://github.com/QwenLM/qwen-code/issues/3803) — daemon mode proposal +- [Issue #4175](https://github.com/QwenLM/qwen-code/issues/4175) — Mode B v0.16 implementation tracker diff --git a/docs/developers/examples/daemon-client-quickstart.md b/docs/developers/examples/daemon-client-quickstart.md index 733a9fad78..4d22986437 100644 --- a/docs/developers/examples/daemon-client-quickstart.md +++ b/docs/developers/examples/daemon-client-quickstart.md @@ -27,7 +27,14 @@ import { DaemonClient, type DaemonEvent } from '@qwen-code/sdk'; const client = new DaemonClient({ baseUrl: 'http://127.0.0.1:4170', - // token: process.env.QWEN_SERVER_TOKEN, // required for non-loopback binds + // PR 27 (v0.16-alpha): when `token` is omitted, DaemonClient falls + // back to `process.env.QWEN_SERVER_TOKEN` automatically — same env + // var the daemon's `--token` CLI flag falls back to. So either: + // export QWEN_SERVER_TOKEN="$(openssl rand -hex 32)" # one-shot + // export QWEN_SERVER_TOKEN="$(cat ./my-token-file)" # user-managed file + // const client = new DaemonClient({ baseUrl: '...' }); + // OR pass it explicitly when you have a different env-var name: + // token: process.env.MY_TOKEN, }); // 1. Confirm we can reach the daemon, gate UI on its features, and @@ -233,6 +240,15 @@ const client = new DaemonClient({ }); ``` +**SDK env fallback (PR 27, v0.16-alpha)** — `DaemonClient` reads `QWEN_SERVER_TOKEN` from the environment automatically when `token` is omitted, mirroring the daemon's own `--token` CLI fallback. So if your shell has `export QWEN_SERVER_TOKEN=...`, this is equivalent to the above: + +```ts +// Same effect as token: process.env.QWEN_SERVER_TOKEN, but without the boilerplate. +const client = new DaemonClient({ baseUrl: 'https://your-host:4170' }); +``` + +The fallback strips leading/trailing whitespace (handy for `export QWEN_SERVER_TOKEN="$(cat token.txt)"` where `cat` adds a newline) and treats empty / whitespace-only values as unset (a stale `export QWEN_SERVER_TOKEN=""` won't accidentally send `Authorization: Bearer ` with no token). The fallback runs once at construction; later `process.env` mutations don't affect already-built clients. Browser bundles (e.g. via `@qwen-code/webui`) get `undefined` cleanly because `globalThis.process` doesn't exist there. + Wrong / missing tokens return `401` with a uniform body — the SDK throws `DaemonHttpError` on any 4xx/5xx from a route handler. ```ts diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index f6d6315827..4e640ef549 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -18,6 +18,32 @@ Without a configured token (loopback dev default) the header is optional. Token When the flag is on, the global `bearerAuth` middleware gates **every** route — including `/capabilities`. An **unauthenticated** client therefore cannot pre-flight `caps.features` to discover that auth is required: the discovery surface for that case is the **401 response body** itself (uniform across all routes per the [Authentication](#authentication) section). The `require_auth` capability tag is a **post-authentication confirmation** — once a client successfully authenticates and reads `/capabilities`, the tag's presence confirms the daemon was started with `--require-auth` (useful for audit / compliance UIs and for SDK clients to surface "this deployment is hardened" in a settings panel). Mutation routes that opt into per-route strict mode (Wave 4 follow-ups) refuse with `401 { code: "token_required", error: "…" }` when reached on a no-token loopback default — but with `--require-auth` enabled the global bearer middleware short-circuits the request before the per-route gate, so the legacy `Unauthorized` body is what unauthenticated callers actually see. +**`--allow-origin ` (T2.4 [#4514](https://github.com/QwenLM/qwen-code/issues/4514)).** Browser webuis hitting the daemon cross-origin are blocked by default — any request carrying an `Origin` header returns `403 {"error":"Request denied by CORS policy"}` because CLI/SDK clients never send `Origin` and the daemon treats its presence as a sign the request came from a browser context the operator has not opted into. Pass `--allow-origin ` (repeatable) at boot to install an allowlist instead of the wall. Each pattern is either: + +- The literal `*` — admit any origin. **Risky**: boot refuses when `*` is configured but no bearer token is set (any source: `--token`, `QWEN_SERVER_TOKEN`, or `--require-auth` which mandates a token at boot). The boot breadcrumb emits a stderr warning when `*` is in the list. **Recommendation**: pair with `--require-auth` on loopback binds so `/health` and `/demo` are also gated by the bearer — they're registered before the bearer middleware on loopback by default (so k8s/Compose probes can reach `/health` without a token), and a `*` allowlist makes them reachable from any cross-origin browser. On non-loopback binds the bearer is already mandatory at boot, so the `*` exposure surface is just `/health` (status JSON) and `/demo` (a static page whose JS still calls token-gated routes) — the actual API surface is gated regardless. +- A canonical URL origin — `://[:]`. **No trailing slash, no path, no userinfo, no query.** Boot refuses with `InvalidAllowOriginPatternError` if the entry fails the round-trip `new URL(pattern).origin === pattern`; the error message names the bad pattern and the canonical form. Strict-by-intent: silent normalization (e.g. trimming a trailing `/`) would let typos slip through and accept ambiguous input. + +Matched origins receive the standard CORS response headers on every request: + +``` +Access-Control-Allow-Origin: +Vary: Origin +Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS +Access-Control-Allow-Headers: Authorization, Content-Type, X-Qwen-Client-Id, Last-Event-ID +Access-Control-Max-Age: 86400 +Access-Control-Expose-Headers: Retry-After +``` + +`Access-Control-Allow-Origin` echoes the request's origin verbatim (lowercase / uppercase as the browser sent it) rather than the literal `*`, even under the `*` pattern — browser caches key responses on it paired with `Vary: Origin`, and echoing leaves room to add `Access-Control-Allow-Credentials` in a later release without a schema change. `Access-Control-Expose-Headers: Retry-After` lets browser webuis honor daemon retry hints from `429` / `503` responses. `Access-Control-Allow-Credentials` is **NOT** sent today: the daemon authenticates via bearer-in-`Authorization`, which works cross-origin without `credentials: 'include'`. + +OPTIONS preflight requests (OPTIONS with `Access-Control-Request-Method` or `Access-Control-Request-Headers`) short-circuit with `204 No Content` plus the headers above. This is the conventional CORS pattern and is safe — the preflight only confirms which methods/headers the daemon will accept; the actual subsequent request still runs the full chain (host allowlist → bearer auth → routes), so anti-DNS-rebinding and bearer enforcement still fire before any state is read or mutated. Plain OPTIONS requests from matched origins keep flowing downstream with CORS headers attached. + +Origins that don't match the allowlist still get `403 {"error":"Request denied by CORS policy"}` — same envelope as the default wall, so clients that already parsed the wall's response don't have to special-case allowlist-deployed daemons. The reject path **does not** emit any `Access-Control-*` headers (the browser would ignore them, and emitting would indirectly advertise the allowlist size through header presence). + +The configured pattern list is intentionally NOT echoed in `/capabilities` — browser webui already knows its own origin (it called the daemon, after all), and surfacing the list would let an unauthenticated reader of `/capabilities` enumerate every trusted origin (useful recon for a misconfigured deployment). SDK clients gate on the `caps.features.allow_origin` tag for "this daemon honors cross-origin browser hits" without needing to know which specific origins. + +Loopback self-origin requests (e.g. the `/demo` page calling the daemon at the same `127.0.0.1:port`) are handled by a **separate** Origin-strip shim that runs BEFORE the CORS middleware and removes the `Origin` header for `127.0.0.1:port` / `localhost:port` / `[::1]:port` / `host.docker.internal:port`. So they pass through regardless of `--allow-origin` configuration — operators don't need to list the daemon's own port to make the demo page work. + ## Common error shape 5xx responses carry the original error's `code` and `data` when present (JSON-RPC style — the ACP SDK forwards `{code, message, data}` from the agent): @@ -99,14 +125,22 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'session_set_model', 'client_identity', 'client_heartbeat', 'session_permission_vote', 'permission_vote', 'workspace_mcp', 'workspace_skills', 'workspace_providers', 'workspace_env', 'workspace_preflight', - 'session_context', 'session_supported_commands', + 'session_context', 'session_supported_commands', 'session_tasks', 'session_close', 'session_metadata', 'mcp_guardrails', 'mcp_guardrail_events', 'workspace_file_read', 'workspace_file_bytes', 'workspace_file_write', 'session_approval_mode_control', 'workspace_tool_toggle', - 'workspace_init', 'workspace_mcp_restart'] + 'workspace_init', 'workspace_mcp_restart', + 'auth_device_flow', 'permission_mediation'] ``` +> The conditional `require_auth` tag (PR 15) appears only when the daemon +> is started with `--require-auth`. F3's `permission_mediation` tag is +> always-on and carries `modes: ['first-responder', 'designated', +'consensus', 'local-only']` so SDK clients can introspect the +> build-supported set; the runtime-active strategy is at +> `body.policy.permission`. + `session_scope_override` is the negotiation handle for the per-request `sessionScope` field on `POST /session` (see below). Older daemons silently ignore the field, so SDK clients should pre-flight `caps.features` for this tag before sending it. `session_load` and `unstable_session_resume` advertise the explicit-restore routes (`POST /session/:id/load` and `POST /session/:id/resume`). Older daemons return `404` for these paths, so SDK clients should pre-flight `caps.features` before calling. The `unstable_` prefix on `unstable_session_resume` mirrors the underlying ACP method (`connection.unstable_resumeSession`) — the daemon's wire shape is committed for v1, but the ACP method name itself may change before ACP marks resume stable. @@ -136,9 +170,10 @@ routes and require a configured bearer token even on loopback. **Conditional tags.** A small number of feature tags are advertised only when the matching deployment toggle is on. Tag presence = behavior is on; absence = either an older daemon predating the tag, OR a current daemon where the operator did not opt in. Currently: -| Tag | Advertised when … | -| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every route, including `/health` on loopback binds. | +| Tag | Advertised when … | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every route, including `/health` on loopback binds. | +| `allow_origin` | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). The daemon was started with at least one `--allow-origin ` (or `allowOrigins: [...]` via the embedded API). Cross-origin requests from matched origins receive proper CORS response headers; unmatched origins still get the default 403. The configured pattern list is intentionally NOT echoed in `/capabilities` to avoid leaking the trusted-origin set to unauthenticated readers — browser webui already knows its own origin. | `mcp_guardrails` is **not** in this conditional table — it's an always-on tag, advertised whenever the binary supports the new `/workspace/mcp` budget fields, regardless of whether the operator configured a budget. Operators who haven't set `--mcp-client-budget` still get the new fields (with `budgetMode: 'off'`, `budgets: []`). @@ -208,6 +243,7 @@ Capability tags: - `workspace_preflight` → `GET /workspace/preflight` - `session_context` → `GET /session/:id/context` - `session_supported_commands` → `GET /session/:id/supported-commands` +- `session_tasks` → `GET /session/:id/tasks` Common status cell: @@ -838,6 +874,36 @@ caller named the path. Success responses and audit events include `available_commands_update` SSE notification. `availableSkills` lists skill names only; clients must not expect skill bodies or paths over this route. +### `GET /session/:id/tasks` + +```json +{ + "v": 1, + "sessionId": "", + "now": 1700000000000, + "tasks": [ + { + "kind": "agent", + "id": "agent-1", + "label": "reviewer: check failure", + "description": "check failure", + "status": "running", + "startTime": 1699999999000, + "runtimeMs": 1000, + "outputFile": "/tmp/agent-1.jsonl", + "isBackgrounded": true, + "subagentType": "reviewer" + } + ] +} +``` + +This route is a read-only out-of-band snapshot. It is intentionally not a +prompt and can be queried while the session is streaming. The response only +contains whitelisted metadata from the agent, shell, and monitor task +registries; controllers, timers, offsets, pending messages, and raw registry +objects are never exposed. + ### `POST /session` Spawn a new agent or attach to an existing one (under `sessionScope: 'single'`, the default). @@ -1099,6 +1165,36 @@ Response: On success, publishes `model_switched` to the SSE stream. On failure, publishes `model_switch_failed` (so passive subscribers see the failure, not just the caller). Races against the agent channel exit so a wedged child can't block the HTTP handler. +### `POST /session/:id/recap` + +Capability tag: `session_recap`. Bridge → ACP extMethod `qwen/control/session/recap`. + +Generate a one-sentence "where did I leave off" summary of the session. Wraps core's `generateSessionRecap` (`packages/core/src/services/sessionRecap.ts`), which runs a side-query against the fast model with tools disabled, `maxOutputTokens: 300`, and a strict `...` output format. The side-query reads the session's existing GeminiClient chat history and does **not** add to it. + +Request body is ignored (send `{}` or empty). Non-strict mutation gate — posture mirrors `/session/:id/prompt` (the call costs tokens but mutates no state). No SSE event is published. + +Response (200): + +```json +{ + "sessionId": "sess:42", + "recap": "Debugging the auth retry race. Next: add deterministic timing to the integration test." +} +``` + +`recap` is `null` (a normal 200, not an error) when: + +- the session has fewer than two dialog turns yet, +- the side-query returned no extractable `...` payload, +- or any underlying model error occurred (the core helper is best-effort and never throws). + +Errors: + +- `400 {code: 'invalid_client_id'}` — malformed `X-Qwen-Client-Id` header. +- `404` — session unknown. + +Cancellation: **none in v1**. The route does not listen for HTTP client disconnect, no `AbortSignal` is plumbed into the bridge, and the ACP child runs the side-query to completion regardless of whether the caller has disconnected. The only ceilings are the bridge's 60s backstop timeout (`SESSION_RECAP_TIMEOUT_MS`) and the transport-closed race against ACP channel death. This is acceptable because recap is short (single-attempt, `maxOutputTokens: 300`, ~1–5s typical); a request-id-based cancel ext-method can plumb full end-to-end cancellation in a future release if the bandwidth cost ever justifies it. + ### Mutation: approval, tools, init, MCP restart Issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) Wave 4 PR 17 adds four mutation control routes that let remote clients change runtime posture without touching the daemon host's CLI. All four: @@ -1278,17 +1374,19 @@ data: {"v":1,"type":"client_evicted","data":{"reason":"queue_overflow","droppedA The SSE-level `id:` / `event:` lines duplicate `envelope.id` / `envelope.type` for EventSource compatibility. Raw-`fetch` consumers (the SDK's `parseSseStream`) read everything off the JSON envelope and ignore the SSE preamble lines. -| Event type | Trigger | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `session_update` | Any ACP `sessionUpdate` notification (LLM chunks, tool calls, usage) | -| `permission_request` | Agent asked for tool approval | -| `permission_resolved` | Some client voted on a permission via `POST /permission/:requestId` | -| `model_switched` | `POST /session/:id/model` succeeded | -| `model_switch_failed` | `POST /session/:id/model` rejected | -| `session_died` | Agent child crashed unexpectedly. **Terminal: SSE stream closes after this frame; the session is gone from `byId`.** Subscribers should reconnect via `POST /session` to spawn a fresh one. | -| `slow_client_warning` | Subscriber-local: queue ≥ 75% full. **Non-terminal** — the stream continues; the warning is a heads-up before eviction. Carries `{queueSize, maxQueued, lastEventId}`. Fires ONCE per overflow episode; re-arms after the queue drains below 37.5%. No `id` (synthetic). Pre-flight `caps.features.slow_client_warning`. | -| `client_evicted` | Subscriber-local: queue overflow. **Terminal: SSE stream closes after this frame** (no `id` — synthetic). Other subscribers on the same session continue. | -| `stream_error` | Daemon-side error during fan-out. **Terminal: SSE stream closes after this frame** (no `id` — synthetic). | +| Event type | Trigger | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `session_update` | Any ACP `sessionUpdate` notification (LLM chunks, tool calls, usage) | +| `permission_request` | Agent asked for tool approval | +| `permission_resolved` | Some client voted on a permission via `POST /permission/:requestId` | +| `permission_partial_vote` | (consensus only) A vote was recorded but quorum not yet reached. Carries `{requestId, sessionId, votesReceived, votesNeeded, quorum, optionTallies}`. Pre-flight `caps.features.permission_mediation`. | +| `permission_forbidden` | A vote was rejected by the active policy (`designated` mismatch, `local-only` non-loopback, or `consensus` voter not in snapshot). Carries `{requestId, sessionId, clientId?, reason}`. Pre-flight `caps.features.permission_mediation`. | +| `model_switched` | `POST /session/:id/model` succeeded | +| `model_switch_failed` | `POST /session/:id/model` rejected | +| `session_died` | Agent child crashed unexpectedly. **Terminal: SSE stream closes after this frame; the session is gone from `byId`.** Subscribers should reconnect via `POST /session` to spawn a fresh one. | +| `slow_client_warning` | Subscriber-local: queue ≥ 75% full. **Non-terminal** — the stream continues; the warning is a heads-up before eviction. Carries `{queueSize, maxQueued, lastEventId}`. Fires ONCE per overflow episode; re-arms after the queue drains below 37.5%. No `id` (synthetic). Pre-flight `caps.features.slow_client_warning`. | +| `client_evicted` | Subscriber-local: queue overflow. **Terminal: SSE stream closes after this frame** (no `id` — synthetic). Other subscribers on the same session continue. | +| `stream_error` | Daemon-side error during fan-out. **Terminal: SSE stream closes after this frame** (no `id` — synthetic). | Reconnect semantics: @@ -1305,20 +1403,32 @@ Backpressure: ### `POST /permission/:requestId` -Cast a vote on a pending `permission_request`. **First responder wins** — once one client answers, every other client trying to answer the same id gets `404`. +Cast a vote on a pending `permission_request`. The active **mediation policy** decides who wins: -> **Stage 1 limitation — no permission timeout.** A `permission_request` +| Policy | Behavior | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `first-responder` (default) | Any validated voter wins; later voters get `404`. Pre-F3 baseline. | +| `designated` | Only the prompt originator (`originatorClientId`) decides; non-originators get `403 permission_forbidden / designated_mismatch`. Falls back to first-responder for anonymous prompts. | +| `consensus` | N-of-M voters must agree (default `N = floor(M/2) + 1`, override via `policy.consensusQuorum`). First option to reach `N` wins. Non-resolving votes get `200` + `permission_partial_vote` SSE frames. | +| `local-only` | Only loopback voters decide; remote callers get `403 permission_forbidden / remote_not_allowed`. | + +The active policy is configured in `settings.json` under `policy.permissionStrategy` and surfaced on `/capabilities` at `body.policy.permission`. Pre-flight `caps.features.permission_mediation` (with `modes: [...]`) for the build-supported set. + +> **F3 (#4175): multi-client permission coordination.** F3 added the four policies above. Pre-F3 daemons hardcoded first-responder; the wire shape stays bit-for-bit unchanged when the configured policy is `first-responder`. New events (`permission_partial_vote`, `permission_forbidden`) are additive — old SDKs see them as `unrecognized_known_event` and gracefully ignore. + +> **Permission timeout (default 5 minutes).** A `permission_request` > stays pending until: (a) some client votes here, (b) `POST /session/:id/cancel` -> fires, (c) the HTTP client driving the prompt -> disconnects (mid-prompt cancel resolves outstanding permissions as -> `cancelled`), (d) the session is killed, or (e) the daemon shuts -> down. **In a fully-headless deployment with no SSE subscriber, -> `requestPermission` blocks the agent indefinitely** — there's nothing -> to time out the wait. Stage 2 will add a configurable -> `permissionTimeoutMs`. Until then, headless callers should keep an -> SSE subscription open or wrap their prompt loop in their own timeout -> -> - `POST /session/:id/cancel` on expiry. +> fires, (c) the HTTP client driving the prompt disconnects +> (mid-prompt cancel resolves outstanding permissions as `cancelled`), +> (d) the session is killed, (e) the daemon shuts down, **or +> (f) the per-session permission timeout fires** (`DEFAULT_PERMISSION_TIMEOUT_MS`, +> 5 minutes). On timeout fire the agent's `requestPermission` resolves +> as `{outcome: 'cancelled'}`, the audit ring records a +> `permission.timeout` entry, daemon stderr emits a one-line +> breadcrumb, and the SSE bus fans out the standard +> `permission_resolved` cancelled frame so subscribers clean up. The +> timeout is configurable via `BridgeOptions.permissionResponseTimeoutMs`; +> headless callers running long-form prompts may want to extend it. Request: @@ -1338,10 +1448,13 @@ Outcomes: Response: -- `200 {}` — your vote was accepted +- `200 {}` — your vote was accepted (resolved OR recorded under consensus quorum) +- `403 { "code": "permission_forbidden", "reason": "designated_mismatch" | "remote_not_allowed", "requestId", "sessionId" }` — F3: the active policy rejected your vote - `404 { "error": "..." }` — the requestId is unknown (already resolved, never existed, or session torn down) +- `500 { "code": "cancel_sentinel_collision", ... }` — F3: the agent's `allowedOptionIds` contains the reserved sentinel `'__cancelled__'`; agent / daemon contract violation +- `501 { "code": "permission_policy_not_implemented", "policy": "" }` — F3 forward-compat: a policy literal landed in the schema but its mediator branch isn't built yet (currently unreachable; reserved for future policies) -After a successful vote, every connected client sees `permission_resolved` with the same `requestId` and the chosen `outcome`. +After a successful vote, every connected client sees `permission_resolved` with the same `requestId` and the chosen `outcome`. Under `consensus`, intermediate votes additionally fan out `permission_partial_vote` until quorum. ### Auth device-flow routes (issue #4175 PR 21) diff --git a/docs/superpowers/plans/2026-05-26-daemon-logger.md b/docs/superpowers/plans/2026-05-26-daemon-logger.md new file mode 100644 index 0000000000..b98afa80e2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-daemon-logger.md @@ -0,0 +1,1497 @@ +# `qwen serve` Daemon File Logger — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a daemon-scoped file logger to `qwen serve` so route errors, lifecycle messages, and ACP child stderr land in `~/.qwen/debug/daemon/.log` in addition to stderr — eliminating the manual `2>serve.log` workaround for issue #4548. + +**Architecture:** New cli-local module `daemonLogger.ts` exposes `initDaemonLogger(opts) → DaemonLogger`. `info/warn/error` tee to file + stderr; `raw` is file-only. `acp-bridge` gets a new optional `BridgeOptions.onDiagnosticLine` callback and `createSpawnChannelFactory({ onDiagnosticLine })` helper so the cli can route `writeServeDebugLine` and ACP child stderr lines into the daemon log without acp-bridge taking a cli dependency. No global singleton — logger is constructed per `runQwenServe` invocation. + +**Tech Stack:** TypeScript, Vitest, Node `fs.promises`, existing `Storage.getGlobalDebugDir()`, existing `updateSymlink` helper. + +**Reference spec:** `docs/superpowers/specs/2026-05-26-daemon-logger-design.md` + +**Test harness:** `vitest run` from each package; for a single file: `cd packages/ && npx vitest run `. + +--- + +## File map + +| File | Action | Purpose | +| ---------------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `packages/cli/src/serve/daemonLogger.ts` | **new** | Logger sink + format helper | +| `packages/cli/src/serve/daemonLogger.test.ts` | **new** | Unit tests for the above | +| `packages/acp-bridge/src/bridgeOptions.ts` | modify | Add `onDiagnosticLine?` field + `DiagnosticLineSink` type | +| `packages/acp-bridge/src/bridge.ts` | modify | Tee `writeServeDebugLine` through `opts.onDiagnosticLine` (via local `teeServeDebugLine` closure) | +| `packages/acp-bridge/src/bridge.test.ts` | modify | Add test that `onDiagnosticLine` receives debug lines | +| `packages/acp-bridge/src/spawnChannel.ts` | modify | Export `createSpawnChannelFactory({ onDiagnosticLine })`; tee child stderr into callback | +| `packages/acp-bridge/src/spawnChannel.test.ts` | modify (or new) | Test stderr forwarding callback | +| `packages/cli/src/serve/server.ts` | modify | `createServeApp` deps accept optional `daemonLog`; `sendBridgeError` routes through it when provided | +| `packages/cli/src/serve/server.test.ts` | modify | Verify daemonLog receives route-error entries | +| `packages/cli/src/serve/runQwenServe.ts` | modify | Init logger, boot banner, wire spawn factory + bridge callback, replace lifecycle `writeStderrLine` calls, flush on shutdown | +| `packages/cli/src/serve/runQwenServe.test.ts` | modify | Verify boot banner + flush behavior | +| `docs/cli/serve.md` (or equivalent) | modify | Document daemon log path + opt-out | + +--- + +## Task 0: Pre-flight + +- [ ] **Step 1: Confirm worktree + branch** + +Run: `git rev-parse --abbrev-ref HEAD && pwd` +Expected: branch `feat/support_daemon_logger`, cwd ends with `.claude/worktrees/feat-support-daemon-logger`. + +- [ ] **Step 2: Install dependencies + baseline tests green** + +Run: `npm install && cd packages/cli && npx vitest run src/serve/runQwenServe.test.ts && cd ../acp-bridge && npx vitest run` +Expected: all pass. (If not, baseline is broken — stop and report.) + +- [ ] **Step 3: Skim the spec** + +Read `docs/superpowers/specs/2026-05-26-daemon-logger-design.md` end-to-end. Key sections to internalize: §3 (modules), §4 (path), §5 (API), §6 (format + tee semantics), §7 (boot/shutdown), §11 (error handling). + +--- + +## Task 1: `buildDaemonLogLine` pure helper + +Pure formatter. No I/O. Easy to TDD. + +**Files:** + +- Create: `packages/cli/src/serve/daemonLogger.ts` +- Create: `packages/cli/src/serve/daemonLogger.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`packages/cli/src/serve/daemonLogger.test.ts`: + +```ts +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { buildDaemonLogLine } from './daemonLogger.js'; + +describe('buildDaemonLogLine', () => { + const FIXED = new Date('2026-05-26T03:14:15.926Z'); + + it('formats INFO with no ctx', () => { + expect( + buildDaemonLogLine({ + level: 'INFO', + message: 'daemon started', + now: FIXED, + }), + ).toBe('2026-05-26T03:14:15.926Z [INFO] [DAEMON] daemon started\n'); + }); + + it('renders ctx fields in fixed order', () => { + const line = buildDaemonLogLine({ + level: 'ERROR', + message: 'route failed', + now: FIXED, + ctx: { + sessionId: 'sess-1', + route: 'POST /session/:id/prompt', + clientId: 'client-x', + childPid: 4242, + channelId: 'ch-9', + }, + }); + expect(line).toBe( + '2026-05-26T03:14:15.926Z [ERROR] [DAEMON] ' + + 'route=POST /session/:id/prompt sessionId=sess-1 clientId=client-x ' + + 'childPid=4242 channelId=ch-9 route failed\n', + ); + }); + + it('appends extra ctx keys sorted lexicographically after fixed keys', () => { + const line = buildDaemonLogLine({ + level: 'WARN', + message: 'note', + now: FIXED, + ctx: { zeta: 1, alpha: 'a', sessionId: 's' }, + }); + expect(line).toBe( + '2026-05-26T03:14:15.926Z [WARN] [DAEMON] sessionId=s alpha=a zeta=1 note\n', + ); + }); + + it('JSON.stringify-quotes values that contain spaces or =', () => { + const line = buildDaemonLogLine({ + level: 'INFO', + message: 'hi', + now: FIXED, + ctx: { weird: 'has space', eq: 'a=b' }, + }); + expect(line).toBe( + '2026-05-26T03:14:15.926Z [INFO] [DAEMON] eq="a=b" weird="has space" hi\n', + ); + }); + + it('appends error stack as indented continuation lines', () => { + const err = new Error('boom'); + err.stack = + 'Error: boom\n at fn (file.ts:1:1)\n at main (file.ts:2:2)'; + const line = buildDaemonLogLine({ + level: 'ERROR', + message: 'failed', + now: FIXED, + err, + }); + expect(line).toBe( + '2026-05-26T03:14:15.926Z [ERROR] [DAEMON] failed\n' + + ' Error: boom\n' + + ' at fn (file.ts:1:1)\n' + + ' at main (file.ts:2:2)\n', + ); + }); + + it('falls back to err.message when stack missing', () => { + const err: Error = { name: 'Plain', message: 'no stack' } as Error; + const line = buildDaemonLogLine({ + level: 'ERROR', + message: 'failed', + now: FIXED, + err, + }); + expect(line).toBe( + '2026-05-26T03:14:15.926Z [ERROR] [DAEMON] failed\n' + + ' Plain: no stack\n', + ); + }); +}); +``` + +- [ ] **Step 2: Run test, confirm fail** + +Run: `cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts` +Expected: failure — `buildDaemonLogLine` not exported. + +- [ ] **Step 3: Implement `buildDaemonLogLine`** + +Create `packages/cli/src/serve/daemonLogger.ts` with: + +```ts +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export type DaemonLogLevel = 'INFO' | 'WARN' | 'ERROR'; + +export interface DaemonLogContext { + route?: string; + sessionId?: string; + clientId?: string; + childPid?: number; + channelId?: string; + [key: string]: unknown; +} + +const FIXED_CTX_ORDER = [ + 'route', + 'sessionId', + 'clientId', + 'childPid', + 'channelId', +] as const; + +function renderCtxValue(value: unknown): string { + const s = String(value); + return /[\s=]/.test(s) ? JSON.stringify(s) : s; +} + +function renderCtx(ctx: DaemonLogContext | undefined): string { + if (!ctx) return ''; + const parts: string[] = []; + for (const key of FIXED_CTX_ORDER) { + const v = ctx[key]; + if (v !== undefined && v !== null) { + parts.push(`${key}=${renderCtxValue(v)}`); + } + } + const fixedSet = new Set(FIXED_CTX_ORDER); + const extraKeys = Object.keys(ctx) + .filter((k) => !fixedSet.has(k) && ctx[k] !== undefined && ctx[k] !== null) + .sort(); + for (const key of extraKeys) { + parts.push(`${key}=${renderCtxValue(ctx[key])}`); + } + return parts.length > 0 ? parts.join(' ') + ' ' : ''; +} + +function renderErr(err: Error | undefined): string { + if (!err) return ''; + const body = err.stack ?? `${err.name ?? 'Error'}: ${err.message}`; + return ( + body + .split('\n') + .map((l) => ` ${l}`) + .join('\n') + '\n' + ); +} + +export interface BuildDaemonLogLineArgs { + level: DaemonLogLevel; + message: string; + now: Date; + ctx?: DaemonLogContext; + err?: Error; +} + +export function buildDaemonLogLine(args: BuildDaemonLogLineArgs): string { + const ts = args.now.toISOString(); + const ctxStr = renderCtx(args.ctx); + return `${ts} [${args.level}] [DAEMON] ${ctxStr}${args.message}\n${renderErr(args.err)}`; +} +``` + +- [ ] **Step 4: Run test, confirm pass** + +Run: `cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts` +Expected: PASS (6 specs). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts +git commit -m "feat(serve): buildDaemonLogLine formatter (#4548)" +``` + +--- + +## Task 2: `initDaemonLogger` opt-out + no-op factory + +Returns a no-op logger when `QWEN_DAEMON_LOG_FILE` is disabled. No filesystem touch yet. + +**Files:** + +- Modify: `packages/cli/src/serve/daemonLogger.ts` +- Modify: `packages/cli/src/serve/daemonLogger.test.ts` + +- [ ] **Step 1: Add failing tests** + +Append to `daemonLogger.test.ts`: + +```ts +import { initDaemonLogger } from './daemonLogger.js'; +import { afterEach, beforeEach } from 'vitest'; + +describe('initDaemonLogger opt-out', () => { + const originalEnv = process.env['QWEN_DAEMON_LOG_FILE']; + afterEach(() => { + if (originalEnv === undefined) delete process.env['QWEN_DAEMON_LOG_FILE']; + else process.env['QWEN_DAEMON_LOG_FILE'] = originalEnv; + }); + + for (const val of ['0', 'false', 'off', 'no', 'False', ' OFF ']) { + it(`returns no-op logger when QWEN_DAEMON_LOG_FILE=${JSON.stringify(val)}`, () => { + process.env['QWEN_DAEMON_LOG_FILE'] = val; + const stderr: string[] = []; + const logger = initDaemonLogger({ + boundWorkspace: '/tmp/ws', + baseDir: '/tmp/nonexistent-should-not-touch', + stderr: (s) => stderr.push(s), + }); + logger.info('hello'); + logger.warn('there'); + logger.error('boom'); + logger.raw('raw'); + expect(stderr).toEqual([]); // no-op = nothing + expect(logger.getLogPath()).toBe(''); + expect(logger.getDaemonId()).toBe(''); + }); + } +}); +``` + +- [ ] **Step 2: Run, confirm fail** + +Run: `cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts` +Expected: failure — `initDaemonLogger` not exported. + +- [ ] **Step 3: Implement opt-out + no-op shape** + +Append to `daemonLogger.ts`: + +```ts +export interface DaemonLogger { + info(message: string, ctx?: DaemonLogContext): void; + warn(message: string, ctx?: DaemonLogContext): void; + error(message: string, err?: Error | null, ctx?: DaemonLogContext): void; + raw(line: string, level?: 'info' | 'warn' | 'error'): void; + getLogPath(): string; + getDaemonId(): string; + flush(): Promise; +} + +export interface InitDaemonLoggerOptions { + boundWorkspace: string; + pid?: number; + now?: () => Date; + stderr?: (line: string) => void; + baseDir?: string; +} + +const NOOP_LOGGER: DaemonLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, + raw: () => {}, + getLogPath: () => '', + getDaemonId: () => '', + flush: () => Promise.resolve(), +}; + +function isOptedOut(): boolean { + const raw = process.env['QWEN_DAEMON_LOG_FILE']; + if (!raw) return false; + return ['0', 'false', 'off', 'no'].includes(raw.trim().toLowerCase()); +} + +export function initDaemonLogger(_opts: InitDaemonLoggerOptions): DaemonLogger { + if (isOptedOut()) return NOOP_LOGGER; + throw new Error('initDaemonLogger: file path not implemented yet'); +} +``` + +- [ ] **Step 4: Run, confirm opt-out specs pass** + +Run: `cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "opt-out"` +Expected: opt-out specs PASS; full file may still fail (we'll add coverage incrementally). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts +git commit -m "feat(serve): daemon logger opt-out env + no-op shape (#4548)" +``` + +--- + +## Task 3: File init (daemon-id, mkdir, sync probe, degraded fallback) + +**Files:** + +- Modify: `packages/cli/src/serve/daemonLogger.ts` +- Modify: `packages/cli/src/serve/daemonLogger.test.ts` + +- [ ] **Step 1: Add failing tests** + +Append to `daemonLogger.test.ts`: + +```ts +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + mkdtempSync, + readFileSync, + existsSync, + mkdirSync, + chmodSync, +} from 'node:fs'; +import { rmSync } from 'node:fs'; + +describe('initDaemonLogger file init', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-')); + }); + afterEach(() => { + try { + rmSync(tmp, { recursive: true, force: true }); + } catch {} + }); + + it('derives daemon-id "serve--" and creates log file', () => { + const logger = initDaemonLogger({ + boundWorkspace: '/workspace/foo', + pid: 1234, + baseDir: tmp, + }); + expect(logger.getDaemonId()).toMatch(/^serve-1234-[0-9a-f]{8}$/); + expect(logger.getLogPath()).toBe( + path.join(tmp, 'daemon', `${logger.getDaemonId()}.log`), + ); + expect(existsSync(logger.getLogPath())).toBe(true); + expect(readFileSync(logger.getLogPath(), 'utf8')).toMatch( + /\[INFO\] \[DAEMON\] daemon started pid=1234 workspace=\/workspace\/foo/, + ); + }); + + it('falls back to no-op when mkdir fails', () => { + const stderr: string[] = []; + // Create a file where the directory should be → mkdir EEXIST/ENOTDIR + const blockingFile = path.join(tmp, 'daemon'); + require('node:fs').writeFileSync(blockingFile, 'blocker'); + + const logger = initDaemonLogger({ + boundWorkspace: '/w', + pid: 1, + baseDir: tmp, + stderr: (s) => stderr.push(s), + }); + expect(logger.getLogPath()).toBe(''); + expect(stderr.join('\n')).toMatch(/daemon log disabled/); + expect(() => logger.info('after')).not.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run, confirm fail** + +Run: `cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "file init"` +Expected: failure — `throw new Error('not implemented')`. + +- [ ] **Step 3: Implement file init** + +Replace the throwing body of `initDaemonLogger`. Add imports and helpers: + +```ts +import * as nodeFs from 'node:fs'; +import * as nodePath from 'node:path'; +import * as crypto from 'node:crypto'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; +import { Storage } from '@qwen-code/qwen-code-core'; + +function computeDaemonId(pid: number, boundWorkspace: string): string { + const hash = crypto + .createHash('sha256') + .update(boundWorkspace) + .digest('hex') + .slice(0, 8); + return `serve-${pid}-${hash}`; +} + +export function initDaemonLogger(opts: InitDaemonLoggerOptions): DaemonLogger { + if (isOptedOut()) return NOOP_LOGGER; + + const pid = opts.pid ?? process.pid; + const now = opts.now ?? (() => new Date()); + const stderr = opts.stderr ?? writeStderrLine; + const baseDir = opts.baseDir ?? Storage.getGlobalDebugDir(); + + const daemonId = computeDaemonId(pid, opts.boundWorkspace); + const daemonDir = nodePath.join(baseDir, 'daemon'); + const logPath = nodePath.join(daemonDir, `${daemonId}.log`); + + try { + nodeFs.mkdirSync(daemonDir, { recursive: true }); + const firstLine = buildDaemonLogLine({ + level: 'INFO', + message: `daemon started pid=${pid} workspace=${opts.boundWorkspace}`, + now: now(), + }); + nodeFs.appendFileSync(logPath, firstLine, { flag: 'a' }); + } catch (err) { + stderr( + `qwen serve: daemon log disabled — init failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return NOOP_LOGGER; + } + + // Methods come in Task 4. For now stub them out so the file-init tests pass. + return { + info: () => {}, + warn: () => {}, + error: () => {}, + raw: () => {}, + getLogPath: () => logPath, + getDaemonId: () => daemonId, + flush: () => Promise.resolve(), + }; +} +``` + +- [ ] **Step 4: Run, confirm pass** + +Run: `cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "file init"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts +git commit -m "feat(serve): daemon logger file init + degraded fallback (#4548)" +``` + +--- + +## Task 4: `info` / `warn` / `error` + async queue + flush + stderr tee + +**Files:** + +- Modify: `packages/cli/src/serve/daemonLogger.ts` +- Modify: `packages/cli/src/serve/daemonLogger.test.ts` + +- [ ] **Step 1: Add failing tests** + +Append to `daemonLogger.test.ts`: + +```ts +describe('initDaemonLogger info/warn/error', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-')); + }); + afterEach(() => { + try { + rmSync(tmp, { recursive: true, force: true }); + } catch {} + }); + + it('info appends to file and tees to stderr', async () => { + const stderr: string[] = []; + const fixed = new Date('2026-05-26T03:14:15.926Z'); + const logger = initDaemonLogger({ + boundWorkspace: '/w', + pid: 1, + baseDir: tmp, + stderr: (s) => stderr.push(s), + now: () => fixed, + }); + logger.info('hello', { route: 'GET /' }); + await logger.flush(); + const content = readFileSync(logger.getLogPath(), 'utf8'); + expect(content).toContain('[INFO] [DAEMON] route=GET / hello\n'); + // Stderr saw the same line (after boot banner, which isn't teed here). + const teedLines = stderr.filter((s) => s.includes('[INFO] [DAEMON]')); + expect(teedLines).toHaveLength(1); + }); + + it('error appends err.stack as continuation', async () => { + const logger = initDaemonLogger({ + boundWorkspace: '/w', + pid: 1, + baseDir: tmp, + }); + const err = new Error('boom'); + logger.error('route failed', err, { route: 'POST /x' }); + await logger.flush(); + const content = readFileSync(logger.getLogPath(), 'utf8'); + expect(content).toMatch( + /\[ERROR\] \[DAEMON\] route=POST \/x route failed\n Error: boom/, + ); + }); + + it('flush awaits all pending appends', async () => { + const logger = initDaemonLogger({ + boundWorkspace: '/w', + pid: 1, + baseDir: tmp, + }); + for (let i = 0; i < 50; i++) logger.info(`msg-${i}`); + await logger.flush(); + const lines = readFileSync(logger.getLogPath(), 'utf8').split('\n'); + const msgLines = lines.filter((l) => /msg-\d+$/.test(l)); + expect(msgLines).toHaveLength(50); + for (let i = 0; i < 50; i++) { + expect(msgLines[i]).toContain(`msg-${i}`); + } + }); + + it('warns once on append failure and keeps trying', async () => { + const logger = initDaemonLogger({ + boundWorkspace: '/w', + pid: 1, + baseDir: tmp, + stderr: () => {}, + }); + // Sabotage by removing the file mid-flight — POSIX will keep the inode + // around for a held fd, but appendFile reopens each call → ENOENT once + // the parent dir is gone. + rmSync(path.dirname(logger.getLogPath()), { recursive: true, force: true }); + const stderr2: string[] = []; + // Re-create logger to bind our stderr capture? Simpler: re-stub via + // private state — instead, do this in a separate test using a custom + // stderr from init time. + logger.info('after-rm-1'); + logger.info('after-rm-2'); + await logger.flush(); + // No throw — degraded path swallows. (Stderr count assertion left to + // a separate variant if needed; this test pins "no crash on failure".) + }); +}); +``` + +- [ ] **Step 2: Run, confirm fail** + +Run: `cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "info/warn/error"` +Expected: failure — methods are stubs. + +- [ ] **Step 3: Implement methods + queue + flush + tee** + +Replace the final `return {...}` block in `initDaemonLogger`: + +```ts +let pending: Promise = Promise.resolve(); +let degraded = false; + +const enqueueAppend = (line: string): void => { + pending = pending.then(() => + nodeFs.promises.appendFile(logPath, line).catch((err) => { + if (!degraded) { + degraded = true; + stderr( + `qwen serve: daemon log write failed — entering degraded mode: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }), + ); +}; + +const teeLine = ( + level: DaemonLogLevel, + message: string, + ctx?: DaemonLogContext, + err?: Error, +): void => { + const line = buildDaemonLogLine({ level, message, now: now(), ctx, err }); + // stderr first (synchronous, preserves human-visible order), then file. + stderr(line.trimEnd()); + enqueueAppend(line); +}; + +return { + info: (message, ctx) => teeLine('INFO', message, ctx), + warn: (message, ctx) => teeLine('WARN', message, ctx), + error: (message, err, ctx) => + teeLine('ERROR', message, ctx, err ?? undefined), + raw: () => {}, // implemented in Task 5 + getLogPath: () => logPath, + getDaemonId: () => daemonId, + flush: () => pending, +}; +``` + +- [ ] **Step 4: Run, confirm pass** + +Run: `cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "info/warn/error"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts +git commit -m "feat(serve): daemon logger info/warn/error + flush (#4548)" +``` + +--- + +## Task 5: `raw()` file-only tee + +**Files:** + +- Modify: `packages/cli/src/serve/daemonLogger.ts` +- Modify: `packages/cli/src/serve/daemonLogger.test.ts` + +- [ ] **Step 1: Add failing test** + +Append: + +```ts +describe('initDaemonLogger raw', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-')); + }); + afterEach(() => { + try { + rmSync(tmp, { recursive: true, force: true }); + } catch {} + }); + + it('appends prefixed line, no stderr tee', async () => { + const stderr: string[] = []; + const logger = initDaemonLogger({ + boundWorkspace: '/w', + pid: 1, + baseDir: tmp, + stderr: (s) => stderr.push(s), + }); + const stderrBefore = stderr.length; + logger.raw('[serve pid=123 cwd=/x] child crashed', 'warn'); + logger.raw('[serve pid=123 cwd=/x] another'); + await logger.flush(); + const content = readFileSync(logger.getLogPath(), 'utf8'); + expect(content).toContain( + '[WARN] [DAEMON] [serve pid=123 cwd=/x] child crashed\n', + ); + expect(content).toContain( + '[INFO] [DAEMON] [serve pid=123 cwd=/x] another\n', + ); + // No new stderr lines from raw() + expect(stderr.length).toBe(stderrBefore); + }); +}); +``` + +- [ ] **Step 2: Run, confirm fail** + +Run: `cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "raw"` +Expected: fail — raw is no-op. + +- [ ] **Step 3: Implement raw** + +In `initDaemonLogger`, replace `raw: () => {},` with: + +```ts +raw: (line: string, level: 'info' | 'warn' | 'error' = 'info') => { + const upper = level.toUpperCase() as DaemonLogLevel; + const formatted = `${now().toISOString()} [${upper}] [DAEMON] ${line}\n`; + enqueueAppend(formatted); +}, +``` + +- [ ] **Step 4: Run, confirm pass** + +Run: `cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "raw"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts +git commit -m "feat(serve): daemon logger raw() file-only tee (#4548)" +``` + +--- + +## Task 6: `latest` symlink + +**Files:** + +- Modify: `packages/cli/src/serve/daemonLogger.ts` +- Modify: `packages/cli/src/serve/daemonLogger.test.ts` + +- [ ] **Step 1: Add failing test** + +Append: + +```ts +import { realpathSync, lstatSync } from 'node:fs'; + +describe('initDaemonLogger latest symlink', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-')); + }); + afterEach(() => { + try { + rmSync(tmp, { recursive: true, force: true }); + } catch {} + }); + + it('creates daemon/latest pointing to the current log', () => { + const logger = initDaemonLogger({ + boundWorkspace: '/w', + pid: 42, + baseDir: tmp, + }); + const linkPath = path.join(tmp, 'daemon', 'latest'); + expect(lstatSync(linkPath).isSymbolicLink() || existsSync(linkPath)).toBe( + true, + ); + expect(realpathSync(linkPath)).toBe(realpathSync(logger.getLogPath())); + }); + + it('updates latest on subsequent init in same dir', () => { + const a = initDaemonLogger({ boundWorkspace: '/w', pid: 1, baseDir: tmp }); + const b = initDaemonLogger({ boundWorkspace: '/w', pid: 2, baseDir: tmp }); + expect(realpathSync(path.join(tmp, 'daemon', 'latest'))).toBe( + realpathSync(b.getLogPath()), + ); + expect(realpathSync(a.getLogPath())).not.toBe(realpathSync(b.getLogPath())); + }); +}); +``` + +- [ ] **Step 2: Run, confirm fail** + +Run: `cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "latest symlink"` +Expected: fail — symlink not created. + +- [ ] **Step 3: Implement symlink update** + +`updateSymlink` lives in `packages/core/src/utils/symlink.ts` but is NOT re-exported from the core barrel (confirmed via `grep -n updateSymlink packages/core/src/index.ts` → no matches at plan-write time). Add the re-export first: + +In `packages/core/src/index.ts`, add (near the other utils exports): + +```ts +export { updateSymlink } from './utils/symlink.js'; +``` + +Then import in `daemonLogger.ts`: + +```ts +import { Storage, updateSymlink } from '@qwen-code/qwen-code-core'; +``` + +(Merge with the existing `Storage` import added in Task 3.) + +Inside `initDaemonLogger`, after the `appendFileSync` first-line write succeeds, add: + +```ts +try { + const aliasPath = nodePath.join(daemonDir, 'latest'); + updateSymlink(aliasPath, logPath, { fallbackCopy: false }).catch(() => { + // Best-effort. Symlink failure must not degrade primary writes. + }); +} catch { + // Sync throw equally best-effort. +} +``` + +- [ ] **Step 4: Run, confirm pass** + +Run: `cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "latest symlink"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts packages/core/src/index.ts +git commit -m "feat(serve): daemon logger latest symlink (#4548)" +``` + +--- + +## Task 7: Add `BridgeOptions.onDiagnosticLine` + tee `writeServeDebugLine` + +**Files:** + +- Modify: `packages/acp-bridge/src/bridgeOptions.ts` +- Modify: `packages/acp-bridge/src/bridge.ts` +- Modify: `packages/acp-bridge/src/bridge.test.ts` + +- [ ] **Step 1: Add `DiagnosticLineSink` type to `bridgeOptions.ts`** + +Insert near the top of the `BridgeOptions` interface (before `sessionScope`): + +```ts +/** + * Sink for serve-level diagnostic lines (set by the cli daemon logger). + * When provided, the bridge tees `writeServeDebugLine` output through + * this callback alongside the existing stderr write — used by + * runQwenServe to capture them in the daemon log file. The bridge + * does not own a file logger itself; this is a pure pass-through hook. + */ +export type DiagnosticLineSink = ( + line: string, + level?: 'info' | 'warn' | 'error', +) => void; +``` + +Add inside `BridgeOptions`: + +```ts + /** + * Optional: tee `writeServeDebugLine` output. See {@link DiagnosticLineSink}. + * No-op when omitted. Set by cli `runQwenServe` from the daemon logger. + */ + onDiagnosticLine?: DiagnosticLineSink; +``` + +- [ ] **Step 2: Add failing test** + +In `packages/acp-bridge/src/bridge.test.ts`, add a new `describe('onDiagnosticLine', ...)` block. The file already imports `makeBridge` and `makeChannel` from `./internal/testUtils.js` — reuse them instead of hand-rolling a `ChannelFactory`. Confirm with `grep -n "import.*testUtils" packages/acp-bridge/src/bridge.test.ts`. To trigger `writeServeDebugLine`, pick the shortest-setup test among the 6 call sites — list them with `grep -n "writeServeDebugLine(" packages/acp-bridge/src/bridge.ts` (currently lines 1410, 1423, 2242, 2328, 2624, 2637; the cross-session permission-vote rejection around line 2242 is a small reproducible trigger). + +```ts +describe('onDiagnosticLine', () => { + const originalDebug = process.env['QWEN_SERVE_DEBUG']; + afterEach(() => { + if (originalDebug === undefined) delete process.env['QWEN_SERVE_DEBUG']; + else process.env['QWEN_SERVE_DEBUG'] = originalDebug; + }); + + it('receives writeServeDebugLine output when QWEN_SERVE_DEBUG=1', async () => { + process.env['QWEN_SERVE_DEBUG'] = '1'; + const captured: Array<{ line: string; level?: string }> = []; + const bridge = makeBridge({ + onDiagnosticLine: (line, level) => captured.push({ line, level }), + }); + // Trigger writeServeDebugLine via [copy harness from the closest + // existing test that exercises one of the 6 call sites above]. + // ... trigger code here ... + expect(captured.some((e) => e.line.includes('qwen serve debug: '))).toBe( + true, + ); + expect( + captured.every((e) => e.level === undefined || e.level === 'info'), + ).toBe(true); + await bridge.shutdown(); + }); +}); +``` + +(`makeBridge` accepts `Partial` — once Task 7 step 1 adds `onDiagnosticLine` to `BridgeOptions`, it flows through without further edits to `testUtils.ts`.) + +- [ ] **Step 3: Run, confirm fail** + +Run: `cd packages/acp-bridge && npx vitest run src/bridge.test.ts -t "onDiagnosticLine"` +Expected: fail — callback not invoked. + +- [ ] **Step 4: Tee `writeServeDebugLine` through the callback** + +In `packages/acp-bridge/src/bridge.ts`, near the top of `createHttpAcpBridge` (after `opts` is destructured), introduce a local tee that wraps the existing module-level helper: + +```ts +const teeServeDebugLine = (message: string): void => { + writeServeDebugLine(message); + if (opts.onDiagnosticLine && isServeDebugLoggingEnabled()) { + opts.onDiagnosticLine(`qwen serve debug: ${message}`, 'info'); + } +}; +``` + +Then, in this file replace every internal `writeServeDebugLine(...)` call **inside** `createHttpAcpBridge`'s closure with `teeServeDebugLine(...)`. Use: + +```bash +grep -n "writeServeDebugLine(" packages/acp-bridge/src/bridge.ts +``` + +to enumerate call sites — there are 6 in the current tree (lines 1410, 1423, 2242, 2328, 2624, 2637; verify with the grep). Edit each. Do NOT change the module-level `writeServeDebugLine` definition itself — other entry points and tests rely on it. + +(Reason for not editing the top-level definition: changes the signature for all callers including tests; the closure tee is additive and locally-scoped.) + +- [ ] **Step 5: Run, confirm pass** + +Run: `cd packages/acp-bridge && npx vitest run src/bridge.test.ts -t "onDiagnosticLine"` +Expected: PASS. Also run full file to catch regressions: `npx vitest run src/bridge.test.ts`. + +- [ ] **Step 6: Commit** + +```bash +git add packages/acp-bridge/src/bridgeOptions.ts packages/acp-bridge/src/bridge.ts packages/acp-bridge/src/bridge.test.ts +git commit -m "feat(acp-bridge): onDiagnosticLine sink for serve debug tee (#4548)" +``` + +--- + +## Task 8: `createSpawnChannelFactory` with `onDiagnosticLine` + +**Files:** + +- Modify: `packages/acp-bridge/src/spawnChannel.ts` +- Modify: `packages/acp-bridge/src/spawnChannel.test.ts` (or create if missing) + +- [ ] **Step 1: Inspect current export shape** + +```bash +grep -n "defaultSpawnChannelFactory\|onDiagnosticLine\|process.stderr.write" packages/acp-bridge/src/spawnChannel.ts | head -20 +``` + +Confirm `defaultSpawnChannelFactory` is the only public spawn export. The existing child-stderr forwarder calls `process.stderr.write(prefix + line + '\n')` inside the body — locate that block (around line 125). + +- [ ] **Step 2: Add failing test** + +In `packages/acp-bridge/src/spawnChannel.test.ts` (look for an existing test file; if none, create one): + +```ts +import { describe, it, expect } from 'vitest'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSpawnChannelFactory } from './spawnChannel.js'; + +describe('createSpawnChannelFactory onDiagnosticLine', () => { + it('returns a ChannelFactory that tees child stderr lines', async () => { + const captured: Array<{ line: string; level?: string }> = []; + const factory = createSpawnChannelFactory({ + onDiagnosticLine: (line, level) => captured.push({ line, level }), + }); + // Spawn a tiny child that writes to stderr then exits. Use the + // QWEN_CLI_ENTRY escape hatch to point at a Node one-liner. + const here = path.dirname(fileURLToPath(import.meta.url)); + process.env['QWEN_CLI_ENTRY'] = path.join( + here, + 'testutil', + 'stderrOnlyEntry.cjs', + ); + try { + const ch = await factory('/tmp', {}); + await ch.exited; + // After child exit, the forwarder flushes buffered tail. + expect( + captured.some((e) => + /\[serve pid=\d+ cwd=\/tmp\] hello-stderr/.test(e.line), + ), + ).toBe(true); + expect( + captured.every((e) => e.level === undefined || e.level === 'warn'), + ).toBe(true); + } finally { + delete process.env['QWEN_CLI_ENTRY']; + } + }); +}); +``` + +And a fixture entry `packages/acp-bridge/src/testutil/stderrOnlyEntry.cjs`: + +```js +process.stderr.write('hello-stderr\n'); +process.exit(0); +``` + +(Adjust if the bridge requires ACP initialize handshake before considering the child "spawned" — alternative: write the stderr line during initialize handling. If the test is too brittle, fall back to mocking the spawn and asserting the forwarder logic in isolation — read `defaultSpawnChannelFactory`'s body and unit-test the inner forwarder by exporting it for tests.) + +- [ ] **Step 3: Run, confirm fail** + +Run: `cd packages/acp-bridge && npx vitest run src/spawnChannel.test.ts -t "onDiagnosticLine"` +Expected: fail — `createSpawnChannelFactory` not exported. + +- [ ] **Step 4: Implement `createSpawnChannelFactory`** + +Refactor `defaultSpawnChannelFactory` into a factory-of-factories. Replace the top of `spawnChannel.ts`: + +```ts +export interface SpawnChannelFactoryOptions { + onDiagnosticLine?: (line: string, level?: 'info' | 'warn' | 'error') => void; +} + +export function createSpawnChannelFactory( + options: SpawnChannelFactoryOptions = {}, +): ChannelFactory { + const onDiagnosticLine = options.onDiagnosticLine; + return async (workspaceCwd, childEnvOverrides) => { + // ... existing body of defaultSpawnChannelFactory ... + // Where the existing forwarder does: + // process.stderr.write(prefix + line + '\n') + // change it to: + // const teedLine = prefix + line; + // process.stderr.write(teedLine + '\n'); + // if (onDiagnosticLine) onDiagnosticLine(teedLine, 'warn'); + // For the [truncated] branch: + // const teedTrunc = prefix + buf.slice(0, STDERR_LINE_CAP_CHARS) + ' [truncated]'; + // process.stderr.write(teedTrunc + '\n'); + // if (onDiagnosticLine) onDiagnosticLine(teedTrunc, 'warn'); + }; +} + +// Preserve the old export for backward compatibility (no callback wiring). +export const defaultSpawnChannelFactory: ChannelFactory = + createSpawnChannelFactory(); +``` + +Implementation discipline: + +- Do NOT remove `defaultSpawnChannelFactory` — channels/IDE adapters still import it. +- Stick to the exact existing stderr write semantics (line buffering, 64 KiB cap, truncation marker). The `onDiagnosticLine` call sits next to each existing `process.stderr.write` and never replaces it. + +- [ ] **Step 5: Run, confirm pass** + +Run: `cd packages/acp-bridge && npx vitest run src/spawnChannel.test.ts -t "onDiagnosticLine"` +Expected: PASS. Also `npx vitest run` full suite to confirm no regressions. + +- [ ] **Step 6: Commit** + +```bash +git add packages/acp-bridge/src/spawnChannel.ts packages/acp-bridge/src/spawnChannel.test.ts packages/acp-bridge/src/testutil/stderrOnlyEntry.cjs +git commit -m "feat(acp-bridge): createSpawnChannelFactory with onDiagnosticLine (#4548)" +``` + +--- + +## Task 9: Route `sendBridgeError` through `daemonLog` + +**Files:** + +- Modify: `packages/cli/src/serve/server.ts` +- Modify: `packages/cli/src/serve/server.test.ts` + +- [ ] **Step 1: Add `daemonLog` to `createServeApp` deps** + +Read `packages/cli/src/serve/server.ts` around the `createServeApp` signature (search for `export function createServeApp` or `export interface ServeAppDeps`). Add to its deps interface: + +```ts +/** + * Optional daemon logger. When provided, `sendBridgeError` routes + * each route-mapped error through `daemonLog.error(...)` (which tees + * to stderr + the daemon log file). When omitted, falls back to + * existing stderr-only behavior. + */ +daemonLog?: import('./daemonLogger.js').DaemonLogger; +``` + +- [ ] **Step 2: Add failing test** + +In `packages/cli/src/serve/server.test.ts`, add (or extend a route-error test): + +```ts +import { initDaemonLogger } from './daemonLogger.js'; + +it('sendBridgeError routes through daemonLog when provided', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-')); + try { + const stderr: string[] = []; + const daemonLog = initDaemonLogger({ + boundWorkspace: '/w', + pid: 1, + baseDir: tmp, + stderr: (s) => stderr.push(s), + }); + // createServeApp signature: (opts, getPort?, deps?). daemonLog goes in deps. + const app = createServeApp( + /* opts */ { /* ...usual ServeOptions, copy from closest existing test... */ } as ServeOptions, + /* getPort */ () => 0, + /* deps */ { /* ...usual deps that make a route throw... */, daemonLog }, + ); + await request(app).get('/some/erroring/route').expect(500); + await daemonLog.flush(); + const content = readFileSync(daemonLog.getLogPath(), 'utf8'); + expect(content).toMatch( + /\[ERROR\] \[DAEMON\] route=GET \/some\/erroring\/route/, + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); +``` + +(Copy whatever route-throws-error harness already lives in `server.test.ts` — e.g. inject a deps stub that throws when called. The point is one route hits `sendBridgeError` → assertion lands in the daemon log.) + +- [ ] **Step 3: Run, confirm fail** + +Run: `cd packages/cli && npx vitest run src/serve/server.test.ts -t "daemonLog"` +Expected: fail. + +- [ ] **Step 4: Wire `sendBridgeError`** + +In `server.ts`, find the `sendBridgeError` function (around line 2765). It currently writes to stderr inline. Refactor: + +1. Plumb `daemonLog` from `createServeApp` into the closure that owns `sendBridgeError` (it's defined inside the function — same closure). +2. At the bottom of `sendBridgeError`, where the stderr write happens, replace with: + +```ts +if (daemonLog) { + daemonLog.error( + err instanceof Error ? err.message : String(err), + err instanceof Error ? err : null, + { + ...(ctx?.route ? { route: ctx.route } : {}), + ...(ctx?.sessionId ? { sessionId: ctx.sessionId } : {}), + }, + ); +} else { + // Legacy stderr-only path. Keep behavior intact for embedders that + // construct createServeApp without daemonLog (tests, direct integrations). + writeStderrLine( + `qwen serve: ${ctx?.route ?? 'unknown route'}: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }${ctx?.sessionId ? ` sessionId=${ctx.sessionId}` : ''}`, + ); +} +``` + +Make sure the new branch is taken when `daemonLog` is non-null. `daemonLog.error` already tees to stderr, so the stderr line is still produced — no behavior loss. + +- [ ] **Step 5: Run, confirm pass** + +Run: `cd packages/cli && npx vitest run src/serve/server.test.ts` +Expected: full file PASS (new + old). + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/serve/server.ts packages/cli/src/serve/server.test.ts +git commit -m "feat(serve): route sendBridgeError through daemonLog (#4548)" +``` + +--- + +## Task 10: Wire `runQwenServe` — init, boot banner, callbacks, lifecycle, shutdown flush + +**Files:** + +- Modify: `packages/cli/src/serve/runQwenServe.ts` +- Modify: `packages/cli/src/serve/runQwenServe.test.ts` + +- [ ] **Step 1: Read the existing boot + shutdown structure** + +Re-read `packages/cli/src/serve/runQwenServe.ts` lines 590-1030 (the `createHttpAcpBridge({...})` call site, the `RunHandle.close` body, and the `onSignal` handler). Note all `writeStderrLine(...)` calls — they're at roughly 393, 565, 805, 821, 825, 835, 859, 865, 872, 877, 951, 961, 986, 997, 1027, 1361 (run `grep -n writeStderrLine` for the current line numbers). + +- [ ] **Step 2: Add failing test** + +In `packages/cli/src/serve/runQwenServe.test.ts`, add (or extend): + +```ts +import { existsSync, readFileSync, rmSync, mkdtempSync } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +it('runQwenServe initializes daemon logger and writes boot banner + flushes on shutdown', async () => { + const tmpRuntime = mkdtempSync(path.join(os.tmpdir(), 'serve-runtime-')); + const originalRuntime = process.env['QWEN_RUNTIME_DIR']; + process.env['QWEN_RUNTIME_DIR'] = tmpRuntime; + try { + const handle = await runQwenServe({ + port: 0, + hostname: '127.0.0.1', + mode: 'workspace', + // ... fill remaining required opts from the smallest existing test ... + }); + // Boot wrote a daemon log somewhere under tmpRuntime/debug/daemon + const daemonDir = path.join(tmpRuntime, 'debug', 'daemon'); + expect(existsSync(daemonDir)).toBe(true); + const logs = require('node:fs') + .readdirSync(daemonDir) + .filter((f: string) => f.endsWith('.log')); + expect(logs.length).toBe(1); + const content = readFileSync(path.join(daemonDir, logs[0]), 'utf8'); + expect(content).toMatch(/daemon started pid=\d+ workspace=/); + await handle.close(); + // After shutdown, "shutdown signal" or equivalent should be in the log. + const after = readFileSync(path.join(daemonDir, logs[0]), 'utf8'); + expect(after).toMatch(/shutdown/i); + } finally { + if (originalRuntime === undefined) delete process.env['QWEN_RUNTIME_DIR']; + else process.env['QWEN_RUNTIME_DIR'] = originalRuntime; + rmSync(tmpRuntime, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 3: Run, confirm fail** + +Run: `cd packages/cli && npx vitest run src/serve/runQwenServe.test.ts -t "daemon logger"` +Expected: fail. + +- [ ] **Step 4: Wire in `runQwenServe`** + +Edit `runQwenServe.ts`: + +1. Add imports near the existing ones: + +```ts +import { initDaemonLogger, type DaemonLogger } from './daemonLogger.js'; +import { createSpawnChannelFactory } from '@qwen-code/acp-bridge/spawnChannel'; +``` + +2. Inside `runQwenServe(opts)`, right after `boundWorkspace` is canonicalized (find the assignment; it's the value passed to `createHttpAcpBridge`): + +```ts +const daemonLog: DaemonLogger = initDaemonLogger({ boundWorkspace }); +writeStderrLine( + `qwen serve: daemon log → ${daemonLog.getLogPath() || '(disabled)'}`, +); +``` + +3. Update the `createHttpAcpBridge({...})` call (around line 606): + +```ts +const channelFactory = createSpawnChannelFactory({ + onDiagnosticLine: (line, level) => daemonLog.raw(line, level), +}); +const bridge = + deps.bridge ?? + createHttpAcpBridge({ + // ... existing fields ... + channelFactory, + onDiagnosticLine: (line, level) => daemonLog.raw(line, level), + }); +``` + +(If `deps.bridge` is provided, the operator is embedding and owns their own wiring — skip the callback.) + +4. Update the `createServeApp(...)` call (currently at `runQwenServe.ts:706`, signature is `createServeApp(opts, getPort, deps)`) to add `daemonLog` to the deps object: + +```ts +const app = createServeApp(opts, () => actualPort, { + bridge, + boundWorkspace, + fsFactory, + daemonLog, +}); +``` + +5. Replace **lifecycle-only** `writeStderrLine(...)` calls (the ones inside `onSignal`, the `bridge.shutdown` error path, the server `error` listener, the device-flow dispose error, the "received signal, draining" line) with `daemonLog.warn(...)` / `daemonLog.error(..., err)` — daemonLog tees to stderr so operator-visible output is preserved. Do NOT touch: + - Boot banner about "listening on URL" (that one is stdout, not stderr — `writeStdoutLine`). + - CLI usage/argparse errors before `daemonLog` is constructed. + - The lone "qwen serve: daemon log → ..." banner added in step 2 (avoid logging a line about itself). + + To be concrete, the **mechanical** rule for this step: every `writeStderrLine` call **after** the `daemonLog` is constructed and **before** `process.exit` is candidate; if its content reads like a daemon diagnostic (not a one-shot startup banner), switch it. + +6. In the `RunHandle.close` body, after the `finish` callback runs (or right before `process.exit(0)` in `onSignal`), add `await daemonLog.flush();`. Concretely, the `onSignal` handler becomes: + +```ts +const onSignal = async (signal: NodeJS.Signals) => { + if (shuttingDown) { + /* unchanged */ return; + } + daemonLog.warn(`received ${signal}, draining`, { signal }); + try { + await handle.close(); + await daemonLog.flush(); + process.exit(0); + } catch (err) { + daemonLog.error('shutdown error', err instanceof Error ? err : null); + await daemonLog.flush().catch(() => {}); + process.exit(1); + } +}; +``` + +- [ ] **Step 5: Run, confirm pass** + +Run: `cd packages/cli && npx vitest run src/serve/runQwenServe.test.ts` +Expected: full file PASS. + +Run also: `cd packages/cli && npx vitest run src/serve/` (full serve dir, catches indirect regressions like server.test.ts assertions on stderr output). + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/serve/runQwenServe.ts packages/cli/src/serve/runQwenServe.test.ts +git commit -m "feat(serve): init daemonLogger in runQwenServe + flush on shutdown (#4548)" +``` + +--- + +## Task 11: Documentation + +**Files:** + +- Modify: existing serve docs (locate with `find docs -iname '*serve*'` and `ls docs/cli/`) + +- [ ] **Step 1: Find the right doc** + +```bash +find docs -iname '*serve*' -type f +ls docs/cli/ 2>/dev/null +``` + +Pick the most natural home — likely `docs/cli/serve.md`. If none exists for `qwen serve`, create `docs/cli/serve-daemon-log.md`. + +- [ ] **Step 2: Write the section** + +Add (or create) a "Daemon log file" section: + +```markdown +## Daemon log file + +`qwen serve` writes a per-process diagnostic log to: +``` + +${QWEN_RUNTIME_DIR or ~/.qwen}/debug/daemon/serve--.log + +``` + +A `latest` symlink in the same directory always points at the current +process's log, so `tail -f ~/.qwen/debug/daemon/latest` will follow whichever +daemon is running. + +The log captures lifecycle messages, route errors (with `route=` and +`sessionId=` context), ACP child stderr, and — when `QWEN_SERVE_DEBUG=1` +is set — extra bridge breadcrumbs. Lines that go to stderr today still +go to stderr; the file log is **additive**, not a replacement. + +### Disabling + +Set `QWEN_DAEMON_LOG_FILE=0` (or `false`/`off`/`no`) to skip file logging +entirely. Stderr output is unaffected. + +### Relation to session debug logs + +Session-scoped debug logs (`~/.qwen/debug/.txt` and the +`~/.qwen/debug/latest` symlink) are independent. The daemon log lives +in a sibling `daemon/` subdirectory; per-session debug semantics are +unchanged by this feature. + +### No rotation + +The daemon log appends indefinitely. Rotate manually if it grows large. +A future enhancement may add automatic rotation; track via #4548 +follow-ups. +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/cli/serve.md # or the actual file path +git commit -m "docs(serve): document daemon log file path and opt-out (#4548)" +``` + +--- + +## Task 12: Final verification + +- [ ] **Step 1: Full test sweep** + +```bash +cd /Users/jinye.djy/Projects/qwen-code/.claude/worktrees/feat-support-daemon-logger +npm run test --workspace=packages/acp-bridge +npm run test --workspace=packages/cli +``` + +Expected: all green. + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck --workspace=packages/acp-bridge +npm run typecheck --workspace=packages/cli +``` + +Expected: no errors. + +- [ ] **Step 3: Manual smoke** + +```bash +QWEN_RUNTIME_DIR=$(mktemp -d) node packages/cli/dist/index.js serve --port 0 --hostname 127.0.0.1 & +SERVE_PID=$! +sleep 1 +ls $QWEN_RUNTIME_DIR/debug/daemon/ +cat $QWEN_RUNTIME_DIR/debug/daemon/latest +kill -TERM $SERVE_PID +wait $SERVE_PID 2>/dev/null || true +cat $QWEN_RUNTIME_DIR/debug/daemon/latest # should now contain shutdown line +``` + +Expected: log file exists, contains `daemon started ...`, then after kill the `received SIGTERM, draining` line. + +If `packages/cli/dist/index.js` doesn't exist, build first: `npm run build --workspace=packages/cli`. + +- [ ] **Step 4: Open PR** + +```bash +git push -u origin HEAD +gh pr create --title "feat(serve): add daemon file logger (#4548)" --body "$(cat <<'EOF' +## Summary +- Adds a per-process daemon file logger at `~/.qwen/debug/daemon/serve--.log` (configurable via `QWEN_RUNTIME_DIR`, opt-out via `QWEN_DAEMON_LOG_FILE=0`). +- Routes `runQwenServe` lifecycle messages, `sendBridgeError` route errors, `writeServeDebugLine` debug breadcrumbs, and ACP child stderr into the daemon log without removing existing stderr output. +- Adds `BridgeOptions.onDiagnosticLine` and `createSpawnChannelFactory({ onDiagnosticLine })` to keep `acp-bridge` ignorant of cli. + +Closes #4548. + +## Test plan +- [x] New unit tests in `packages/cli/src/serve/daemonLogger.test.ts` cover formatter, file init, info/warn/error, raw, latest symlink, opt-out, degraded fallback. +- [x] `packages/acp-bridge/src/bridge.test.ts` covers `onDiagnosticLine` tee from `writeServeDebugLine`. +- [x] `packages/acp-bridge/src/spawnChannel.test.ts` covers child stderr forwarder. +- [x] `packages/cli/src/serve/server.test.ts` covers route-error routing through `daemonLog.error`. +- [x] `packages/cli/src/serve/runQwenServe.test.ts` covers boot banner + flush on shutdown. +- [x] Manual smoke: log file created at boot, contains shutdown line on SIGTERM. + +🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) +EOF +)" +``` + +--- + +## Self-review notes + +- **Spec coverage**: §3 module table covered by Tasks 1-10. §4 daemon-id + path → Task 3. §5 API surface → Tasks 1-6. §6 format + tee semantics → Task 1 (format), Task 4 (info/warn/error tee), Task 5 (raw file-only). §7 boot/shutdown → Task 10. §8 coverage table → Tasks 7/8/9/10. §9 write path & flush → Task 4. §10 config → Task 2 (opt-out), Task 11 (docs). §11 error handling → Tasks 3, 4. §12 testing → distributed across tasks. §13 docs → Task 11. §15 acceptance criteria → met by Tasks 3, 9, 8, 10, 10, 11 respectively. + +- **Trace context (§6 bullet)**: deferred. The spec leaves it explicit ("Helper extracted to a shared module ... or duplicated locally — leave to plan"). The current plan does NOT inject trace_id/span_id; that is a follow-up task tracked in §16. If reviewer pushes back, add a Task 4.5 that imports `trace` from `@opentelemetry/api` and folds the span context into `buildDaemonLogLine` — but only if the reviewer asks; YAGNI otherwise. + +- **`updateSymlink` import path**: Task 6 step 3 hedges on whether `updateSymlink` is exported from `@qwen-code/qwen-code-core`. Verify before editing: `grep -n updateSymlink packages/core/src/index.ts`. If missing, add the re-export in the same commit as Task 6. + +- **acp-bridge test for `createSpawnChannelFactory`**: spawning a real child in a unit test is brittle. If Task 8 step 2 turns out to be flaky in CI, the fallback is to refactor the inner stderr forwarder into a small exported helper (`forwardChildStderr(stream, { prefix, onLine })`) and unit-test that in isolation — no real spawn needed. diff --git a/docs/superpowers/plans/2026-05-27-daemon-workspace-service.md b/docs/superpowers/plans/2026-05-27-daemon-workspace-service.md new file mode 100644 index 0000000000..7e9a683bdd --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-daemon-workspace-service.md @@ -0,0 +1,1236 @@ +# DaemonWorkspaceService Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract all workspace-scoped capabilities from HttpAcpBridge into a new DaemonWorkspaceService, enabling /acp transport parity and honest rename to AcpSessionBridge. + +**Architecture:** Scope-based split — workspace-scoped ops go to a new facade (DaemonWorkspaceService) with 4 internal sub-services; session-scoped ops stay in bridge. Child-dependent workspace ops delegate via injected callbacks. Both REST and /acp call the same L2 service. + +**Tech Stack:** TypeScript, Vitest, Express (REST routes), JSON-RPC (ACP), supertest (integration) + +**Spec:** `docs/superpowers/specs/2026-05-27-daemon-workspace-service-design.md` + +--- + +## File Map + +### New Files +| File | Responsibility | +|---|---| +| `packages/cli/src/serve/workspace-service/types.ts` | WorkspaceRequestContext, sub-service interfaces, deps interface, result types | +| `packages/cli/src/serve/workspace-service/index.ts` | Facade factory `createDaemonWorkspaceService` | +| `packages/cli/src/serve/workspace-service/fileService.ts` | FileService — wraps fsFactory | +| `packages/cli/src/serve/workspace-service/authService.ts` | AuthService — wraps DeviceFlowRegistry | +| `packages/cli/src/serve/workspace-service/agentsService.ts` | AgentsService — wraps SubagentManager | +| `packages/cli/src/serve/workspace-service/memoryService.ts` | MemoryService — wraps memory file ops | +| `packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts` | FileService unit tests | +| `packages/cli/src/serve/workspace-service/__tests__/authService.test.ts` | AuthService unit tests | +| `packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts` | AgentsService unit tests | +| `packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts` | MemoryService unit tests | +| `packages/cli/src/serve/workspace-service/__tests__/facade.test.ts` | Facade + workspace-scoped methods (status/tool/init/restart) unit tests | +| `packages/cli/src/serve/workspace-service/__tests__/e2e.test.ts` | REST ↔ /acp equivalence e2e tests | + +### Modified Files +| File | Change | +|---|---| +| `packages/acp-bridge/src/bridgeTypes.ts` | Rename interface + remove 8 methods + add 2 new methods | +| `packages/acp-bridge/src/bridge.ts` | Remove 8 workspace methods, expose `queryWorkspaceStatus` + `invokeWorkspaceCommand`, rename factory | +| `packages/acp-bridge/src/bridgeOptions.ts` | Update JSDoc references | +| `packages/acp-bridge/src/status.ts` | Update error message class name | +| `packages/cli/src/serve/httpAcpBridge.ts` → rename to `acpSessionBridge.ts` | Update re-exports | +| `packages/cli/src/serve/runQwenServe.ts` | Construct workspace service, inject callbacks | +| `packages/cli/src/serve/server.ts` | Rewire workspace routes to call service | +| `packages/cli/src/serve/workspaceAgents.ts` | Extract business logic → agentsService, keep as route shell | +| `packages/cli/src/serve/workspaceMemory.ts` | Extract business logic → memoryService, keep as route shell | +| `packages/cli/src/serve/routes/workspaceFileRead.ts` | Rewire to call FileService | +| `packages/cli/src/serve/routes/workspaceFileWrite.ts` | Rewire to call FileService | + +--- + +## Task 1: Types & Interfaces + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/types.ts` + +- [ ] **Step 1: Create types file with all interfaces** + +```ts +// packages/cli/src/serve/workspace-service/types.ts +import type { WorkspaceFileSystemFactory } from '../fs/index.js'; +import type { DeviceFlowRegistry } from '../auth/deviceFlow.js'; +import type { + ServeWorkspaceMcpStatus, + ServeWorkspaceSkillsStatus, + ServeWorkspaceProvidersStatus, + ServeWorkspaceEnvStatus, + ServeWorkspacePreflightStatus, +} from '@qwen-code/acp-bridge'; + +// --- Request Context --- + +export interface WorkspaceRequestContext { + originatorClientId?: string; + sessionId?: string; + route: string; + workspaceCwd: string; +} + +// --- Sub-service interfaces --- + +export interface FileService { + read(ctx: WorkspaceRequestContext, path: string, opts?: { maxBytes?: number }): Promise; + readBytes(ctx: WorkspaceRequestContext, path: string): Promise; + write(ctx: WorkspaceRequestContext, path: string, content: string, opts?: { mode?: string }): Promise; + edit(ctx: WorkspaceRequestContext, path: string, edits: FileEdit[]): Promise; + glob(ctx: WorkspaceRequestContext, pattern: string): Promise; + list(ctx: WorkspaceRequestContext, path: string): Promise; + stat(ctx: WorkspaceRequestContext, path: string): Promise; +} + +export interface AuthService { + startFlow(ctx: WorkspaceRequestContext): Promise; + getFlowStatus(ctx: WorkspaceRequestContext, flowId: string): Promise; + cancelFlow(ctx: WorkspaceRequestContext, flowId: string): Promise; + getAuthStatus(ctx: WorkspaceRequestContext): Promise; +} + +export interface AgentsService { + list(ctx: WorkspaceRequestContext): Promise; + get(ctx: WorkspaceRequestContext, agentType: string): Promise; + create(ctx: WorkspaceRequestContext, spec: AgentCreateSpec): Promise; + update(ctx: WorkspaceRequestContext, agentType: string, spec: AgentUpdateSpec): Promise; + delete(ctx: WorkspaceRequestContext, agentType: string, opts?: { scope?: string }): Promise; +} + +export interface MemoryService { + list(ctx: WorkspaceRequestContext): Promise; + read(ctx: WorkspaceRequestContext, key: string): Promise; + write(ctx: WorkspaceRequestContext, key: string, content: string): Promise; + delete(ctx: WorkspaceRequestContext, key: string): Promise; +} + +// --- Facade interface --- + +export interface DaemonWorkspaceService { + file: FileService; + auth: AuthService; + agents: AgentsService; + memory: MemoryService; + + initWorkspace(opts: InitWorkspaceOpts, ctx: WorkspaceRequestContext): Promise; + setToolEnabled(toolName: string, enabled: boolean, ctx: WorkspaceRequestContext): Promise; + + getMcpStatus(): Promise; + getSkillsStatus(): Promise; + getProvidersStatus(): Promise; + getEnvStatus(): Promise; + getPreflightStatus(): Promise; + restartMcpServer(serverName: string, ctx: WorkspaceRequestContext, opts?: RestartMcpOpts): Promise; +} + +// --- Deps (callback injection) --- + +export interface WorkspaceEvent { + type: string; + data: Record; + originatorClientId?: string; +} + +export interface DaemonWorkspaceServiceDeps { + fsFactory: WorkspaceFileSystemFactory; + deviceFlowRegistry: DeviceFlowRegistry; + subagentManager: unknown; // type from workspaceAgents.ts — refine during implementation + boundWorkspace: string; + contextFilename: string; + persistDisabledTools: (workspace: string, tool: string, enabled: boolean) => Promise; + + // Cross-cutting callbacks (session-derived infrastructure) + publishWorkspaceEvent: (event: WorkspaceEvent) => void; + knownClientIds: () => Set; + + // Child delegation callbacks + queryWorkspaceStatus: (method: string, idle: () => T) => Promise; + invokeWorkspaceCommand: (method: string, params?: Record, opts?: { timeoutMs?: number }) => Promise; +} + +// --- Result types (refine from existing code during implementation) --- + +export interface FileReadResult { content: string; truncated: boolean; bytesRead: number; } +export interface FileWriteResult { ok: boolean; filePath: string; bytesWritten: number; mode?: string; } +export interface FileEdit { oldText: string; newText: string; } +export interface FileEditResult { ok: boolean; filePath: string; } +export interface ListEntry { name: string; type: 'file' | 'directory' | 'symlink'; } +export interface StatResult { exists: boolean; isFile: boolean; isDirectory: boolean; size: number; } +export interface DeviceFlowStartResult { flowId: string; verificationUri: string; userCode: string; } +export interface DeviceFlowStatus { state: string; /* refine from existing types */ } +export interface AuthStatusResult { authenticated: boolean; /* refine from existing */ } +export interface AgentSummary { agentType: string; /* refine */ } +export interface AgentDetail { agentType: string; /* refine */ } +export interface AgentCreateSpec { agentType: string; content: string; /* refine */ } +export interface AgentUpdateSpec { content: string; /* refine */ } +export interface MemoryEntry { key: string; /* refine */ } +export interface MemoryContent { key: string; content: string; } +export interface InitWorkspaceOpts { /* refine from bridge.ts:3256 */ } +export interface ToolToggleResult { toolName: string; enabled: boolean; } +export interface RestartMcpOpts { entryIndex?: number; } +export interface RestartMcpResult { serverName: string; restarted: boolean; durationMs?: number; } +``` + +> **Note:** Result types marked `/* refine */` should be aligned with existing response shapes during implementation. Read the current route handlers to get exact fields. + +- [ ] **Step 2: Verify types compile** + +Run: `cd packages/cli && npx tsc --noEmit src/serve/workspace-service/types.ts` +Expected: No errors (may need to adjust imports based on actual export paths) + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/types.ts +git commit -m "feat(serve): add DaemonWorkspaceService type definitions" +``` + +--- + +## Task 2: FileService (TDD) + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts` +- Create: `packages/cli/src/serve/workspace-service/fileService.ts` + +- [ ] **Step 1: Write failing tests for FileService.read** + +```ts +// packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { createFileService } from '../fileService.js'; +import type { WorkspaceRequestContext } from '../types.js'; + +function makeCtx(overrides: Partial = {}): WorkspaceRequestContext { + return { route: 'GET /file', workspaceCwd: '/workspace', ...overrides }; +} + +describe('FileService', () => { + describe('read', () => { + it('calls fsFactory.forRequest with context and delegates to readFile', async () => { + const mockFs = { readFile: vi.fn().mockResolvedValue({ content: 'hello', truncated: false, bytesRead: 5 }) }; + const fsFactory = { forRequest: vi.fn().mockReturnValue(mockFs) }; + const service = createFileService({ fsFactory: fsFactory as any, boundWorkspace: '/workspace' }); + + const result = await service.read(makeCtx({ originatorClientId: 'c1' }), 'src/app.ts'); + + expect(fsFactory.forRequest).toHaveBeenCalledWith({ + originatorClientId: 'c1', + route: 'GET /file', + }); + expect(mockFs.readFile).toHaveBeenCalledWith('src/app.ts', undefined); + expect(result.content).toBe('hello'); + }); + + it('works without originatorClientId (read-only, no auth required)', async () => { + const mockFs = { readFile: vi.fn().mockResolvedValue({ content: '', truncated: false, bytesRead: 0 }) }; + const fsFactory = { forRequest: vi.fn().mockReturnValue(mockFs) }; + const service = createFileService({ fsFactory: fsFactory as any, boundWorkspace: '/workspace' }); + + await service.read(makeCtx(), 'README.md'); + + expect(fsFactory.forRequest).toHaveBeenCalledWith({ + originatorClientId: undefined, + route: 'GET /file', + }); + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/fileService.test.ts` +Expected: FAIL — `createFileService` not found + +- [ ] **Step 3: Implement FileService** + +```ts +// packages/cli/src/serve/workspace-service/fileService.ts +import type { WorkspaceFileSystemFactory } from '../fs/index.js'; +import type { FileService, WorkspaceRequestContext, FileReadResult, FileWriteResult, FileEdit, FileEditResult, ListEntry, StatResult } from './types.js'; + +export interface FileServiceDeps { + fsFactory: WorkspaceFileSystemFactory; + boundWorkspace: string; +} + +export function createFileService(deps: FileServiceDeps): FileService { + const { fsFactory } = deps; + + function scopedFs(ctx: WorkspaceRequestContext) { + return fsFactory.forRequest({ + originatorClientId: ctx.originatorClientId, + route: ctx.route, + ...(ctx.sessionId ? { sessionId: ctx.sessionId } : {}), + }); + } + + return { + async read(ctx, path, opts) { + const fs = scopedFs(ctx); + return fs.readFile(path, opts?.maxBytes); + }, + async readBytes(ctx, path) { + const fs = scopedFs(ctx); + return fs.readFileBytes(path); + }, + async write(ctx, path, content, opts) { + const fs = scopedFs(ctx); + return fs.writeFile(path, content, opts); + }, + async edit(ctx, path, edits) { + const fs = scopedFs(ctx); + return fs.editFile(path, edits); + }, + async glob(ctx, pattern) { + const fs = scopedFs(ctx); + return fs.glob(pattern); + }, + async list(ctx, path) { + const fs = scopedFs(ctx); + return fs.listDirectory(path); + }, + async stat(ctx, path) { + const fs = scopedFs(ctx); + return fs.stat(path); + }, + }; +} +``` + +> **Important:** The method names on `WorkspaceFileSystem` (`readFile`, `readFileBytes`, `writeFile`, `editFile`, `glob`, `listDirectory`, `stat`) must be verified against the actual interface at `packages/cli/src/serve/fs/workspaceFileSystem.ts`. Adjust if they differ. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/fileService.test.ts` +Expected: PASS + +- [ ] **Step 5: Add tests for write (trust gate validates clientId when present)** + +Add to the test file: +```ts + describe('write', () => { + it('passes originatorClientId to forRequest for audit', async () => { + const mockFs = { writeFile: vi.fn().mockResolvedValue({ ok: true, filePath: '/workspace/f.ts', bytesWritten: 3 }) }; + const fsFactory = { forRequest: vi.fn().mockReturnValue(mockFs) }; + const service = createFileService({ fsFactory: fsFactory as any, boundWorkspace: '/workspace' }); + + await service.write(makeCtx({ originatorClientId: 'c1', route: 'POST /file/write' }), 'f.ts', 'abc'); + + expect(fsFactory.forRequest).toHaveBeenCalledWith({ + originatorClientId: 'c1', + route: 'POST /file/write', + }); + expect(mockFs.writeFile).toHaveBeenCalledWith('f.ts', 'abc', undefined); + }); + }); +``` + +- [ ] **Step 6: Run full FileService tests** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/fileService.test.ts` +Expected: All PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/fileService.ts packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts +git commit -m "feat(serve): add FileService wrapping fsFactory (TDD)" +``` + +--- + +## Task 3: AuthService (TDD) + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/__tests__/authService.test.ts` +- Create: `packages/cli/src/serve/workspace-service/authService.ts` + +- [ ] **Step 1: Read existing auth route logic** + +Read: `packages/cli/src/serve/server.ts:794-966` (device flow routes) and `packages/cli/src/serve/auth/deviceFlow.ts` to understand the DeviceFlowRegistry interface. + +- [ ] **Step 2: Write failing test** + +```ts +// packages/cli/src/serve/workspace-service/__tests__/authService.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { createAuthService } from '../authService.js'; +import type { WorkspaceRequestContext } from '../types.js'; + +const ctx: WorkspaceRequestContext = { route: 'POST /workspace/auth/device-flow', workspaceCwd: '/w' }; + +describe('AuthService', () => { + it('startFlow delegates to registry.start and returns flowId + verificationUri + userCode', async () => { + const registry = { + start: vi.fn().mockReturnValue({ id: 'flow-1', verificationUri: 'https://auth.example/device', userCode: 'ABCD-1234' }), + }; + const service = createAuthService({ deviceFlowRegistry: registry as any }); + + const result = await service.startFlow(ctx); + + expect(registry.start).toHaveBeenCalled(); + expect(result.flowId).toBe('flow-1'); + expect(result.verificationUri).toBe('https://auth.example/device'); + }); + + it('cancelFlow delegates to registry.cancel', async () => { + const registry = { cancel: vi.fn().mockReturnValue({ cancelled: true }) }; + const service = createAuthService({ deviceFlowRegistry: registry as any }); + + await service.cancelFlow(ctx, 'flow-1'); + + expect(registry.cancel).toHaveBeenCalledWith('flow-1', undefined); + }); +}); +``` + +- [ ] **Step 3: Run test — verify fail** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/authService.test.ts` +Expected: FAIL + +- [ ] **Step 4: Implement AuthService** + +```ts +// packages/cli/src/serve/workspace-service/authService.ts +import type { DeviceFlowRegistry } from '../auth/deviceFlow.js'; +import type { AuthService, WorkspaceRequestContext, DeviceFlowStartResult, DeviceFlowStatus, AuthStatusResult } from './types.js'; + +export interface AuthServiceDeps { + deviceFlowRegistry: DeviceFlowRegistry; +} + +export function createAuthService(deps: AuthServiceDeps): AuthService { + const { deviceFlowRegistry } = deps; + + return { + async startFlow(ctx) { + const flow = deviceFlowRegistry.start(ctx.originatorClientId); + return { flowId: flow.id, verificationUri: flow.verificationUri, userCode: flow.userCode }; + }, + async getFlowStatus(ctx, flowId) { + return deviceFlowRegistry.get(flowId); + }, + async cancelFlow(ctx, flowId) { + deviceFlowRegistry.cancel(flowId, ctx.originatorClientId); + }, + async getAuthStatus(_ctx) { + return deviceFlowRegistry.getStatus(); + }, + }; +} +``` + +> **Note:** Method names on `DeviceFlowRegistry` (`start`, `get`, `cancel`, `getStatus`) must be verified against `packages/cli/src/serve/auth/deviceFlow.ts`. Adjust signatures as needed. + +- [ ] **Step 5: Run test — verify pass** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/authService.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/authService.ts packages/cli/src/serve/workspace-service/__tests__/authService.test.ts +git commit -m "feat(serve): add AuthService wrapping DeviceFlowRegistry (TDD)" +``` + +--- + +## Task 4: AgentsService (TDD) + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts` +- Create: `packages/cli/src/serve/workspace-service/agentsService.ts` + +- [ ] **Step 1: Read existing agent logic** + +Read: `packages/cli/src/serve/workspaceAgents.ts` — extract the business logic (validation, SubagentManager calls, event publishing). Note: this file is ~700+ lines with route handling mixed in. + +- [ ] **Step 2: Write failing test — list + clientId validation** + +```ts +// packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { createAgentsService } from '../agentsService.js'; +import type { WorkspaceRequestContext } from '../types.js'; + +const ctx: WorkspaceRequestContext = { route: 'GET /workspace/agents', workspaceCwd: '/w', originatorClientId: 'c1' }; + +describe('AgentsService', () => { + it('list returns agents from subagentManager', async () => { + const subagentManager = { list: vi.fn().mockResolvedValue([{ agentType: 'reviewer' }]) }; + const deps = { + subagentManager, + publishWorkspaceEvent: vi.fn(), + knownClientIds: () => new Set(['c1']), + }; + const service = createAgentsService(deps as any); + + const result = await service.list(ctx); + + expect(result).toEqual([{ agentType: 'reviewer' }]); + }); + + it('create publishes workspace event after success', async () => { + const subagentManager = { create: vi.fn().mockResolvedValue({ agentType: 'helper', content: '...' }) }; + const publishWorkspaceEvent = vi.fn(); + const deps = { + subagentManager, + publishWorkspaceEvent, + knownClientIds: () => new Set(['c1']), + }; + const service = createAgentsService(deps as any); + + await service.create(ctx, { agentType: 'helper', content: 'prompt' }); + + expect(publishWorkspaceEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'agent_created' })); + }); + + it('rejects unknown clientId on mutation', async () => { + const deps = { + subagentManager: { create: vi.fn() }, + publishWorkspaceEvent: vi.fn(), + knownClientIds: () => new Set(['c2']), // c1 not in set + }; + const service = createAgentsService(deps as any); + + await expect(service.create(ctx, { agentType: 'x', content: '' })) + .rejects.toThrow(/not registered/); + }); +}); +``` + +- [ ] **Step 3: Run test — verify fail** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/agentsService.test.ts` +Expected: FAIL + +- [ ] **Step 4: Implement AgentsService** + +Extract business logic from `packages/cli/src/serve/workspaceAgents.ts` into: +```ts +// packages/cli/src/serve/workspace-service/agentsService.ts +import type { AgentsService, WorkspaceRequestContext, WorkspaceEvent } from './types.js'; + +export interface AgentsServiceDeps { + subagentManager: any; // refine type from workspaceAgents.ts + publishWorkspaceEvent: (event: WorkspaceEvent) => void; + knownClientIds: () => Set; +} + +function validateClientId(deps: AgentsServiceDeps, ctx: WorkspaceRequestContext): void { + if (ctx.originatorClientId && !deps.knownClientIds().has(ctx.originatorClientId)) { + throw new Error(`Client id "${ctx.originatorClientId}" is not registered for this workspace`); + } +} + +export function createAgentsService(deps: AgentsServiceDeps): AgentsService { + return { + async list(_ctx) { + return deps.subagentManager.list(); + }, + async get(_ctx, agentType) { + return deps.subagentManager.get(agentType); + }, + async create(ctx, spec) { + validateClientId(deps, ctx); + const result = await deps.subagentManager.create(spec); + deps.publishWorkspaceEvent({ + type: 'agent_created', + data: { agentType: spec.agentType }, + originatorClientId: ctx.originatorClientId, + }); + return result; + }, + async update(ctx, agentType, spec) { + validateClientId(deps, ctx); + const result = await deps.subagentManager.update(agentType, spec); + deps.publishWorkspaceEvent({ + type: 'agent_updated', + data: { agentType }, + originatorClientId: ctx.originatorClientId, + }); + return result; + }, + async delete(ctx, agentType, opts) { + validateClientId(deps, ctx); + await deps.subagentManager.delete(agentType, opts); + deps.publishWorkspaceEvent({ + type: 'agent_deleted', + data: { agentType }, + originatorClientId: ctx.originatorClientId, + }); + }, + }; +} +``` + +> **Important:** The actual SubagentManager interface and event types must be extracted from `workspaceAgents.ts` during implementation. The above is the pattern; exact method names/params will differ. + +- [ ] **Step 5: Run test — verify pass** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/agentsService.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/agentsService.ts packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts +git commit -m "feat(serve): add AgentsService with clientId validation and event publish (TDD)" +``` + +--- + +## Task 5: MemoryService (TDD) + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts` +- Create: `packages/cli/src/serve/workspace-service/memoryService.ts` + +- [ ] **Step 1: Read existing memory logic** + +Read: `packages/cli/src/serve/workspaceMemory.ts` — understand how memory CRUD works (likely file-based with `writeWorkspaceContextFile` or similar). + +- [ ] **Step 2: Write failing test** + +```ts +// packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { createMemoryService } from '../memoryService.js'; +import type { WorkspaceRequestContext } from '../types.js'; + +const ctx: WorkspaceRequestContext = { route: 'POST /workspace/memory', workspaceCwd: '/w', originatorClientId: 'c1' }; + +describe('MemoryService', () => { + it('write publishes workspace event', async () => { + const publishWorkspaceEvent = vi.fn(); + const deps = { + // mock whatever memory backend is used + publishWorkspaceEvent, + knownClientIds: () => new Set(['c1']), + boundWorkspace: '/w', + }; + const service = createMemoryService(deps as any); + + await service.write(ctx, 'user-prefs', 'dark mode'); + + expect(publishWorkspaceEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'memory_written' })); + }); + + it('rejects unknown clientId on write', async () => { + const deps = { + publishWorkspaceEvent: vi.fn(), + knownClientIds: () => new Set(['other']), + boundWorkspace: '/w', + }; + const service = createMemoryService(deps as any); + + await expect(service.write(ctx, 'key', 'val')).rejects.toThrow(/not registered/); + }); +}); +``` + +- [ ] **Step 3: Implement MemoryService** + +Extract logic from `packages/cli/src/serve/workspaceMemory.ts`. Pattern identical to AgentsService: validate clientId on mutations, delegate to backend, publish event. + +- [ ] **Step 4: Run tests — verify pass** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/memoryService.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/memoryService.ts packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts +git commit -m "feat(serve): add MemoryService with event publish (TDD)" +``` + +--- + +## Task 6: Facade + Workspace-Scoped Methods (TDD) + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/__tests__/facade.test.ts` +- Create: `packages/cli/src/serve/workspace-service/index.ts` + +- [ ] **Step 1: Write failing test for facade construction + status delegation** + +```ts +// packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { createDaemonWorkspaceService } from '../index.js'; +import type { WorkspaceRequestContext } from '../types.js'; + +const ctx: WorkspaceRequestContext = { route: 'POST /workspace/init', workspaceCwd: '/w' }; + +describe('DaemonWorkspaceService', () => { + function makeDeps(overrides = {}) { + return { + fsFactory: { forRequest: vi.fn().mockReturnValue({}) }, + deviceFlowRegistry: {}, + subagentManager: {}, + boundWorkspace: '/w', + contextFilename: 'QWEN.md', + persistDisabledTools: vi.fn(), + publishWorkspaceEvent: vi.fn(), + knownClientIds: () => new Set(), + queryWorkspaceStatus: vi.fn().mockImplementation((_m, idle) => Promise.resolve(idle())), + invokeWorkspaceCommand: vi.fn(), + ...overrides, + }; + } + + it('exposes file, auth, agents, memory sub-services', () => { + const service = createDaemonWorkspaceService(makeDeps()); + expect(service.file).toBeDefined(); + expect(service.auth).toBeDefined(); + expect(service.agents).toBeDefined(); + expect(service.memory).toBeDefined(); + }); + + it('getMcpStatus delegates to queryWorkspaceStatus callback', async () => { + const idle = { servers: [] }; + const queryWorkspaceStatus = vi.fn().mockResolvedValue(idle); + const service = createDaemonWorkspaceService(makeDeps({ queryWorkspaceStatus })); + + const result = await service.getMcpStatus(); + + expect(queryWorkspaceStatus).toHaveBeenCalled(); + expect(result).toBe(idle); + }); + + it('setToolEnabled calls persistDisabledTools + publishes event', async () => { + const persistDisabledTools = vi.fn().mockResolvedValue(undefined); + const publishWorkspaceEvent = vi.fn(); + const service = createDaemonWorkspaceService(makeDeps({ persistDisabledTools, publishWorkspaceEvent })); + + const result = await service.setToolEnabled('Bash', false, ctx); + + expect(persistDisabledTools).toHaveBeenCalledWith('/w', 'Bash', false); + expect(publishWorkspaceEvent).toHaveBeenCalledWith(expect.objectContaining({ + type: 'tool_toggled', + data: { toolName: 'Bash', enabled: false }, + })); + expect(result).toEqual({ toolName: 'Bash', enabled: false }); + }); +}); +``` + +- [ ] **Step 2: Run test — verify fail** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/facade.test.ts` +Expected: FAIL + +- [ ] **Step 3: Implement facade factory** + +```ts +// packages/cli/src/serve/workspace-service/index.ts +import type { DaemonWorkspaceService, DaemonWorkspaceServiceDeps } from './types.js'; +import { createFileService } from './fileService.js'; +import { createAuthService } from './authService.js'; +import { createAgentsService } from './agentsService.js'; +import { createMemoryService } from './memoryService.js'; +import { SERVE_STATUS_EXT_METHODS } from '@qwen-code/acp-bridge'; + +export { type DaemonWorkspaceService, type DaemonWorkspaceServiceDeps, type WorkspaceRequestContext } from './types.js'; + +export function createDaemonWorkspaceService(deps: DaemonWorkspaceServiceDeps): DaemonWorkspaceService { + const file = createFileService({ fsFactory: deps.fsFactory, boundWorkspace: deps.boundWorkspace }); + const auth = createAuthService({ deviceFlowRegistry: deps.deviceFlowRegistry }); + const agents = createAgentsService({ + subagentManager: deps.subagentManager, + publishWorkspaceEvent: deps.publishWorkspaceEvent, + knownClientIds: deps.knownClientIds, + }); + const memory = createMemoryService({ + publishWorkspaceEvent: deps.publishWorkspaceEvent, + knownClientIds: deps.knownClientIds, + boundWorkspace: deps.boundWorkspace, + }); + + return { + file, + auth, + agents, + memory, + + async initWorkspace(opts, ctx) { + // Migrate logic from bridge.ts:3256 — local file creation via fsFactory + const fs = deps.fsFactory.forRequest({ originatorClientId: ctx.originatorClientId, route: ctx.route }); + // ... path validation + file creation (copy from bridge.ts:3256-3350) + }, + + async setToolEnabled(toolName, enabled, ctx) { + await deps.persistDisabledTools(deps.boundWorkspace, toolName, enabled); + deps.publishWorkspaceEvent({ + type: 'tool_toggled', + data: { toolName, enabled }, + ...(ctx.originatorClientId ? { originatorClientId: ctx.originatorClientId } : {}), + }); + return { toolName, enabled }; + }, + + async getMcpStatus() { + return deps.queryWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceMcp, () => createIdleMcpStatus(deps.boundWorkspace)); + }, + async getSkillsStatus() { + return deps.queryWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceSkills, () => ({ skills: [] })); + }, + async getProvidersStatus() { + return deps.queryWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceProviders, () => ({ providers: [] })); + }, + async getEnvStatus() { + return deps.queryWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceEnv, () => ({ env: [] })); + }, + async getPreflightStatus() { + return deps.queryWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspacePreflight, () => ({ checks: [] })); + }, + + async restartMcpServer(serverName, ctx, opts) { + const params: Record = { serverName }; + if (opts?.entryIndex !== undefined) params['entryIndex'] = opts.entryIndex; + const result = await deps.invokeWorkspaceCommand( + SERVE_STATUS_EXT_METHODS.workspaceMcpRestart ?? 'qwen/control/workspace/mcp/restart', + params, + ); + deps.publishWorkspaceEvent({ + type: 'mcp_server_restarted', + data: { serverName, ...(result as object) }, + ...(ctx.originatorClientId ? { originatorClientId: ctx.originatorClientId } : {}), + }); + return result as any; + }, + }; +} +``` + +> **Critical:** `initWorkspace` implementation must be copied from `bridge.ts:3256-3350` (path validation, symlink checks, file creation). Use `fsFactory.forRequest(ctx)` instead of raw `node:fs/promises` — this fixes the existing FIXME. + +- [ ] **Step 4: Run test — verify pass** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/facade.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/index.ts packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +git commit -m "feat(serve): add DaemonWorkspaceService facade with status/tool/init/restart (TDD)" +``` + +--- + +## Task 7: Bridge — Expose Child Delegation + Remove Workspace Methods + +**Files:** +- Modify: `packages/acp-bridge/src/bridge.ts` +- Modify: `packages/acp-bridge/src/bridgeTypes.ts` + +- [ ] **Step 1: Add `queryWorkspaceStatus` and `invokeWorkspaceCommand` to bridge interface** + +In `packages/acp-bridge/src/bridgeTypes.ts`, add to the interface (which is still named `HttpAcpBridge` at this point): + +```ts + queryWorkspaceStatus(method: string, idle: () => T): Promise; + invokeWorkspaceCommand(method: string, params?: Record, opts?: { timeoutMs?: number }): Promise; +``` + +- [ ] **Step 2: Implement them in bridge.ts** + +In `packages/acp-bridge/src/bridge.ts`, add to the returned object (near the existing `requestWorkspaceStatus` usage): + +```ts + queryWorkspaceStatus(method, idle) { + return requestWorkspaceStatus(method, idle); + }, + invokeWorkspaceCommand(method, params, opts) { + const info = liveChannelInfo(); + if (!info) throw new SessionNotFoundError(`workspace-command:${method}`); + const timeout = opts?.timeoutMs ?? initTimeoutMs; + return withTimeout( + Promise.race([ + info.connection.extMethod(method, { ...params, cwd: boundWorkspace }), + getChannelClosedReject(info), + ]), + timeout, + method, + ) as Promise; + }, +``` + +- [ ] **Step 3: Remove the 8 workspace methods from bridge** + +Remove from bridge.ts: +- `initWorkspace` (lines ~3256-3550) +- `setWorkspaceToolEnabled` (lines ~3071-3093) +- `getWorkspaceMcpStatus` / `getWorkspaceSkillsStatus` / `getWorkspaceProvidersStatus` / `getWorkspaceEnvStatus` / `getWorkspacePreflightStatus` (lines ~2665-2790) +- `restartMcpServer` (lines ~3093-3256) + +Remove their signatures from `bridgeTypes.ts`. + +- [ ] **Step 4: Run bridge tests to verify nothing is broken** + +Run: `cd packages/acp-bridge && npx vitest run` +Expected: Some tests may reference removed methods — fix those (they should now test via the facade in integration). + +- [ ] **Step 5: Commit** + +```bash +git add packages/acp-bridge/src/bridge.ts packages/acp-bridge/src/bridgeTypes.ts +git commit -m "refactor(bridge): extract workspace methods, expose queryWorkspaceStatus + invokeWorkspaceCommand" +``` + +--- + +## Task 8: Bridge Rename (HttpAcpBridge → AcpSessionBridge) + +**Files:** +- Modify: `packages/acp-bridge/src/bridgeTypes.ts` +- Modify: `packages/acp-bridge/src/bridge.ts` +- Modify: `packages/acp-bridge/src/bridgeOptions.ts` +- Modify: `packages/acp-bridge/src/status.ts` +- Modify: `packages/acp-bridge/src/index.ts` +- Rename: `packages/cli/src/serve/httpAcpBridge.ts` → `packages/cli/src/serve/acpSessionBridge.ts` +- Modify: `packages/cli/src/serve/runQwenServe.ts` (import paths) +- Modify: all files importing `HttpAcpBridge` or `createHttpAcpBridge` + +- [ ] **Step 1: Rename interface + factory function in acp-bridge package** + +In `bridgeTypes.ts`: +```ts +// Before: export interface HttpAcpBridge { +// After: +export interface AcpSessionBridge { +``` + +In `bridge.ts`: +```ts +// Before: export function createHttpAcpBridge( +// After: +export function createAcpSessionBridge( +``` + +Add deprecated re-export for safety: +```ts +/** @deprecated Use AcpSessionBridge */ +export type HttpAcpBridge = AcpSessionBridge; +/** @deprecated Use createAcpSessionBridge */ +export const createHttpAcpBridge = createAcpSessionBridge; +``` + +- [ ] **Step 2: Rename file in cli package** + +```bash +git mv packages/cli/src/serve/httpAcpBridge.ts packages/cli/src/serve/acpSessionBridge.ts +``` + +- [ ] **Step 3: Update all imports project-wide** + +```bash +# Find and fix all references +grep -rn "HttpAcpBridge\|createHttpAcpBridge\|httpAcpBridge" packages/ --include="*.ts" | grep -v node_modules | grep -v ".test.ts" +``` + +Update each file to use new names. Key files: +- `packages/cli/src/serve/runQwenServe.ts` +- `packages/cli/src/serve/workspaceAgents.ts` +- `packages/cli/src/serve/workspaceMemory.ts` +- `packages/cli/src/serve/server.ts` +- `packages/acp-bridge/src/status.ts` (error message string) +- `packages/acp-bridge/src/bridgeOptions.ts` (JSDoc) + +- [ ] **Step 4: Run typecheck** + +Run: `cd packages/cli && npx tsc --noEmit && cd ../acp-bridge && npx tsc --noEmit` +Expected: No type errors + +- [ ] **Step 5: Run full test suites** + +Run: `cd packages/acp-bridge && npx vitest run && cd ../cli && npx vitest run` +Expected: All pass (tests still use deprecated alias or are updated) + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor(bridge): rename HttpAcpBridge → AcpSessionBridge" +``` + +--- + +## Task 9: Wire Service into runQwenServe + REST Routes + +**Files:** +- Modify: `packages/cli/src/serve/runQwenServe.ts` +- Modify: `packages/cli/src/serve/server.ts` +- Modify: `packages/cli/src/serve/workspaceAgents.ts` +- Modify: `packages/cli/src/serve/workspaceMemory.ts` +- Modify: `packages/cli/src/serve/routes/workspaceFileRead.ts` +- Modify: `packages/cli/src/serve/routes/workspaceFileWrite.ts` + +- [ ] **Step 1: Construct service in runQwenServe.ts** + +Add after bridge construction: +```ts +import { createDaemonWorkspaceService } from './workspace-service/index.js'; + +// After bridge is created: +const workspace = createDaemonWorkspaceService({ + fsFactory, + deviceFlowRegistry, + subagentManager, // from existing construction + boundWorkspace, + contextFilename, + persistDisabledTools, + publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event), + knownClientIds: () => bridge.knownClientIds(), + queryWorkspaceStatus: (method, idle) => bridge.queryWorkspaceStatus(method, idle), + invokeWorkspaceCommand: (method, params, opts) => bridge.invokeWorkspaceCommand(method, params, opts), +}); +``` + +Pass `workspace` to `createServeApp`. + +- [ ] **Step 2: Rewire workspace status routes in server.ts** + +Replace direct bridge calls with service calls: +```ts +// Before: +app.get('/workspace/mcp', async (_req, res) => { + res.status(200).json(await bridge.getWorkspaceMcpStatus()); +}); + +// After: +app.get('/workspace/mcp', async (_req, res) => { + res.status(200).json(await workspace.getMcpStatus()); +}); +``` + +Repeat for `/workspace/skills`, `/workspace/providers`, `/workspace/env`, `/workspace/preflight`, `/workspace/init`, tool toggle route. + +- [ ] **Step 3: Rewire workspaceAgents.ts route shell** + +Change `mountWorkspaceAgentsRoutes` to receive `workspace.agents` instead of `bridge`: +```ts +// deps.bridge.publishWorkspaceEvent → service handles internally +// deps.bridge.knownClientIds() → service handles internally +// Route handler becomes thin: parse request → build ctx → call service → send response +``` + +- [ ] **Step 4: Rewire workspaceMemory.ts route shell** + +Same pattern as agents. + +- [ ] **Step 5: Rewire file routes** + +`workspaceFileRead.ts` and `workspaceFileWrite.ts` — change from calling `fsFactory.forRequest` directly to calling `workspace.file.*`: +```ts +// Before: +const fs = getFsFactory(req, res); +const result = await fs.readFile(path, maxBytes); + +// After: +const ctx = buildRequestContext(req); +const result = await workspace.file.read(ctx, path, { maxBytes }); +``` + +- [ ] **Step 6: Run full test suite** + +Run: `cd packages/cli && npx vitest run` +Expected: All existing route tests pass (HTTP surface unchanged) + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "refactor(serve): wire DaemonWorkspaceService into REST routes" +``` + +--- + +## Task 10: /acp Northbound Method Dispatch + +**Files:** +- Modify: relevant `/acp` handler file (locate via `grep -rn "extMethod\|acpHttp\|acp-integration" packages/cli/src/`) +- Create or modify: northbound method dispatcher + +- [ ] **Step 1: Locate the /acp method dispatch entry point** + +```bash +grep -rn "method.*dispatch\|handleMethod\|jsonrpc.*method" packages/cli/src/acp-integration/ packages/cli/src/serve/ --include="*.ts" | grep -v test | head -20 +``` + +- [ ] **Step 2: Add workspace method dispatch** + +In the /acp handler that routes JSON-RPC methods, add a switch/map for `qwen/workspace/*`: + +```ts +// Pattern (exact location depends on codebase structure): +case 'qwen/workspace/fs/read': { + const ctx = buildAcpRequestContext(connection, 'qwen/workspace/fs/read'); + const { path } = params; + return workspace.file.read(ctx, path); +} +case 'qwen/workspace/fs/write': { + const ctx = buildAcpRequestContext(connection, 'qwen/workspace/fs/write'); + const { path, content, mode } = params; + return workspace.file.write(ctx, path, content, { mode }); +} +// ... all 27 methods +``` + +> Build a helper `buildAcpRequestContext` that extracts clientId from the ACP connection and constructs `WorkspaceRequestContext`. + +- [ ] **Step 3: Add capabilities advertisement** + +Ensure `_meta.qwen.methods` includes all `qwen/workspace/*` methods in the `initialize` response. + +- [ ] **Step 4: Run typecheck** + +Run: `cd packages/cli && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(serve): add /acp northbound workspace methods (27 qwen/workspace/* endpoints)" +``` + +--- + +## Task 11: E2e Equivalence Tests + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/__tests__/e2e.test.ts` + +- [ ] **Step 1: Build /acp test harness helper** + +```ts +// Helper for sending JSON-RPC to /acp endpoint via supertest +import request from 'supertest'; + +async function acpCall(app: any, method: string, params: Record = {}, token = 'test-token') { + const res = await request(app) + .post('/acp') + .set('Authorization', `Bearer ${token}`) + .set('Content-Type', 'application/json') + .send({ jsonrpc: '2.0', id: 1, method, params }); + return res.body; +} +``` + +- [ ] **Step 2: Write equivalence tests** + +```ts +// packages/cli/src/serve/workspace-service/__tests__/e2e.test.ts +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import { createServeApp } from '../../server.js'; +// ... setup with mocked bridge + workspace + +describe('REST ↔ /acp equivalence', () => { + let app: any; + + beforeAll(() => { + // Create app with both REST and /acp wired to same workspace service + app = createServeApp({ /* ... test deps */ }); + }); + + describe('file read', () => { + it('returns same content via both transports', async () => { + const restRes = await request(app).get('/file?path=README.md').set('Authorization', 'Bearer tok'); + const acpRes = await acpCall(app, 'qwen/workspace/fs/read', { path: 'README.md' }); + + expect(restRes.body.content).toBe(acpRes.result.content); + }); + }); + + describe('trust gate rejection', () => { + it('rejects invalid clientId via REST (400)', async () => { + const res = await request(app) + .post('/file/write') + .set('Authorization', 'Bearer tok') + .set('X-Qwen-Client-Id', 'unknown-client') + .send({ path: 'x.ts', content: 'y' }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_client_id'); + }); + + it('rejects invalid clientId via /acp (JSON-RPC error)', async () => { + const res = await acpCall(app, 'qwen/workspace/fs/write', { path: 'x.ts', content: 'y' }); + expect(res.error.code).toBe(-32602); + expect(res.error.message).toContain('invalid_client_id'); + }); + }); +}); +``` + +- [ ] **Step 3: Run e2e tests** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/e2e.test.ts` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/__tests__/e2e.test.ts +git commit -m "test(serve): add REST ↔ /acp equivalence e2e tests" +``` + +--- + +## Task 12: Final Verification + +- [ ] **Step 1: Run full typecheck across all packages** + +```bash +cd packages/acp-bridge && npx tsc --noEmit && cd ../cli && npx tsc --noEmit && cd ../sdk-typescript && npx tsc --noEmit +``` +Expected: No errors + +- [ ] **Step 2: Run full test suites** + +```bash +cd packages/acp-bridge && npx vitest run && cd ../cli && npx vitest run +``` +Expected: All pass. SDK tests should pass WITHOUT modification (REST surface unchanged). + +- [ ] **Step 3: Verify SDK tests pass unmodified** + +```bash +cd packages/sdk-typescript && npx vitest run +``` +Expected: All pass — confirms backward compatibility. + +- [ ] **Step 4: Run lint** + +```bash +cd packages/cli && npm run lint && cd ../acp-bridge && npm run lint +``` +Expected: No errors + +- [ ] **Step 5: Final commit (if any cleanup needed)** + +```bash +git status +# If clean, no commit needed. If lint fixes: +git add -A && git commit -m "chore: lint fixes" +``` + +- [ ] **Step 6: Verify git log is clean** + +```bash +git log --oneline -15 +``` + +Confirm commits tell a coherent story for the single-PR reviewer. diff --git a/docs/superpowers/specs/2026-05-26-daemon-logger-design.md b/docs/superpowers/specs/2026-05-26-daemon-logger-design.md new file mode 100644 index 0000000000..0a60e83730 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-daemon-logger-design.md @@ -0,0 +1,280 @@ +# `qwen serve` Daemon File Logger — Design + +- **Issue**: [QwenLM/qwen-code#4548](https://github.com/QwenLM/qwen-code/issues/4548) +- **Branch**: `feat/support_daemon_logger` +- **Status**: design approved, awaiting implementation plan +- **Date**: 2026-05-26 + +## 1. Problem + +`qwen serve` emits daemon-level diagnostics (lifecycle, route errors, ACP child stderr) to `process.stderr`. That works under systemd/Docker but is fragile for SDK / Desktop / local daemon use: when a client sees `POST /session/:id/prompt` return HTTP 500, the route + session + stack context is gone unless the operator manually redirected stderr. + +`createDebugLogger` (in `packages/core/src/utils/debugLogger.ts`) is session-scoped: it requires an active `DebugLogSession` and writes to `${runtimeBaseDir}/debug/.txt`. The serve daemon starts **before** any session exists, so daemon-level calls would silently no-op. It also can't be reused without changing the per-session `debug/latest` semantics. + +This design adds a daemon-specific file sink, additive to existing stderr behavior, so daemon diagnostics survive without shell redirection. + +## 2. Scope + +### In scope + +- A new logger initialized once per `runQwenServe` process. +- File at `${QWEN_RUNTIME_DIR or ~/.qwen}/debug/daemon/.log`, append mode. +- Tee of: + - `runQwenServe.ts` lifecycle / shutdown / signal messages + - `sendBridgeError` (`server.ts`) route errors + - `bridge.ts` `writeServeDebugLine` (when `QWEN_SERVE_DEBUG` is set) + - `spawnChannel.ts` ACP child stderr forwarding +- Opt-out via `QWEN_DAEMON_LOG_FILE=0|false|off|no`. +- `latest` symlink in the daemon dir for `tail -f`. +- Documentation in serve CLI docs. + +### Out of scope (non-goals from issue) + +- Replacing OpenTelemetry or adding daemon tracing. +- Structured enterprise error log export (issue #2014). +- Rotation or deletion of existing session debug logs. +- Log rotation / size cap for the daemon log itself (deferred to a follow-up PR). A boot-time stderr warning is emitted if the existing file is unusually large; no automatic action. + +## 3. Architecture + +### 3.1 Module boundaries + +| Layer | New / Changed | Responsibility | +| ------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/cli/src/serve/daemonLogger.ts` | **new** | Sink: init, format, append-to-file, tee-to-stderr, flush, latest-symlink | +| `packages/cli/src/serve/runQwenServe.ts` | changed | Init logger at boot; replace lifecycle `writeStderrLine` with `daemonLog.*`; `await flush()` on shutdown; pass `onDiagnosticLine` into bridge | +| `packages/cli/src/serve/server.ts` | changed | `sendBridgeError(...)` routes through `daemonLog.error(...)` | +| `packages/acp-bridge/src/types.ts` (`BridgeOptions`) | changed | Add optional `onDiagnosticLine?: (line: string, level?: 'info' \| 'warn' \| 'error') => void` | +| `packages/acp-bridge/src/bridge.ts:writeServeDebugLine` | changed | If `onDiagnosticLine` injected, tee the same line | +| `packages/acp-bridge/src/spawnChannel.ts` | changed | Child stderr forwarder tees each prefixed line into `onDiagnosticLine` | + +**Design intent**: `daemonLogger.ts` is single-file, cli-local, no global singleton. `acp-bridge` stays ignorant of cli — it only sees a callback. Dependency graph unchanged. + +### 3.2 No global singleton + +Logger is created in `runQwenServe`, passed by closure to internal serve modules that need it (or by callback to `acp-bridge`). Rationale: + +- Mirrors how `BridgeOptions` already injects dependencies. +- Avoids the cross-test state leaks `debugLogger` has hit historically (`resetDebugLoggingState()` exists for that reason). + +## 4. Daemon ID & File Path + +- Path: `Storage.getGlobalDebugDir() + '/daemon/.log'` + - Resolves to `${QWEN_RUNTIME_DIR or ~/.qwen}/debug/daemon/.log`. + - Reuses `Storage.getGlobalDebugDir()` so the runtime-dir override (env var, contextual) automatically applies. +- `daemon-id` = `serve-${pid}-${workspaceHash}` + - `workspaceHash` = `crypto.createHash('sha256').update(boundWorkspace).digest('hex').slice(0, 8)` + - `pid` disambiguates multiple daemons on the same workspace. + - `workspaceHash` is fixed-length, filename-safe, and stable for the same workspace path. +- `latest` symlink: `~/.qwen/debug/daemon/latest` → current process's log file. Updated on init using the existing `updateSymlink` helper (`packages/core/src/utils/symlink.ts`). Symlink failure is logged and ignored — does not degrade primary writes. Distinct from `${runtimeBaseDir}/debug/latest` (session-scoped) per non-goal. +- File mode: `'a'` (append on `O_APPEND | O_CREAT`). Existing files survive restarts for forensics. + +## 5. Public API + +```ts +// packages/cli/src/serve/daemonLogger.ts + +export interface DaemonLogContext { + route?: string; + sessionId?: string; + clientId?: string; + childPid?: number; + channelId?: string; + [key: string]: unknown; +} + +export interface DaemonLogger { + info(message: string, ctx?: DaemonLogContext): void; + warn(message: string, ctx?: DaemonLogContext): void; + /** + * `err.stack` is appended as indented continuation lines after the message. + * Both `err` and `ctx` are optional and independent. + */ + error(message: string, err?: Error | null, ctx?: DaemonLogContext): void; + /** + * File-only tee for lines whose caller is already writing to stderr + * (ACP child stderr forwarder, `writeServeDebugLine`). The line is + * appended to the daemon log under the standard ` [] [DAEMON] ` + * prefix; it is NOT echoed to stderr (which would double the operator's output). + */ + raw(line: string, level?: 'info' | 'warn' | 'error'): void; + /** Absolute path to the daemon log file. */ + getLogPath(): string; + /** `serve--`. */ + getDaemonId(): string; + /** Drain pending appends. Called from runQwenServe shutdown handler. */ + flush(): Promise; +} + +export interface InitDaemonLoggerOptions { + boundWorkspace: string; + pid?: number; // default process.pid + now?: () => Date; // default () => new Date() + stderr?: (line: string) => void; // default writeStderrLine + baseDir?: string; // default Storage.getGlobalDebugDir() +} + +export function initDaemonLogger(opts: InitDaemonLoggerOptions): DaemonLogger; +``` + +`initDaemonLogger` synchronously: + +1. Computes `daemonId` + log path. +2. `mkdirSync(parentDir, { recursive: true })` — fail → return no-op logger, write one stderr warning. Boot continues. +3. `appendFileSync(path, '\n', { flag: 'a' })` — writes `daemon started pid= workspace= version=` synchronously. This doubles as a writability probe; on EACCES/ENOSPC, fail-mode = no-op logger + one stderr warning. +4. Updates `latest` symlink (best-effort, errors swallowed). +5. Returns logger; subsequent `info/warn/error/raw` calls enqueue async `fs.promises.appendFile`. + +If `process.env['QWEN_DAEMON_LOG_FILE']` is one of `0|false|off|no`, `initDaemonLogger` short-circuits to a no-op logger before any filesystem call. + +## 6. Log Line Format + +Mirror `debugLogger.buildLogLine` for visual parity: + +``` +2026-05-26T03:14:15.926Z [ERROR] [DAEMON] [trace_id=... span_id=...] route=POST /session/:id/prompt sessionId=abc clientId=xyz daemon failed to ... + at fn (file.ts:42:7) + at ... +``` + +- Timestamp: ISO 8601, UTC. +- Level: `INFO` | `WARN` | `ERROR`. (No DEBUG initially — `QWEN_SERVE_DEBUG` flows in as `INFO` via `raw()`.) +- Tag: literal `DAEMON`. +- Trace context: `trace.getActiveSpan()` when available; same logic as `debugLogger.getActiveSpanTraceContext`. Helper extracted to a shared module (`packages/core/src/utils/traceContext.ts`?) or duplicated locally — leave to plan. +- Context fields: rendered as `key=value`, fixed order (`route`, `sessionId`, `clientId`, `childPid`, `channelId`), then any extra keys sorted lexicographically. Values containing whitespace or `=` are `JSON.stringify`-quoted. +- Error stack: appended as indented continuation lines after the message. +- `raw(line, level)` writes the line as-is after the standard prefix ` [] [DAEMON] `, no extra processing. + +**Tee semantics (important):** + +- `info` / `warn` / `error` write to **both** the daemon log file **and** stderr (via the injected `stderr` writer). Callers replacing a previous `writeStderrLine(...)` use these directly; no separate stderr call needed. +- `raw` writes to **file only**. Used by ACP child stderr forwarder and `writeServeDebugLine`, where the caller is already writing to stderr through its existing path. Doubling would flood operator output. + +## 7. Boot / Shutdown Flow + +``` +runQwenServe(opts): + ... + daemonLog = initDaemonLogger({ boundWorkspace }) + writeStderrLine(`qwen serve: daemon log → ${daemonLog.getLogPath()}`) + // boot banner is stderr-only to avoid the line referencing itself + + bridge = createHttpAcpBridge({ + ..., + onDiagnosticLine: (line, level) => daemonLog.raw(line, level), + }) + + app = createServeApp({ ..., daemonLog }) // injected for sendBridgeError + + shutdownHandler(signal): + daemonLog.warn(`shutdown signal=${signal}`) + await drainBridge() + await daemonLog.flush() + process.exit(0) +``` + +- Boot banner is stderr-only (the path line about itself would be circular if logged). +- `initDaemonLogger` is synchronous so any failure is visible immediately at boot, not buried after the first error. +- Shutdown `flush()` is the last awaited step before `process.exit`. SIGKILL is unflushable by definition — we accept that. + +## 8. Coverage Table + +| Source | Today | After | +| ------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `runQwenServe.ts` lifecycle / signals / config warnings | `writeStderrLine(...)` | `daemonLog.info \| warn(...)` (stderr still happens — `daemonLog` tees) | +| `runQwenServe.ts` "listening on URL" (stdout) | `writeStdoutLine(...)` | unchanged — operator scripts parse stdout | +| `server.ts:sendBridgeError` | `writeStderrLine(...)` with route/sessionId | `daemonLog.error(msg, err, { route, sessionId, ... })` (stderr still emitted by daemonLog's tee) | +| `bridge.ts:writeServeDebugLine` (`QWEN_SERVE_DEBUG`) | `writeStderrLine('qwen serve debug: ...')` | tee to `onDiagnosticLine(line, 'info')` | +| `spawnChannel.ts` child stderr | `process.stderr.write(prefix + line + '\n')` | also `onDiagnosticLine(prefix + line, 'warn')` | +| `writeStdoutLine` callers | unchanged | unchanged | +| CLI usage / argparse errors (`runQwenServe` early validation) | `writeStderrLine(...)` | unchanged (logger may not exist yet) | + +Every existing stderr write is preserved. Daemon log is **additive**, never substitutive. + +## 9. Write Path & Flush + +- Internal queue: a single `Promise` chain (`this.pending = this.pending.then(() => fs.promises.appendFile(...))`). +- Each `info/warn/error/raw` call enqueues an append (file) and, for `info/warn/error`, also synchronously calls the injected `stderr` writer. +- Stderr write order is preserved (synchronous, before queuing the append). File appends are eventually consistent in enqueue order. +- Write failures set an internal `degraded` flag and emit a one-time stderr warning. Subsequent calls still attempt the write but the counter is not maintained. +- `flush()` returns the current tail promise. +- No buffering layer: each call = one `appendFile`. Volume is low (route errors + lifecycle); micro-batching is premature optimization. + +## 10. Configuration + +| Env var | Behavior | +| ----------------------------------------------- | ---------------------------------------------------------------------------- | +| `QWEN_DAEMON_LOG_FILE=0\|false\|off\|no` | `initDaemonLogger` returns no-op; tee is a no-op; stderr unchanged | +| `QWEN_DAEMON_LOG_FILE=` or unset | Enabled (default) | +| `QWEN_RUNTIME_DIR=` | Relocates `~/.qwen` root, daemon log moves with it (existing semantics) | +| `QWEN_SERVE_DEBUG=1` | Existing — `writeServeDebugLine` activates; lines now also tee to daemon log | + +`QWEN_DAEMON_LOG_FILE` is intentionally separate from `QWEN_DEBUG_LOG_FILE` so disabling per-session debug logs doesn't take down the operator's daemon log (and vice versa). + +## 11. Error Handling + +- `initDaemonLogger` mkdir/open failure → no-op logger + one stderr warning. Daemon boot proceeds. Operator sees nothing in the file but still gets stderr. +- Per-append failures → flip degraded flag, emit one stderr warning, keep trying. Issue says nothing about a degraded-mode UI signal, so no public surface needed. +- `flush()` rejection → caught in shutdown handler, logged via `writeStderrLine`. Does not block exit. +- `latest` symlink failure → swallowed; primary writes unaffected. + +## 12. Testing + +### `daemonLogger.test.ts` (new) + +- Sandboxed `baseDir`, mocked `now`, `pid`, `stderr`. +- Path & daemon-id derivation including the 8-char `workspaceHash` for known input. +- `latest` symlink created and updated on subsequent `initDaemonLogger` invocations in the same dir. +- Level formatting (INFO/WARN/ERROR), context field order, error stack continuation. +- Trace context injection when an active span exists. +- `raw(line, level)` writes the prefixed line verbatim. +- `flush()` resolves only after all enqueued writes hit the file. +- `QWEN_DAEMON_LOG_FILE=0` → no file created. +- `mkdir` failure → no-op logger, one stderr warning, subsequent calls don't throw. +- `appendFile` failure → degraded flag flipped, one stderr warning. + +### `runQwenServe.test.ts` (extend) + +- Boot writes `daemon started ...` line to the log. +- Shutdown handler awaits `daemonLog.flush()` before exit. +- Stderr boot banner contains the daemon log path. + +### `server.test.ts` (extend) + +- A route that throws routes the error through `daemonLog.error(...)` with the right `route` and `sessionId`. + +### acp-bridge tests (extend) + +- `onDiagnosticLine` callback invoked from `writeServeDebugLine` when `QWEN_SERVE_DEBUG=1` and from `spawnChannel` child stderr forwarder. Tests inject a capturing fake; no filesystem. + +## 13. Documentation + +- `docs/cli/serve.md` (or wherever serve is documented) gains a "Daemon log file" section covering: path, daemon-id format, `latest` symlink, `QWEN_DAEMON_LOG_FILE` opt-out, distinction from per-session `debug/.txt`. +- README under `packages/cli/src/serve/` if one exists. +- No CHANGELOG-style file in this repo; release notes are handled separately. + +## 14. Rollback + +- Pure-additive change. Rollback = revert the commit: + - Delete `daemonLogger.ts` + its test. + - Revert `runQwenServe.ts` lifecycle / sendBridgeError / bridge / spawnChannel changes. + - Remove `onDiagnosticLine` from `BridgeOptions`. +- No on-disk state to clean up; existing daemon log files become orphaned but harmless. + +## 15. Acceptance Criteria (from issue) + +| Criterion | How met | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `qwen serve` creates / appends daemon log without shell redirection | `initDaemonLogger` opens the file at boot | +| HTTP 500 from `POST /session/:id/prompt` correlatable in daemon log | `sendBridgeError` writes `route=` + `sessionId=` | +| ACP child stderr lines also in daemon log | `spawnChannel` tees through `onDiagnosticLine` | +| Logging works before first session and after all sessions closed | Not session-scoped; lives for daemon lifetime | +| Existing stderr behavior intact | All writes are additive; no `writeStderrLine` call is removed without an equivalent left in place | +| Log path + opt-out documented | Docs section in §13 | + +## 16. Open Questions + +None blocking. Possible follow-ups: + +- Should `latest` symlink go in `~/.qwen/debug/daemon/latest` or `~/.qwen/debug/daemon-latest`? Spec picks the former for directory tidiness. +- Should we offer JSON-line output as a future flag (e.g., `QWEN_DAEMON_LOG_FORMAT=json`)? Out of scope for this PR; structured export is what #2014 owns. diff --git a/docs/superpowers/specs/2026-05-27-daemon-workspace-service-design.md b/docs/superpowers/specs/2026-05-27-daemon-workspace-service-design.md new file mode 100644 index 0000000000..3135a20f2c --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-daemon-workspace-service-design.md @@ -0,0 +1,434 @@ +# DaemonWorkspaceService 实施设计(方案 C) + +> 关联:issue #4542, PR #4472, #3803, #4175 +> 分支:`daemon_mode_b_main` +> 日期:2026-05-27 +> 性质:实施设计文档(面向落地),非 RFC + +--- + +> **落地范围说明(2026-05-31 更新,PR #4563)** +> +> 本文档描述的是**终态架构**。PR #4563 只落地其中一部分,其余为后续 PR 范围。阅读时请以下表为准,不要假设全部已实现: +> +> | 能力 | 本 PR (#4563) 状态 | +> | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +> | `HttpAcpBridge` → `AcpSessionBridge` 改名 | ✅ 已落地 | +> | bridge 暴露 `queryWorkspaceStatus` / `invokeWorkspaceCommand` 泛型委托 | ✅ 已落地 | +> | facade 的 workspace 级 **status / init / tool-toggle / mcp-restart** | ✅ 已落地并接线(server.ts + acpHttp dispatch 走 facade) | +> | **File / Auth / Agents / Memory 四个 sub-service** | ⏳ **deferred** —— 不在本 PR。连同各自的路由接线、`deviceFlowRegistry`/`subagentManager` 注入、e2e 测试一起在后续 PR 落地 | +> | `/workspace/memory`、`/workspace/agents` 等 REST 路由改调 facade | ⏳ **deferred** —— 当前仍由旧的 `workspaceMemory.ts` / `workspaceAgents.ts` 直接服务 | +> | `/acp` northbound `qwen/workspace/*` dispatch(§6) | ⏳ **deferred** | +> | `initWorkspace` 走 `fsFactory` / `WorkspaceFileSystem`(trust gate + audit) | ⏳ **deferred** —— 当前沿用旧 bridge 的 raw `node:fs` 实现(含 §SV TOCTOU/symlink 防护),无回归;fsFactory/audit 迁移留待后续 | +> +> 因此本文 §3.4(子服务接口)、§6(/acp northbound)、§7.1 中的 `e2e.test.ts`、§10 的 PR 形态描述均属**终态/未来范围**,本 PR 未实现。 + +--- + +## 1. 架构与边界 + +### 1.1 终态分层 + +``` + CLIENTS + webui SDK/channels(via REST) Zed/Goose(/acp) future + │ │ │ +═════╪═════════════╪═══════════════════════╪═════════════ L1 transport (薄) + REST+SSE REST+SSE /acp (jsonrpc/sse) + server.ts acpHttp/ + └─────────────┴───────────────────────┘ + │ 业务/trust/audit 一律下沉 L2 +═════════════════════════╪═══════════════════════════════ L2 应用层 + ┌──────────────────────────┐ ┌─────────────────────────────────┐ + │ AcpSessionBridge │ │ DaemonWorkspaceService (facade) │ + │ (← HttpAcpBridge 改名) │ │ ┌──────────────────────────┐ │ + │ • channel/session 生命周期 │ │ │ FileService │ │ + │ • prompt / cancel / close │ │ │ AuthService │ │ + │ • EventBus / 权限仲裁 │ │ │ AgentsService │ │ + │ • 依赖 child 的状态内省 │ │ │ MemoryService │ │ + │ (mcp/skills/preflight) │ │ └──────────────────────────┘ │ + └──────────┬───────────────┘ │ 统一 WorkspaceRequestContext │ + │ └──────────┬──────────────────────┘ + │ L3 → child │ + ▼ │ (纯本地,不碰 child) +══════════════════════════════════════════════════════════ L3 ACP-client +══════════════════════════════════════════════════════════ L4 agent +``` + +### 1.2 拆分判定函数 + +**唯一规则:操作的 scope 是 session 还是 workspace?** + +- **session-scoped**(操作特定 sessionId:prompt/cancel/close/model/approval/metadata/heartbeat)**→ 留 `AcpSessionBridge`** +- **workspace-scoped**(操作工作区整体:file/auth/agents/memory/mcp-status/skills/env/preflight/tool-toggle/init)**→ 进 `DaemonWorkspaceService`** + +workspace 方法中部分需要查询 child(status getters、restartMcpServer),通过 **injected callback** 委托 bridge 的 channel 完成,service 本身不持有 connection。 + +### 1.3 跨切依赖:callback 注入(非共享 infra) + +当前 `publishWorkspaceEvent` 和 `knownClientIds` 由 bridge 持有(per-session bus fan-out / session-derived)。service 通过 **单向 callback 注入** 使用它们,不引入共享基础设施层。 + +**理由:** + +1. EventBus 是 per-session bus(`bridge.ts:1457`),workspace-level bus 在代码注释中已挂在 PR 24(`bridge.ts:2611`) +2. `knownClientIds` 同样是派生自 session-attach state,注释明确 "PR 24 will replace it"(`bridge.ts:2658`) +3. 这两件是已立项独立工作,硬绑进本 PR 等于叠加额外 refactor +4. callback 注入对 service 是单向依赖(只持函数引用,不知道来自 bridge);PR 24 落地后换注入源即可,service 接口不变 + +**硬规则:** + +1. `DaemonWorkspaceServiceDeps` 中不得出现 `AcpSessionBridge` 类型引用——只用函数签名。 +2. bridge 对外新暴露 `queryWorkspaceStatus` 和 `invokeWorkspaceCommand` 两个方法,供 service 通过 callback 调用。内部仍使用现有的 `requestWorkspaceStatus` / `liveChannelInfo` + timeout 逻辑,不新建抽象。 + +--- + +## 2. 构造时序与依赖注入 + +```ts +// runQwenServe.ts 中的构造顺序 + +// 1. fsFactory 先构造(两者共享) +const fsFactory = resolveBridgeFsFactory({ ... }); + +// 2. bridge 先构造(它是 session/channel/EventBus 的 owner) +const bridge = createAcpSessionBridge({ + eventRingSize, + boundWorkspace, + fileSystem: createBridgeFileSystemAdapter(fsFactory), + // ... 其他现有参数不变 +}); + +// 3. service 后构造,接收 bridge 的 callback 集 +const workspace = createDaemonWorkspaceService({ + fsFactory, + deviceFlowRegistry, + subagentManager, + boundWorkspace, + contextFilename, + // 跨切 callback — service 不知道它们来自 bridge + publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event), + knownClientIds: () => bridge.knownClientIds(), + // child 委托 callback — workspace-scoped ext method 通过 bridge 的 channel 到达 agent + queryWorkspaceStatus: (method, idle) => bridge.queryWorkspaceStatus(method, idle), + invokeWorkspaceCommand: (method, params, opts) => bridge.invokeWorkspaceCommand(method, params, opts), +}); + +// 4. 两者传给 server routes + /acp handler +createServeApp({ bridge, workspace, ... }); +``` + +**构造顺序 bridge → service 是硬依赖**(service 需要 bridge 实例上的方法作为 callback 源)。 + +--- + +## 3. DaemonWorkspaceService 内部结构 + +### 3.1 目录布局 + +``` +packages/cli/src/serve/workspace-service/ +├── types.ts ← WorkspaceRequestContext + sub-service interfaces +├── index.ts ← facade factory (createDaemonWorkspaceService) +├── fileService.ts ← wraps fsFactory +├── authService.ts ← wraps DeviceFlowRegistry +├── agentsService.ts ← wraps SubagentManager +├── memoryService.ts ← wraps memory file ops +└── __tests__/ + ├── fileService.test.ts + ├── authService.test.ts + ├── agentsService.test.ts + ├── memoryService.test.ts + └── e2e.test.ts +``` + +### 3.2 Facade 接口 + +```ts +export interface DaemonWorkspaceService { + file: FileService; + auth: AuthService; + agents: AgentsService; + memory: MemoryService; + + // 纯本地 + initWorkspace( + opts: InitWorkspaceOpts, + ctx: WorkspaceRequestContext, + ): Promise; + setToolEnabled( + toolName: string, + enabled: boolean, + ctx: WorkspaceRequestContext, + ): Promise; + + // 通过 callback 委托 child + getMcpStatus(): Promise; + getSkillsStatus(): Promise; + getProvidersStatus(): Promise; + getEnvStatus(): Promise; + getPreflightStatus(): Promise; + restartMcpServer( + serverName: string, + ctx: WorkspaceRequestContext, + opts?: RestartOpts, + ): Promise; +} +``` + +> `listWorkspaceSessions` / `recordHeartbeat` / `getHeartbeatState` / `publishWorkspaceEvent` / `knownClientIds` 留在 bridge——它们访问 bridge 内部的 per-session state(`byId` map / session bus),是 session 衍生的基础设施。service 通过 callback 消费,不直接拥有。 + +### 3.3 Facade Factory 签名 + +```ts +export interface DaemonWorkspaceServiceDeps { + fsFactory: WorkspaceFileSystemFactory; + deviceFlowRegistry: DeviceFlowRegistry; + subagentManager: SubagentManager; + boundWorkspace: string; + contextFilename: string; + persistDisabledTools: ( + workspace: string, + tool: string, + enabled: boolean, + ) => Promise; + + // 跨切 callback(session 衍生基础设施) + publishWorkspaceEvent: (event: WorkspaceEvent) => void; + knownClientIds: () => Set; + + // child 委托 callback(workspace-scoped ext method 通过 bridge channel 到达 agent) + queryWorkspaceStatus: (method: string, idle: () => T) => Promise; + invokeWorkspaceCommand: ( + method: string, + params?: Record, + opts?: { timeoutMs?: number }, + ) => Promise; +} + +export function createDaemonWorkspaceService( + deps: DaemonWorkspaceServiceDeps, +): DaemonWorkspaceService; +``` + +### 3.4 各子服务接口 + +| 子服务 | 方法 | 所需 deps | 现有来源 | +| ------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| FileService | `read`, `readBytes`, `write`, `edit`, `glob`, `list`, `stat` | `fsFactory`, `boundWorkspace` | `serve/routes/workspaceFileRead.ts`, `workspaceFileWrite.ts`, `serve/fs/` | +| AuthService | `startFlow`, `getFlowStatus(flowId)`, `cancelFlow(flowId)`, `getAuthStatus` | `deviceFlowRegistry` | `serve/auth/deviceFlow.ts`, `server.ts:794-966` | +| AgentsService | `list`, `get(agentType)`, `create`, `update`, `delete` | `subagentManager`, `publishWorkspaceEvent`, `knownClientIds` | `serve/workspaceAgents.ts` | +| MemoryService | `list`, `read`, `write`, `delete` | `fsFactory` or direct fs, `publishWorkspaceEvent`, `knownClientIds` | `serve/workspaceMemory.ts` | + +每个方法第一个参数都是 `ctx: WorkspaceRequestContext`,trust gate 在方法入口统一执行。 + +--- + +## 4. WorkspaceRequestContext + +```ts +export interface WorkspaceRequestContext { + originatorClientId?: string; // X-Qwen-Client-Id header(只读操作可缺失) + sessionId?: string; // audit 关联(如从 session context 内发起的操作) + route: string; // audit trail(如 "POST /file/write") + workspaceCwd: string; // trust boundary root +} +``` + +> `originatorClientId` 为 optional——当前 file read 等只读路由在 header 缺失时照常工作(`clientId ?? undefined` 传入 `fsFactory.forRequest`)。write 路由在 clientId **存在时**才校验合法性。 + +**构建位置**:L1 route handler / `/acp` method handler 从 request headers/params 提取后传入 L2。L2 只消费,不自行提取 HTTP context。 + +--- + +## 5. AcpSessionBridge 瘦身与改名 + +### 5.1 从 bridge 迁出的方法 + +| 方法 | 去向 | 机制 | 理由 | +| ----------------------------- | ------------------------------ | ------------------------------------- | -------------------------------------------------------------- | +| `initWorkspace` | `workspace.initWorkspace` | 直接迁(纯本地) | 附带修 FIXME(bridge 没接 fsFactory,跳过 trust gate / audit) | +| `setWorkspaceToolEnabled` | `workspace.setToolEnabled` | 直接迁(纯本地) | 纯 file I/O + event fan-out,注释明确 "no ACP roundtrip" | +| `getWorkspaceMcpStatus` | `workspace.getMcpStatus` | via `queryWorkspaceStatus` callback | workspace-scoped status query | +| `getWorkspaceSkillsStatus` | `workspace.getSkillsStatus` | via `queryWorkspaceStatus` callback | 同上 | +| `getWorkspaceProvidersStatus` | `workspace.getProvidersStatus` | via `queryWorkspaceStatus` callback | 同上 | +| `getWorkspaceEnvStatus` | `workspace.getEnvStatus` | via `queryWorkspaceStatus` callback | 同上 | +| `getWorkspacePreflightStatus` | `workspace.getPreflightStatus` | via `queryWorkspaceStatus` callback | 同上 | +| `restartMcpServer` | `workspace.restartMcpServer` | via `invokeWorkspaceCommand` callback | workspace-scoped mutation | + +> `listWorkspaceSessions` / `recordHeartbeat` / `getHeartbeatState` / `updateSessionMetadata` 保留在 bridge——它们访问 bridge 内部 `byId` session map,是 session-scoped 操作。 + +### 5.2 留在 bridge 的 + +- 所有 session/channel 生命周期(spawn/load/resume/send/cancel/close/kill/detach) +- EventBus 持有 + `publishWorkspaceEvent` fan-out 实现(供 service callback 消费) +- `knownClientIds`(供 service callback 消费) +- `queryWorkspaceStatus` / `invokeWorkspaceCommand`(新暴露,封装 channel + timeout + error,供 service callback 委托) +- 权限仲裁 mediator +- session 配置变更(model/approvalMode/recap) +- session 状态(context/supportedCommands/metadata/heartbeat/listSessions) + +### 5.3 改名 + +- `HttpAcpBridge` → `AcpSessionBridge` +- `createHttpAcpBridge` → `createAcpSessionBridge` +- 文件 `serve/httpAcpBridge.ts` → `serve/acpSessionBridge.ts` + +无外部包消费者(验证过 `packages/cli/src/serve/` 和 `packages/acp-bridge/src/` 之外无引用),内部安全。 + +--- + +## 6. /acp northbound ext methods + +### 6.1 命名空间 + +`qwen/workspace/...`(与现有 `qwen/control/...` 区分): + +- `qwen/control/...` = daemon→child 转发命令(southbound,经 AcpSessionBridge) +- `qwen/workspace/...` = daemon 本地工作区操作(northbound,终止于 DaemonWorkspaceService) + +> 待 chiga0 确认。如改命名空间只需换方法名前缀,不影响架构。 + +### 6.2 方法列表 + +| method | 对应 REST | L2 调用 | +| --------------------------------- | ----------------------------------------------- | --------------------------------------------------- | +| `qwen/workspace/fs/read` | `GET /file?path=...` | `workspace.file.read(ctx, path)` | +| `qwen/workspace/fs/readBytes` | `GET /file/bytes?path=...` | `workspace.file.readBytes(ctx, path)` | +| `qwen/workspace/fs/write` | `POST /file/write` | `workspace.file.write(ctx, path, content)` | +| `qwen/workspace/fs/edit` | `POST /file/edit` | `workspace.file.edit(ctx, path, edits)` | +| `qwen/workspace/fs/glob` | `GET /glob?pattern=...` | `workspace.file.glob(ctx, pattern)` | +| `qwen/workspace/fs/list` | `GET /list?path=...` | `workspace.file.list(ctx, path)` | +| `qwen/workspace/fs/stat` | `GET /stat?path=...` | `workspace.file.stat(ctx, path)` | +| `qwen/workspace/auth/start` | `POST /workspace/auth/device-flow` | `workspace.auth.startFlow(ctx)` | +| `qwen/workspace/auth/status` | `GET /workspace/auth/status` | `workspace.auth.getAuthStatus(ctx)` | +| `qwen/workspace/auth/flow` | `GET /workspace/auth/device-flow/:id` | `workspace.auth.getFlowStatus(ctx, flowId)` | +| `qwen/workspace/auth/cancel` | `POST /workspace/auth/device-flow/:id` (cancel) | `workspace.auth.cancelFlow(ctx, flowId)` | +| `qwen/workspace/agents/list` | `GET /workspace/agents` | `workspace.agents.list(ctx)` | +| `qwen/workspace/agents/get` | `GET /workspace/agents/:agentType` | `workspace.agents.get(ctx, agentType)` | +| `qwen/workspace/agents/create` | `POST /workspace/agents` | `workspace.agents.create(ctx, spec)` | +| `qwen/workspace/agents/update` | `POST /workspace/agents/:agentType` | `workspace.agents.update(ctx, agentType, spec)` | +| `qwen/workspace/agents/delete` | `DELETE /workspace/agents/:agentType` | `workspace.agents.delete(ctx, agentType)` | +| `qwen/workspace/memory/list` | `GET /workspace/memory` | `workspace.memory.list(ctx)` | +| `qwen/workspace/memory/read` | `GET /workspace/memory/:key` | `workspace.memory.read(ctx, key)` | +| `qwen/workspace/memory/write` | `POST /workspace/memory` | `workspace.memory.write(ctx, key, content)` | +| `qwen/workspace/memory/delete` | `DELETE /workspace/memory/:key` | `workspace.memory.delete(ctx, key)` | +| `qwen/workspace/init` | `POST /workspace/init` | `workspace.initWorkspace(ctx, opts)` | +| `qwen/workspace/tool/toggle` | `POST /workspace/tool/toggle` | `workspace.setToolEnabled(ctx, toolName, enabled)` | +| `qwen/workspace/status/mcp` | `GET /workspace/mcp` | `workspace.getMcpStatus()` | +| `qwen/workspace/status/skills` | `GET /workspace/skills` | `workspace.getSkillsStatus()` | +| `qwen/workspace/status/providers` | `GET /workspace/providers` | `workspace.getProvidersStatus()` | +| `qwen/workspace/status/env` | `GET /workspace/env` | `workspace.getEnvStatus()` | +| `qwen/workspace/status/preflight` | `GET /workspace/preflight` | `workspace.getPreflightStatus()` | +| `qwen/workspace/mcp/restart` | `POST /workspace/mcp/restart` | `workspace.restartMcpServer(ctx, serverName, opts)` | + +Capabilities advertise 时在 `_meta.qwen.methods` 中声明这些方法。 + +--- + +## 7. 文件变更清单 + +### 7.1 新增 + +| 文件 | 用途 | +| --------------------------------------------------------- | -------------------------------------------------- | +| `serve/workspace-service/types.ts` | `WorkspaceRequestContext` + sub-service interfaces | +| `serve/workspace-service/index.ts` | facade factory | +| `serve/workspace-service/fileService.ts` | FileService 实现 | +| `serve/workspace-service/authService.ts` | AuthService 实现 | +| `serve/workspace-service/agentsService.ts` | AgentsService 实现 | +| `serve/workspace-service/memoryService.ts` | MemoryService 实现 | +| `serve/workspace-service/__tests__/fileService.test.ts` | unit test | +| `serve/workspace-service/__tests__/authService.test.ts` | unit test | +| `serve/workspace-service/__tests__/agentsService.test.ts` | unit test | +| `serve/workspace-service/__tests__/memoryService.test.ts` | unit test | +| `serve/workspace-service/__tests__/e2e.test.ts` | 端到端 REST ↔ /acp 等价验证 | + +### 7.2 修改 + +| 文件 | 变更 | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `acp-bridge/src/bridge.ts` | 移除 8 个 workspace 方法(initWorkspace / setWorkspaceToolEnabled / 5 status getters / restartMcpServer);新暴露 `queryWorkspaceStatus` + `invokeWorkspaceCommand`;重命名工厂函数 | +| `acp-bridge/src/bridgeTypes.ts` | 接口改名 `HttpAcpBridge` → `AcpSessionBridge`;移除 8 个 workspace 方法签名;新增 `queryWorkspaceStatus` + `invokeWorkspaceCommand` 签名 | +| `acp-bridge/src/bridgeOptions.ts` | 更新 JSDoc 引用 | +| `acp-bridge/src/status.ts` | 更新错误消息中的类名 | +| `cli/src/serve/httpAcpBridge.ts` → 改名 `acpSessionBridge.ts` | re-export 更新 | +| `cli/src/serve/runQwenServe.ts` | 构造 `DaemonWorkspaceService`,注入 callback,传给 routes 和 /acp handler | +| `cli/src/serve/server.ts` | routes 从直连 `fsFactory`/`DeviceFlowRegistry` 改为调 `workspace.file.*` / `workspace.auth.*` | +| `cli/src/serve/workspaceAgents.ts` | 业务逻辑迁入 `agentsService.ts`;原文件变成 route handler 薄壳(构建 ctx → 调 service) | +| `cli/src/serve/workspaceMemory.ts` | 同上 | +| `cli/src/serve/routes/workspaceFileRead.ts` | 同上 | +| `cli/src/serve/routes/workspaceFileWrite.ts` | 同上 | +| `/acp` handler(`acp-integration/` 或 `serve/` 内) | 新增 northbound method dispatch | + +--- + +## 8. SDK 兼容与错误格式 + +### 8.1 SDK backward compat + +REST API surface(路径、HTTP 方法、请求/响应 JSON schema)保持不变。`sdk-typescript` 中的 `DaemonClient` / `DaemonSessionClient` 无需任何改动。 + +验证方式:现有 `packages/sdk-typescript/test/unit/DaemonClient.test.ts` 和 `DaemonSessionClient.test.ts` 在本 PR 中必须零修改通过。 + +### 8.2 /acp trust gate 拒绝的错误格式 + +两传输语义等价但编码不同: + +| 场景 | REST | /acp (JSON-RPC) | +| ----------------------------- | ------------------------------------------ | ------------------------------------------------------------------------ | +| 无效/缺失 bearer token | `401 { error, code: "unauthorized" }` | `{ error: { code: -32001, message: "unauthorized" } }` | +| 无效 clientId | `400 { error, code: "invalid_client_id" }` | `{ error: { code: -32602, message: "invalid_client_id", data: {...} } }` | +| trust gate 拒绝(路径逃逸等) | `403 { error, code: "forbidden" }` | `{ error: { code: -32003, message: "forbidden", data: {...} } }` | + +> JSON-RPC error codes 遵循 [ACP error code registry](https://spec.acpprotocol.org)(标准范围 -32000 ~ -32099 为 server-defined application errors)。具体 code 值在实现时对齐 `/acp` 现有 error 映射逻辑(`acp-integration/errorCodes.ts`)。 + +--- + +## 9. 测试策略 + +| 层 | 测试类型 | 覆盖目标 | +| ----------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------- | +| Sub-service unit | Jest,mock fsFactory / DeviceFlowRegistry / SubagentManager / callbacks | 业务逻辑正确性 + trust gate 拒绝非法 clientId | +| Route integration | 现有 route test 改为经 service(验证 HTTP surface 不变) | 回归保障,REST 路径不 break | +| E2e 等价验证 | 启动真实 serve + HTTP 请求 | REST 和 `/acp` 对同一操作返回等价结果;trust gate 两端一致拒绝 | + +### E2e 验证矩阵 + +- File read/write:REST `GET /file` vs `/acp` `qwen/workspace/fs/read` → 同结果 +- Agent CRUD:REST `POST /workspace/agents` vs `/acp` `qwen/workspace/agents/create` → 同行为 +- Trust gate rejection:无效 clientId 两路径都 403 +- Workspace init:验证 fsFactory 走通 + audit trail 产出 + +--- + +## 10. PR 形态 + +单 PR 原子提交,包含: + +- DaemonWorkspaceService 全部新建文件 +- REST route handler 改为调 service +- bridge 瘦身(迁出 8 个 workspace 方法)+ 新暴露 2 个 child 委托方法 +- `HttpAcpBridge` → `AcpSessionBridge` 改名 +- `/acp` northbound ext methods 新增(27 个) +- 全量测试(unit + integration + e2e) + +--- + +## 11. 明确不做(scope boundary) + +- workspace-scoped EventBus(PR 24 territory) +- workspace-scoped ClientRegistry(PR 24 territory) +- L2 ↔ L3 拆分(把 `ClientSideConnection` 从 bridge 拆出) +- REST 做成 `/acp` compat shim(长期方向) +- channels standalone 模式统一(独立部署形态问题) +- `listWorkspaceSessions` / `recordHeartbeat` / `getHeartbeatState` / `updateSessionMetadata` 迁移(session-scoped,保留原位) +- `publishWorkspaceEvent` / `knownClientIds` 的 ownership 转移(session 衍生基础设施,保留 bridge 持有,service 通过 callback 消费) + +--- + +## 12. 待 chiga0 确认的决策点 + +1. `/acp` northbound 命名空间:`qwen/workspace/...` vs 其他(如复用 `qwen/control/...`) +2. 改名是否同 PR:倾向同 PR,但可按反馈拆出 + +> 以上两点如需调整,只影响命名和 commit 边界,不影响架构。 diff --git a/docs/users/_meta.ts b/docs/users/_meta.ts index 06587e1e73..3691406b61 100644 --- a/docs/users/_meta.ts +++ b/docs/users/_meta.ts @@ -15,6 +15,7 @@ export default { 'integration-jetbrains': 'JetBrains IDEs', 'integration-github-action': 'GitHub Actions', 'qwen-serve': 'Daemon mode (qwen serve)', + 'qwen-serve-deploy-local': 'Daemon mode — local launch templates', 'Code with Qwen Code': { type: 'separator', title: 'Code with Qwen Code', // Title is optional diff --git a/docs/users/qwen-serve-deploy-local.md b/docs/users/qwen-serve-deploy-local.md new file mode 100644 index 0000000000..5746296b9a --- /dev/null +++ b/docs/users/qwen-serve-deploy-local.md @@ -0,0 +1,221 @@ +# Local launch templates for `qwen serve` (v0.16-alpha) + +Reference templates for running `qwen serve` as a long-lived background process on a developer workstation. Pairs with the [v0.16-alpha known limits](./qwen-serve.md#v016-alpha-known-limits) — local-only, single-user, BYO bearer token. Containerized / multi-host / TLS-fronted deployments defer to v0.16.x. + +> **Audience**: dogfooding developers who want the daemon up across reboots, with logs going somewhere durable, and a clean `restart-on-failure` story. If you only need the daemon for the duration of a single shell session, plain `qwen serve` (foreground, Ctrl-C to stop) is fine. + +## Generate a bearer token (once) + +```bash +openssl rand -hex 32 > ~/.qwen-serve-token # user-managed, NOT a built-in path +chmod 600 ~/.qwen-serve-token +export QWEN_SERVER_TOKEN="$(cat ~/.qwen-serve-token)" +``` + +The path / filename is yours to choose; v0.16-alpha does not auto-generate or auto-locate a token file (deferred to v0.16.x). See the [Authentication](./qwen-serve.md#authentication) section of the user guide for the canonical BYO setup. + +> **Scope this `export` to the current shell session only.** Don't add it to `~/.bashrc` / `~/.zshrc` — a profile-level export exposes the bearer token to every process spawned from that shell (IDE subprocesses, browser debuggers, `npm` scripts from unrelated projects). For long-running setups, use the systemd `EnvironmentFile=` / launchd `EnvironmentVariables` mechanisms below — both scope the token to just the daemon process. + +The daemon reads the bearer token from either `--token ` on the CLI or the `QWEN_SERVER_TOKEN` env var (whitespace stripped from both). The TypeScript SDK's `DaemonClient` constructor falls back to `QWEN_SERVER_TOKEN` when no `token` option is passed (PR 27 fallback — clients with the env var set never need to thread the value through their script). + +One shell-level `export` covers both server boot and SDK client construction (just keep it scoped to the session, per the note above). + +## Linux: systemd user unit + +> **Find your `qwen` binary first.** The unit file's `ExecStart=` must hold an **absolute path** — service managers don't read your shell's `PATH`. Run `which qwen` to discover it. Common locations: `/usr/local/bin/qwen` (Linuxbrew, manual installs), `~/.nvm/versions/node/vX.Y.Z/bin/qwen` (nvm), `~/.fnm/aliases/default/bin/qwen` (fnm), `~/.volta/bin/qwen` (Volta). Substitute the actual path everywhere the templates below show `/PATH/TO/qwen`. + +`~/.config/systemd/user/qwen-serve.service`: + +```ini +[Unit] +Description=Qwen Code daemon (loopback HTTP + SSE) +After=network.target + +[Service] +Type=simple +# Replace with your project; %h expands to $HOME under user units. +WorkingDirectory=%h/your-project +# Run `which qwen` to find the absolute path. systemd does NOT read $PATH. +ExecStart=/PATH/TO/qwen serve --hostname 127.0.0.1 --port 4170 +# Read the bearer token from a chmod 600 file rather than inlining it +# in the unit. `Environment=` would expose the token in the unit file +# (typically 644 = world-readable). EnvironmentFile keeps the token in +# the user-owned secret file you already created with `chmod 600`. +EnvironmentFile=%h/.qwen-serve-token-env +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target +``` + +Build the env file once (the token file from the setup step holds the raw value; this wraps it in `KEY=value` form so systemd reads it as an env assignment): + +```bash +echo "QWEN_SERVER_TOKEN=$(cat ~/.qwen-serve-token)" > ~/.qwen-serve-token-env +chmod 600 ~/.qwen-serve-token-env +``` + +Manage: + +```bash +systemctl --user daemon-reload +systemctl --user enable --now qwen-serve.service +loginctl enable-linger "$(whoami)" # keep the user manager running after logout / across reboot +journalctl --user -u qwen-serve -f # tail logs +systemctl --user restart qwen-serve.service # after token rotation +systemctl --user disable --now qwen-serve.service +``` + +Without `loginctl enable-linger`, the user-level systemd instance shuts down when the user logs out and only restarts on next login — on a headless dev box the daemon would not survive an SSH session ending. `enable-linger` is what makes "across reboots" actually work. + +**System-wide alternative** (shared dev hosts, less common): drop the unit at `/etc/systemd/system/qwen-serve@.service` with `User=%i`, manage via `sudo systemctl enable --now qwen-serve@.service`. Same `[Service]` body otherwise — but world-readable `Environment=` exposure is even more problematic at this level, so always use `EnvironmentFile=` pointing at the user's `chmod 600` file. Pick user-level + linger for single-user workstations. + +## macOS: launchd user agent + +> **Find your `qwen` binary first.** Same constraint as systemd — `ProgramArguments` must hold an **absolute path**. Run `which qwen` to discover it. Common locations on macOS: `/opt/homebrew/bin/qwen` (Homebrew on Apple Silicon), `/usr/local/bin/qwen` (Homebrew on Intel, manual installs), `~/.nvm/versions/node/vX.Y.Z/bin/qwen` (nvm), `~/.volta/bin/qwen` (Volta). Substitute below where the template shows `/PATH/TO/qwen`. + +`~/Library/LaunchAgents/com.qwenlm.qwen-serve.plist`: + +```xml + + + + + Label + com.qwenlm.qwen-serve + ProgramArguments + + + /PATH/TO/qwen + serve + --hostname + 127.0.0.1 + --port + 4170 + + + WorkingDirectory + /Users/YOUR-USERNAME/your-project + EnvironmentVariables + + + QWEN_SERVER_TOKEN + PASTE-YOUR-TOKEN-HERE + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + ThrottleInterval + 10 + + StandardOutPath + /Users/YOUR-USERNAME/Library/Logs/qwen-serve/out.log + StandardErrorPath + /Users/YOUR-USERNAME/Library/Logs/qwen-serve/err.log + + +``` + +Manage: + +```bash +mkdir -p ~/Library/Logs/qwen-serve # first time only +chmod 600 ~/Library/LaunchAgents/com.qwenlm.qwen-serve.plist # plist holds the inline token +launchctl load ~/Library/LaunchAgents/com.qwenlm.qwen-serve.plist +launchctl unload ~/Library/LaunchAgents/com.qwenlm.qwen-serve.plist # to stop +tail -f ~/Library/Logs/qwen-serve/out.log ~/Library/Logs/qwen-serve/err.log +``` + +After editing the plist (e.g., rotating the token) you must `unload` then `load` again — `launchctl` does not auto-reload on plist changes the way `systemd daemon-reload` does. Note: each `load` truncates the log files, so save them off if you're investigating an incident before rotating. + +## tmux session (interactive supervision) + +Assumes `QWEN_SERVER_TOKEN` is already exported in your shell (see the setup section above): + +```bash +tmux new -d -s qwen-serve "cd ~/your-project && qwen serve --hostname 127.0.0.1" +tmux attach -t qwen-serve # see live logs; Ctrl-b d to detach +tmux kill-session -t qwen-serve +``` + +`tmux new -d` inherits the parent shell's environment, so `QWEN_SERVER_TOKEN` flows through automatically. Best when you want to occasionally watch the daemon's stdout (auth warnings, MCP discovery progress, slow-client warnings) without committing to a service unit. Survives terminal close but not host reboot. + +## nohup one-liner (quick + dirty) + +Assumes `QWEN_SERVER_TOKEN` is already exported in your shell: + +```bash +nohup bash -c 'cd ~/your-project && qwen serve --hostname 127.0.0.1' > qwen-serve.log 2>&1 & +echo $! # daemon PID; capture if you want to `kill` cleanly later +``` + +The wrapping `bash -c '...'` ensures the daemon binds to `~/your-project` rather than wherever you happened to run the command. Without that `cd`, `qwen serve` defaults to `process.cwd()` and a `POST /session` from a client expecting your project workspace returns `400 workspace_mismatch` — silent foot-gun. + +OK for one-off "let me run this in the background while I poke at the API" workflows. **Not recommended** for anything beyond a single session — no restart-on-crash, log file grows unbounded, no clean way to find the daemon if you forget the PID. Prefer tmux for interactive supervision or systemd / launchd for anything you want to outlast a reboot. + +## Verifying the daemon is up + +```bash +curl http://127.0.0.1:4170/health # → {"status":"ok"} +curl -H "Authorization: Bearer $QWEN_SERVER_TOKEN" \ + http://127.0.0.1:4170/capabilities | jq .protocolVersions # daemon's feature set +``` + +When auth is configured (i.e., the daemon was started with `--token` / `QWEN_SERVER_TOKEN` set, OR `--require-auth=true`), every route except `/health` on loopback binds requires `Authorization: Bearer `. If you started the daemon without a token on the loopback default (the `qwen serve` zero-config path), neither call requires a header. The templates above all configure a token, so the `Authorization` header is needed in practice. If `/capabilities` returns `401`, the unit / plist token doesn't match the env-exported token your `curl` is using. + +## Token rotation + +1. Generate a new token + write the env file the unit references: + ```bash + openssl rand -hex 32 > ~/.qwen-serve-token + chmod 600 ~/.qwen-serve-token + echo "QWEN_SERVER_TOKEN=$(cat ~/.qwen-serve-token)" > ~/.qwen-serve-token-env + chmod 600 ~/.qwen-serve-token-env + ``` + (For the launchd / nohup / tmux templates: edit the plist's `` value or re-`export QWEN_SERVER_TOKEN`. Don't forget `chmod 600` on the plist if you regenerate it.) +2. Restart the daemon: + - **systemd**: `systemctl --user restart qwen-serve.service` + - **launchd**: `launchctl unload ~/Library/LaunchAgents/com.qwenlm.qwen-serve.plist && launchctl load ~/Library/LaunchAgents/com.qwenlm.qwen-serve.plist` + - **tmux / nohup**: `kill ` then re-run with the new token in env +3. Update any client SDKs / scripts. The TypeScript SDK's `DaemonClient` reads `QWEN_SERVER_TOKEN` automatically (PR 27 fallback) — re-`export` the new value in any client shell and reconstruct the client. + +## Restart and crash behavior + +Service-manager restart semantics differ across the templates: + +- **systemd `Restart=on-failure`** — restart only on non-zero exit / signal. A clean SIGTERM (`systemctl stop`) does **not** trigger a restart loop. +- **launchd `KeepAlive` with `SuccessfulExit=false`** (the template above) — matches systemd behavior. A bare `` would have respawned even after a clean exit. `ThrottleInterval=10` rate-limits restart storms on persistent failures, mirroring systemd's `RestartSec=5`. +- **tmux / nohup** — no automatic restart. A daemon crash leaves you with a dead PID until you re-run. + +Within a **single daemon process lifetime**, client disconnects recover via SSE `Last-Event-ID` resume per the [Durability model](./qwen-serve.md#durability-model) section of the user guide — the replay ring is in-memory. + +A daemon **restart** drops all in-memory sessions; clients reconnect and start fresh. Cross-restart durability of session content (prompts, tool calls, conversation history) is **NOT** in v0.16-alpha. + +## Out of scope (defers to v0.16.x or later) + +- **Containerized deployment** — Dockerfile, docker-compose, Kubernetes manifests, nginx + TLS reverse proxy, multi-instance token isolation. Defers to v0.16.x once an enterprise pilot is committed; the doc would otherwise rot from no-one-validating. +- **Cross-host federation / multi-daemon coordination on one host** — `1 daemon = 1 workspace × N sessions` is enforced. Instance-path token keying + stale-token cleanup defer to v0.16.x. +- **Auto-generated daemon tokens** — alpha is BYO-token. Auto-gen + token-store infrastructure defers to v0.16.x. +- **Windows native service** (`nssm`, Service Control Manager wrapper) — for now use [WSL2](https://learn.microsoft.com/en-us/windows/wsl/) and follow the systemd section above. + +See the [v0.16-alpha known limits](./qwen-serve.md#v016-alpha-known-limits) callout in the main user guide for the full deferred-features list, and [#4175](https://github.com/QwenLM/qwen-code/issues/4175) for the v0.16-alpha rollout tracking issue. diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 638e971e11..4026d5c1f3 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -2,6 +2,8 @@ Run Qwen Code as a local HTTP daemon so multiple clients (IDE plugins, web UIs, CI scripts, custom CLIs) share one agent session over HTTP + Server-Sent Events instead of each spawning their own subprocess. +> **🚧 v0.16-alpha**: `qwen serve` first ships to npm in v0.16-alpha as **text-only chat / coding** with **local-only deployment**. Image / file attachments on the prompt path, containerized deployment (Docker / k8s / nginx reverse-proxy), and remote / multi-daemon hardening land in a follow-up patch when an enterprise pilot is committed. See [v0.16-alpha known limits](#v016-alpha-known-limits) for the full deferred list. + > **Status:** Stage 1 (experimental). The protocol surface is locked at the §04 routes table from issue [#3803](https://github.com/QwenLM/qwen-code/issues/3803). Stage 1.5 (`qwen --serve` flag — TUI co-hosts the same HTTP server) and Stage 2 (in-process refactor + `mDNS`/OpenAPI/WebSocket/Prometheus polish) are immediately downstream. > > **Scope honesty:** Stage 1 is sized for **developers prototyping clients against the protocol surface** and for **local single-user / small-team collaboration**. Production-grade multi-client / long-running / network-flaky workloads (mobile companions, IM bots reaching 1000+ chats) need Stage 1.5+ guarantees that aren't in this release. See [Stage 1.5+ runtime guarantees](#stage-15-runtime-guarantees) for the full gap list and #3803 for the convergence roadmap. @@ -12,7 +14,40 @@ Run Qwen Code as a local HTTP daemon so multiple clients (IDE plugins, web UIs, - **Reconnect-safe streaming** — SSE with `Last-Event-ID` reconnect lets a client drop and pick up exactly where it left off (within the ring's replay window). - **First-responder permissions** — when the agent asks for permission to run a tool, every connected client sees the request; whichever client answers first wins. - **One daemon, one workspace** — each `qwen serve` process binds to exactly one workspace at boot (per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02). Multi-workspace deployments run one daemon per workspace on separate ports (or behind an orchestrator). -- **Remote runtime control** ([#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 17) — change a session's approval mode (`POST /session/:id/approval-mode`), toggle a tool per workspace (`POST /workspace/tools/:name/enable`), scaffold an empty `QWEN.md` (`POST /workspace/init`, mechanical only — does NOT call the model; for AI-fill, follow up with `POST /session/:id/prompt`), or restart a single MCP server with a budget pre-check (`POST /workspace/mcp/:server/restart`). All four are strict-gated — configure `--token` first. +- **Remote runtime control** ([#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 17) — change a session's approval mode (`POST /session/:id/approval-mode`), toggle a tool per workspace (`POST /workspace/tools/:name/enable`), scaffold an empty `QWEN.md` (`POST /workspace/init`, mechanical only — does NOT call the model; for AI-fill, follow up with `POST /session/:id/prompt`), restart a single MCP server with a budget pre-check (`POST /workspace/mcp/:server/restart`), or add/remove MCP servers at runtime without a daemon restart (`POST /workspace/mcp/servers`, `DELETE /workspace/mcp/servers/:name`). All strict-gated — configure `--token` first. +- **Session recap** ([#4175](https://github.com/QwenLM/qwen-code/issues/4175) follow-up) — fetch a one-sentence "where did I leave off" summary of an active session (`POST /session/:id/recap`). Wraps core's `generateSessionRecap` as a side-query against the fast model; pollutes neither the main chat history nor the SSE stream. Non-strict gate (same posture as `/prompt`); SDK helper `client.recapSession(sessionId)`. + - **Known limit — token-cost amplification:** the route is a pure-cost endpoint (each call is an LLM side-query, no state benefit) and the daemon has no per-route rate limit in v1. On a no-token loopback default a buggy or malicious local client can spam it to burn tokens. Configure `--token` (and optionally `--require-auth`) on shared dev hosts before exposing the daemon. + - **Concurrent recap safety:** two simultaneous `/recap` calls on the same session run two independent side-queries. `generateSessionRecap` reads a snapshot of the chat history via `GeminiClient.getChat().getHistory()` and feeds it to a separate `BaseLlmClient.generateText` call (via `runSideQuery`); it never appends to or mutates the session's `GeminiChat`. Safe to call from multiple clients without coordination. + +## v0.16-alpha known limits + +The first npm release of `qwen serve` (v0.16-alpha) is intentionally narrow — text-only chat / coding for developers running the daemon on their own machine. The list below makes the deferred surface explicit so adopters can plan around it; everything here is on the v0.16.x patch roadmap or a near-term follow-up release. + +**Product surface — text-only:** + +- ✅ Text prompts and text responses (chat, coding, tool calls, MCP integration) +- ❌ **Image / file attachments on the prompt path** — `MessageEmitter` currently only renders text; multimodal echo lands when an alpha target with image needs is committed (#4175 chiga0 #27 P0 item) +- ❌ **Streaming uploads** — same gating as multimodal + +**Deployment surface — local-only:** + +- ✅ Loopback (`127.0.0.1`, default) — no auth required, suitable for dev workstations +- ✅ Local launch via `systemd` / `launchd` / `nohup &` / `tmux` — see [Local launch templates](./qwen-serve-deploy-local.md) +- ✅ Bring-your-own bearer token via `QWEN_SERVER_TOKEN` env var ([Authentication](#authentication) for setup) +- ❌ **Containerized deployment** — Docker / Compose / Kubernetes / nginx reverse-proxy with TLS termination NOT in v0.16-alpha. Defers to v0.16.x once an enterprise pilot is committed (would otherwise rot from no-one-validating). +- ❌ **Multi-daemon coordination on one host** — `1 daemon = 1 workspace × N sessions` is enforced. Cross-host federation, instance-path token keying, and stale-token cleanup defer to v0.16.x. +- ❌ **Auto-generated daemon tokens** — alpha is BYO-token (one `openssl rand -hex 32` away). Auto-gen + token-store infrastructure defers to v0.16.x. + +**Hardening — minimum viable for local single-user:** + +- ✅ Boot-time security gate (refuses non-loopback bind without a token, [PR 15 / #4236](https://github.com/QwenLM/qwen-code/pull/4236)) +- ✅ Mutation-route auth gate, session-scoped permission routing (Wave 4 PRs) +- ✅ MCP guardrails + multi-client permission coordination (F2 / F3) +- ⏸️ **Prompt absolute deadline + SSE writer idle timeout** — current AbortSignal + 15s heartbeat + `res.on('error')` cleanup is sufficient for local dev; explicit application-layer deadlines defer to v0.16.x once a remote / long-running scenario lands. +- ⏸️ **Rate limiting + observability + load test harness** — defers to v0.17 F4 Phase-1 scale instrumentation when 30-50 active sessions becomes a real target. +- ⏸️ **`--max-body-size` CLI flag** — daemon enforces `express.json({ limit: '10mb' })` by default which comfortably covers text-only prompts (model context windows are well under 10 MiB of chars). Tunable via flag in v0.16.x. + +For the deeper "what we won't fix in Stage 1" enumeration (single-host session-state mutation model + N-parallel-sessions sharing one ACP child), see [Stage 1 scope boundaries](#stage-1-scope-boundaries--what-we-wont-fix-in-stage-15) below. ## Quickstart @@ -42,7 +77,8 @@ The `workspaceCwd` field surfaces the bound workspace so clients can pre-flight The daemon also exposes read-only runtime snapshots for client UIs: `GET /workspace/mcp`, `GET /workspace/skills`, `GET /workspace/providers`, `GET /workspace/env`, `GET /workspace/preflight`, -`GET /session/:id/context`, and `GET /session/:id/supported-commands`. +`GET /session/:id/context`, `GET /session/:id/supported-commands`, and +`GET /session/:id/tasks`. `GET /workspace/mcp`, `GET /workspace/skills`, and `GET /workspace/providers` report the live ACP runtime and do not start the ACP child when idle; an @@ -166,19 +202,20 @@ The token comparison is constant-time (SHA-256 + `crypto.timingSafeEqual`); 401 ## CLI flags -| Flag | Default | Purpose | -| ------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--port ` | `4170` | TCP port. `0` = OS-assigned ephemeral port. | -| `--hostname ` | `127.0.0.1` | Bind interface. Anything beyond loopback requires a token. | -| `--token ` | — | Bearer token. Falls back to `QWEN_SERVER_TOKEN` env var (with leading/trailing whitespace stripped — handy for `$(cat token.txt)`). | -| `--require-auth` | `false` | Refuse to start without a bearer token, even on loopback. Hardens the `127.0.0.1` developer default for shared dev hosts / CI runners / multi-tenant workstations where any local user can hit the listener. Boots only with `--token` or `QWEN_SERVER_TOKEN` set; gates `/health` behind the bearer too. | -| `--max-sessions ` | `20` | Cap on concurrent live sessions. New `POST /session` requests that would spawn a fresh child return `503` (with `Retry-After: 5`) when the cap is hit; attaches to existing sessions are NOT counted. Set to `0` to disable. Sized for single-user / small-team usage; raise it if your deployment has the RAM/FD headroom (~30–50 MB per session). | -| `--workspace ` | `process.cwd()` | Absolute workspace path this daemon binds to (per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02 — 1 daemon = 1 workspace). `POST /session` requests with a mismatched `cwd` return `400 workspace_mismatch`. For multi-workspace deployments, run one `qwen serve` per workspace on separate ports. | -| `--max-connections ` | `256` | Listener-level TCP connection cap (`server.maxConnections`). Bounds raw socket count irrespective of session count — slow / phantom SSE clients get rejected at accept time once full. Raise alongside `--max-sessions` if your deployment expects many SSE subscribers per session. | -| `--event-ring-size ` | `8000` | Per-session SSE replay ring depth (#3803 §02 target). Sets the backlog available to `GET /session/:id/events` with `Last-Event-ID: N`. Larger = more reconnect headroom at the cost of a few hundred KB extra RAM per session. SDK clients can additionally request a larger per-subscriber backlog cap on a specific subscription via `?maxQueued=N` (range `[16, 2048]`, default 256). Daemons also emit a non-terminal `slow_client_warning` SSE frame at 75% queue fill so clients can drain / reconnect before getting evicted. Pre-flight `caps.features.slow_client_warning`. | -| `--mcp-client-budget ` | — | Positive integer cap on live MCP clients **per ACP session** (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14 v1; PR 23 graduates this to per-workspace via the shared MCP pool). Combine with `--mcp-budget-mode`. When unset, no accounting-driven enforcement (but `GET /workspace/mcp` still reports `clientCount`). Distinct from claude-code's `MCP_SERVER_CONNECTION_BATCH_SIZE` which gates startup concurrency, not the total client count. Pre-flight `caps.features.mcp_guardrails`. | -| `--mcp-budget-mode ` | `warn` / `off` | How `--mcp-client-budget` is enforced. `warn` (default when budget set): no refusal, snapshot's `budgets[0].status` flips to `warning` at ≥75% of budget. `enforce`: connects past the cap are refused, per-server cell shows `disabledReason: 'budget'`, deterministic by `mcpServers` declaration order. `off` (default when budget unset): pure observability. Boot rejects `enforce` without a budget. | -| `--http-bridge` | `true` | Stage 1 mode: one `qwen --acp` child per daemon (bound to one workspace at boot, per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02); N sessions multiplex onto that child via ACP `newSession()`. Stage 2 native in-process becomes available later. | +| Flag | Default | Purpose | +| ------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--port ` | `4170` | TCP port. `0` = OS-assigned ephemeral port. | +| `--hostname ` | `127.0.0.1` | Bind interface. Anything beyond loopback requires a token. | +| `--token ` | — | Bearer token. Falls back to `QWEN_SERVER_TOKEN` env var (with leading/trailing whitespace stripped — handy for `$(cat token.txt)`). | +| `--require-auth` | `false` | Refuse to start without a bearer token, even on loopback. Hardens the `127.0.0.1` developer default for shared dev hosts / CI runners / multi-tenant workstations where any local user can hit the listener. Boots only with `--token` or `QWEN_SERVER_TOKEN` set; gates `/health` behind the bearer too. | +| `--max-sessions ` | `20` | Cap on concurrent live sessions. New `POST /session` requests that would spawn a fresh child return `503` (with `Retry-After: 5`) when the cap is hit; attaches to existing sessions are NOT counted. Set to `0` to disable. Sized for single-user / small-team usage; raise it if your deployment has the RAM/FD headroom (~30–50 MB per session). | +| `--workspace ` | `process.cwd()` | Absolute workspace path this daemon binds to (per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02 — 1 daemon = 1 workspace). `POST /session` requests with a mismatched `cwd` return `400 workspace_mismatch`. For multi-workspace deployments, run one `qwen serve` per workspace on separate ports. | +| `--max-connections ` | `256` | Listener-level TCP connection cap (`server.maxConnections`). Bounds raw socket count irrespective of session count — slow / phantom SSE clients get rejected at accept time once full. Raise alongside `--max-sessions` if your deployment expects many SSE subscribers per session. | +| `--event-ring-size ` | `8000` | Per-session SSE replay ring depth (#3803 §02 target). Sets the backlog available to `GET /session/:id/events` with `Last-Event-ID: N`. Larger = more reconnect headroom at the cost of a few hundred KB extra RAM per session. SDK clients can additionally request a larger per-subscriber backlog cap on a specific subscription via `?maxQueued=N` (range `[16, 2048]`, default 256). Daemons also emit a non-terminal `slow_client_warning` SSE frame at 75% queue fill so clients can drain / reconnect before getting evicted. Pre-flight `caps.features.slow_client_warning`. | +| `--mcp-client-budget ` | — | Positive integer cap on live MCP clients **per ACP session** (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14 v1; PR 23 graduates this to per-workspace via the shared MCP pool). Combine with `--mcp-budget-mode`. When unset, no accounting-driven enforcement (but `GET /workspace/mcp` still reports `clientCount`). Distinct from claude-code's `MCP_SERVER_CONNECTION_BATCH_SIZE` which gates startup concurrency, not the total client count. Pre-flight `caps.features.mcp_guardrails`. | +| `--mcp-budget-mode ` | `warn` / `off` | How `--mcp-client-budget` is enforced. `warn` (default when budget set): no refusal, snapshot's `budgets[0].status` flips to `warning` at ≥75% of budget. `enforce`: connects past the cap are refused, per-server cell shows `disabledReason: 'budget'`, deterministic by `mcpServers` declaration order. `off` (default when budget unset): pure observability. Boot rejects `enforce` without a budget. | +| `--http-bridge` | `true` | Stage 1 mode: one `qwen --acp` child per daemon (bound to one workspace at boot, per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02); N sessions multiplex onto that child via ACP `newSession()`. Stage 2 native in-process becomes available later. | +| `--allow-origin ` | — | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). Cross-origin allowlist for browser webui clients. Repeatable. Each value is `*` (any origin — boot refuses if no bearer token is configured; `--require-auth` on loopback is recommended so `/health` and `/demo` are also bearer-gated, since both are pre-auth on loopback by default) or a canonical URL origin (`://[:]`, no trailing slash / path / userinfo / query). **Subdomain wildcards (`https://*.example.com`) are intentionally unsupported** — list each subdomain explicitly, or use `*` with a configured token (and `--require-auth` for full hardening). Matched origins receive CORS response headers (`Access-Control-Allow-Origin`, `Vary: Origin`, methods, headers, max-age, and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as today's wall. `Origin: null` (sandboxed iframes, file:// docs) is always rejected, even under `*`. Pre-flight via `caps.features.allow_origin`. Loopback self-origin hits are unaffected. | > **Sizing the load knobs.** `--max-sessions` is the **new-child** cap. > Three other layers also limit load — when sizing for a high-concurrency @@ -218,7 +255,7 @@ The token comparison is constant-time (SHA-256 + `crypto.timingSafeEqual`); 401 - **`--hostname 0.0.0.0` requires a token** — boot refuses without one. - **`LOOPBACK_BINDS` includes IPv6** — `::1` and `[::1]` count as loopback for the no-token rule. - **Host header allowlist** — on **loopback** binds the daemon checks `Host:` matches `localhost:port` / `127.0.0.1:port` / `[::1]:port` / `host.docker.internal:port` (case-insensitive per RFC 7230 §5.4) to defend against DNS rebinding. **Non-loopback binds (`--hostname 0.0.0.0`) intentionally bypass the Host allowlist** — the operator has chosen the surface area, so the bearer-token gate is the sole authentication layer; reverse proxies / SNI / client cert pinning are the operator's responsibility, not the daemon's. If you need Host-based isolation on a non-loopback bind, terminate TLS + check Host at a front proxy. -- **CORS denies any browser Origin** — returns `403` JSON. **Implication for browser-served webuis** (BUy4e): any `packages/webui`-style frontend that lives on a separate origin will get 403 at the wire. Stage 1 options for browser-style consumption: (a) package the webui as a native shell (Electron/Tauri) so no `Origin` header is sent, or (b) front the daemon with a same-origin reverse proxy that strips/rewrites `Origin` for a known frontend. Stage 1.5 will add `--allow-origin ` for opt-in named frontends. +- **CORS denies any browser Origin by default** — returns `403` JSON. Pass **`--allow-origin `** (repeatable, T2.4 #4514) to opt specific browser origins through. Each value is either the literal `*` (any origin — boot refuses if no bearer token is configured; `--require-auth` on loopback is recommended for full hardening since `/health` and `/demo` remain pre-auth on loopback by default) or a canonical URL origin (`://[:]`, no trailing slash / path / userinfo). Matched origins receive proper CORS response headers (`Access-Control-Allow-Origin: `, `Vary: Origin`, plus standard methods / headers / max-age and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as the default wall. `caps.features.allow_origin` is advertised conditionally so SDK / webui clients can pre-flight whether the daemon honors cross-origin hits before issuing them. Example: `qwen serve --allow-origin http://localhost:3000 --allow-origin http://localhost:5173`. Loopback self-origin hits (e.g. the `/demo` page) are unaffected — a separate Origin-strip shim handles them regardless of `--allow-origin`. **Browser webuis without `--allow-origin` configured** still fall back to the same Stage 1 options as before: package as a native shell (Electron/Tauri) so no `Origin` header is sent, or front the daemon with a same-origin reverse proxy. - **Spawned `qwen --acp` child inherits the daemon's environment** with one explicit scrub: `QWEN_SERVER_TOKEN` is removed before the child starts (the daemon's own bearer; the agent doesn't need it). Everything else — `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `QWEN_*` / `DASHSCOPE_API_KEY` / your custom `modelProviders[].envKey` / etc. — passes through, because the agent legitimately needs those to authenticate to the LLM. **This is intentional, not a sandbox.** The agent runs as the same UID with shell-tool access, so anything in `~/.bashrc` / `~/.aws/credentials` / `~/.npmrc` is reachable by prompt injection regardless. The env passthrough is not the security boundary; the user-as-trust-root is. Don't run `qwen serve` under an identity that has env-resident credentials you wouldn't trust the agent with. - **Per-subscriber bounded SSE queues** — a slow client that overflows its queue gets a `client_evicted` terminal frame and is closed; one stuck consumer can't pin the daemon. - **Graceful shutdown** — SIGINT/SIGTERM drain the agent children before closing the listener (10s deadline per child). @@ -234,11 +271,31 @@ The token comparison is constant-time (SHA-256 + `crypto.timingSafeEqual`); 401 > "alive" until Node's keepalive probes time out — typically ~2 hours > on Linux defaults. On `--hostname 0.0.0.0` deployments behind such > NATs, phantom SSE connections can accumulate and eventually hit the -> 256 `server.maxConnections` ceiling. Stage 2 will add an -> application-level idle deadline (last-byte-written tracking + -> per-connection timeout). Until then, operators on networks that -> swallow RSTs may want to lower `server.keepAliveTimeout` via a -> reverse proxy or accept periodic daemon restarts. +> 256 `server.maxConnections` ceiling. +> +> Set [`--writer-idle-timeout-ms `](#deadlines-and-writer-idle-timeout) +> (issue [#4514](https://github.com/QwenLM/qwen-code/issues/4514) T2.9) +> to close the gap with an explicit application-level idle deadline: +> when no write has successfully flushed for `n` ms the daemon emits +> a terminal `client_evicted` frame with +> `reason: 'writer_idle_timeout'` and closes the stream. The flag is +> off by default to preserve the legacy contract — operators on +> networks that swallow RSTs should pick a value well above the 15s +> heartbeat interval (e.g. `60000`–`300000`) so legitimate idle +> connections aren't evicted while genuinely stuck writers are +> reaped promptly. Pre-flight `caps.features.includes('writer_idle_timeout')` +> from your SDK to confirm the daemon supports it. + +### Deadlines and writer idle timeout + +Issue [#4514](https://github.com/QwenLM/qwen-code/issues/4514) T2.9 ships two opt-in flags that close the long-running / remote-deployment gaps the 15s heartbeat + AbortSignal don't cover. Both are off by default — single-user loopback workflows stay bit-for-bit unchanged. + +| Flag | Env var | Default | What it does | +| ------------------------------ | ----------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--prompt-deadline-ms ` | `QWEN_SERVE_PROMPT_DEADLINE_MS` | unset | Server-side wallclock cap on a single `POST /session/:id/prompt`. On expiry the daemon aborts the prompt's AbortController and returns HTTP `504` with `{code:"prompt_deadline_exceeded", errorKind:"prompt_deadline_exceeded", deadlineMs:n}`. A per-prompt request body field `deadlineMs` can SHORTEN the effective deadline below the flag but never extend it. Capability tag (conditional): `prompt_absolute_deadline`. | +| `--writer-idle-timeout-ms ` | `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` | unset | Per-SSE-connection idle deadline. When no write has SUCCESSFULLY flushed for `n` ms — neither a real event nor the 15s heartbeat — the daemon emits a terminal `client_evicted` frame with `data.reason = 'writer_idle_timeout'` (mirrored on `data.errorKind`) and closes the stream. **Pick a value comfortably above the 15s heartbeat** (e.g. `30000`–`300000`) so legitimate idle streams aren't evicted; values `< 15000` WILL evict otherwise-healthy idle connections before the first heartbeat fires (intentional only for tests / short-lived dev sessions). Capability tag (conditional): `writer_idle_timeout`. | + +Both flags accept a positive integer in milliseconds; `0`, `NaN`, non-integer, or negative values are rejected at boot with a clear error message. CLI flag wins over env var; explicit `ServeOptions` field (embedded callers) wins over env. SDK consumers should pre-flight the matching capability tag before relying on either behavior — daemons predating this PR omit both tags and the request `deadlineMs` field is silently dropped. ## Multi-session & multi-workspace deployment @@ -334,10 +391,10 @@ The Stage 1.5 plan describes TUI as an in-process EventBus subscriber. In practi No TUI shell runs inside the daemon. The slash commands listed above **don't exist** in this mode — there's no terminal UI to issue them from. Session state is therefore: -- **Boot-time-frozen** for `approval-mode` / `memory` / `mcp servers` / `agents` / `tools` allowlist / `auth` — all loaded from settings + disk when the daemon's `qwen --acp` child starts; immutable for the session's lifetime. -- **Mutable over HTTP** only via the routes this PR exposes — primarily `POST /session/:id/model` (publishes `model_switched`). Permission votes (`POST /permission/:requestId`) are per-request, not per-session-state. +- **Boot-time-frozen** for `approval-mode` / `memory` / `agents` / `tools` allowlist / `auth` — all loaded from settings + disk when the daemon's `qwen --acp` child starts; immutable for the session's lifetime. Settings-defined MCP servers are likewise frozen at boot, but **runtime-added servers** (via `POST /workspace/mcp/servers`) can be added or removed without restart. +- **Mutable over HTTP** via `POST /session/:id/model` (publishes `model_switched`), `POST /workspace/mcp/servers` / `DELETE /workspace/mcp/servers/:name` (publishes `mcp_server_added` / `mcp_server_removed`), and permission votes (`POST /permission/:requestId`). -**Consequence:** remote clients in headless mode see the **full session state**. No TUI hides additional state; no drift is possible. If you want to change `approval-mode` or add an MCP server, restart the daemon with new settings — the daemon doesn't expose runtime mutation for those today. +**Consequence:** remote clients in headless mode see the **full session state**. No TUI hides additional state; no drift is possible. If you want to change `approval-mode`, restart the daemon with new settings. MCP servers can now be added/removed at runtime via the mutation routes (`POST /workspace/mcp/servers`, `DELETE /workspace/mcp/servers/:name`) — see [Runtime MCP server management](#runtime-mcp-server-management-issue-4514). #### Mode 2 — Stage 1.5 `qwen --serve` co-hosted TUI (not in this PR) @@ -424,8 +481,161 @@ const result = await flow.awaitCompletion({ signal: abortCtrl.signal }); **Cross-client take-over.** Two SDK clients on the same daemon that both `POST /workspace/auth/device-flow` for the same provider get the per-provider singleton: the first call starts a fresh IdP request and returns `attached: false`; the second call returns the EXISTING in-flight entry with `attached: true`. The take-over is recorded on the audit trail (under the second client's `X-Qwen-Client-Id`) but does NOT emit a separate event — both clients eventually observe the SAME `auth_device_flow_authorized` once the user finishes the IdP page. If your UI distinguishes "I started this" from "someone else's flow I joined", branch on the `attached` field returned by `start()`. +## Daemon log file + +`qwen serve` writes a per-process diagnostic log to: + +``` +${QWEN_RUNTIME_DIR or ~/.qwen}/debug/daemon/serve--.log +``` + +A `latest` symlink in the same directory always points at the current process's log, so `tail -f ~/.qwen/debug/daemon/latest` will follow whichever daemon is running. + +The log captures lifecycle messages, route errors (with `route=` and `sessionId=` context), ACP child stderr, and — when `QWEN_SERVE_DEBUG=1` is set — extra bridge breadcrumbs. Lines that go to stderr today still go to stderr; the file log is **additive**, not a replacement. + +### Disabling + +Set `QWEN_DAEMON_LOG_FILE=0` (or `false`/`off`/`no`) to skip file logging entirely. Stderr output is unaffected. + +### Relation to session debug logs + +Session-scoped debug logs (`~/.qwen/debug/.txt` and the `~/.qwen/debug/latest` symlink) are independent. The daemon log lives in a sibling `daemon/` subdirectory; per-session debug semantics are unchanged by this feature. + +### No rotation + +The daemon log appends indefinitely. Rotate manually if it grows large. A future enhancement may add automatic rotation; track via [#4548](https://github.com/QwenLM/qwen-code/issues/4548) follow-ups. + +## Runtime MCP server management (issue [#4514](https://github.com/QwenLM/qwen-code/issues/4514)) + +Add or remove MCP servers at runtime without restarting the daemon. Runtime entries live in an ephemeral overlay that **shadows** settings-defined servers of the same name; the underlying `settings.json` / `mcpServers` config is never written to. + +**Pre-flight:** check `caps.features` for `mcp_server_runtime_mutation` before calling either route. Older daemons without this tag return `404`. + +### `POST /workspace/mcp/servers` — add a runtime MCP server + +Strict-gated (bearer token required). Connects the server immediately via the live `McpClientManager` and discovers its tools. + +Request: + +```json +{ + "name": "my-server", + "config": { + "command": "npx", + "args": ["-y", "@my-org/mcp-server"] + } +} +``` + +`name` must be alphanumeric plus `_` and `-` (max 256 characters). `config` is the same MCP server configuration object used in `settings.json` `mcpServers` entries (transport-dependent fields: `command`/`args` for stdio, `url` for SSE/HTTP). Security-sensitive fields (`trust`, `env`, `cwd`, `oauth`, `headers`, `authProviderType`, `includeTools`, `excludeTools`, `type`) are stripped by the daemon and ignored. + +Response (200) — success: + +```json +{ + "name": "my-server", + "transport": "stdio", + "replaced": false, + "shadowedSettings": false, + "toolCount": 3, + "originatorClientId": "client-1" +} +``` + +- `replaced: true` — a runtime entry with the same name already existed and the config fingerprint differs; old connection torn down, new one established. When the fingerprint matches (idempotent re-add), `replaced` is `false`. +- `shadowedSettings: true` — a settings-defined server with the same name exists; the runtime entry now shadows it. The settings entry is untouched and re-emerges if the runtime entry is later removed. +- `toolCount` — number of tools discovered on the newly connected server. + +Response (200) — soft refuse (budget warning mode): + +```json +{ + "name": "my-server", + "skipped": true, + "reason": "budget_warning_only" +} +``` + +Returned when `--mcp-budget-mode=warn` and adding the server would exceed the configured `--mcp-client-budget`. The server is NOT connected. Callers should surface the budget pressure to the user. + +Errors: + +| Status | Code | When | +| ------ | ------------------------- | -------------------------------------------------------------------------------------------------- | +| `400` | `invalid_server_name` | Name empty, exceeds 256 chars, or contains characters outside `[A-Za-z0-9_-]` | +| `400` | `missing_required_field` | `config` missing or not a non-null object | +| `400` | `invalid_client_id` | `X-Qwen-Client-Id` header present but not registered for this workspace | +| `400` | `invalid_config` | Config shape rejected by the MCP transport validator | +| `401` | `token_required` | No bearer token configured (strict gate) | +| `409` | `mcp_budget_would_exceed` | `--mcp-budget-mode=enforce` and budget is full | +| `502` | `mcp_server_spawn_failed` | Server process exited or timed out during connect; body carries `serverName`, `exitCode`, `stderr` | +| `503` | `acp_channel_unavailable` | No live ACP child (no session has been created yet) | + +### `DELETE /workspace/mcp/servers/:name` — remove a runtime MCP server + +Strict-gated. Disconnects the server and removes it from the runtime overlay. Idempotent — removing a name that was never added returns a skip response (not an error). + +The `:name` path parameter is the URL-encoded server name. + +Response (200) — success: + +```json +{ + "name": "my-server", + "removed": true, + "wasShadowingSettings": false, + "originatorClientId": "client-1" +} +``` + +- `wasShadowingSettings: true` — the removed runtime entry was shadowing a settings-defined server of the same name. That settings entry is now un-shadowed and will be used on next discovery/restart. + +Response (200) — idempotent skip: + +```json +{ + "name": "ghost", + "skipped": true, + "reason": "not_present" +} +``` + +Returned when the name was not in the runtime overlay (it may still exist in settings — settings entries cannot be removed via this route). + +Errors: + +| Status | Code | When | +| ------ | ------------------------- | ----------------------------------------------------------------------------- | +| `400` | `invalid_server_name` | Name empty, exceeds 256 chars, or contains characters outside `[A-Za-z0-9_-]` | +| `400` | `invalid_client_id` | `X-Qwen-Client-Id` header present but not registered for this workspace | +| `401` | `token_required` | No bearer token configured (strict gate) | +| `503` | `acp_channel_unavailable` | No live ACP child | + +### Shadow semantics + +Runtime entries form an ephemeral overlay on top of settings-defined MCP servers: + +- **Adding** a runtime server with the same name as a settings entry **shadows** it — the runtime config takes precedence. The original settings entry is not modified. +- **Removing** a runtime server that was shadowing a settings entry **un-shadows** it — the settings-defined config becomes active again on next connection. +- **Daemon restart** loses all runtime entries. Only settings-defined servers survive across restarts. Runtime servers are session-lifetime scoped. +- **`GET /workspace/mcp`** reports the merged view — both settings-defined and runtime servers appear in the `servers[]` array. There is no wire-level distinction between the two origins in the snapshot today. + +### Events + +Both routes emit **workspace-scoped** SSE events (all active session buses receive them): + +| Event | Emitted when | Payload fields | +| -------------------- | ------------------------------- | -------------------------------------------------------------------------------------- | +| `mcp_server_added` | `POST` succeeds (not skipped) | `name`, `transport`, `replaced`, `shadowedSettings`, `toolCount`, `originatorClientId` | +| `mcp_server_removed` | `DELETE` succeeds (not skipped) | `name`, `wasShadowingSettings`, `originatorClientId` | + +Skipped responses (`budget_warning_only`, `not_present`) do NOT emit events. + +Budget-related events from the existing `mcp_guardrail_events` surface (`mcp_budget_warning`, `mcp_child_refused_batch`) also fire when runtime additions cross the budget threshold. + ## What's next +- **Setting up a long-running daemon?** [Local launch templates (systemd / launchd / nohup / tmux)](./qwen-serve-deploy-local.md) for v0.16-alpha (local-only). - **Build a client?** See the [DaemonClient TypeScript quickstart](../developers/examples/daemon-client-quickstart.md) and the [HTTP protocol reference](../developers/qwen-serve-protocol.md). - **Reading the source?** Bridge code lives at `packages/cli/src/serve/`; SDK client at `packages/sdk-typescript/src/daemon/`. - **Tracking the roadmap?** Stage 1.5 / Stage 2 progress is tracked on issue [#3803](https://github.com/QwenLM/qwen-code/issues/3803). diff --git a/integration-tests/cli/_daemon-benchmark-helpers.ts b/integration-tests/cli/_daemon-benchmark-helpers.ts new file mode 100644 index 0000000000..16cd95fc59 --- /dev/null +++ b/integration-tests/cli/_daemon-benchmark-helpers.ts @@ -0,0 +1,440 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Benchmark-only helpers extracted from `qwen-daemon-vs-cli-benchmark.test.ts`. + * + * These functions wrap `/usr/bin/time` to capture OS-level resource metrics + * (peak RSS, CPU time, context switches, page faults, hardware counters) + * for both CLI cold-start and daemon lifecycle measurements. POSIX only — + * `/usr/bin/time -l` on macOS, `/usr/bin/time -v` on Linux. + * + * Also provides `measureProcessTreeRss` which walks the daemon process tree + * via the harness's `getRssMB` / `countDescendants` to produce a breakdown + * of daemon + ACP child + MCP grandchildren RSS. + */ + +import { spawn, execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { DaemonClient } from '@qwen-code/sdk'; + +import { + getRssMB, + countDescendants, + sleep, + DEFAULT_CLI_BIN, + DEFAULT_TOKEN, + type SpawnDaemonOptions, + type SpawnedDaemon, +} from './_daemon-harness.js'; + +const IS_DARWIN = process.platform === 'darwin'; + +// --------------------------------------------------------------------------- +// ProcessResourceMetrics — OS-level resource counters from /usr/bin/time +// --------------------------------------------------------------------------- + +export interface ProcessResourceMetrics { + peakRssMB: number | null; + userTimeMs: number | null; + sysTimeMs: number | null; + voluntaryCtxSwitches: number | null; + involuntaryCtxSwitches: number | null; + pageFaults: number | null; + pageReclaims: number | null; + instructionsRetired: number | null; + cyclesElapsed: number | null; +} + +export interface CliResult extends ProcessResourceMetrics { + wallClockMs: number; + exitCode: number | null; + stdout: string; + stderr: string; +} + +// --------------------------------------------------------------------------- +// ProcessTreeRss — RSS breakdown across daemon process tree +// --------------------------------------------------------------------------- + +export interface ProcessTreeRss { + daemonRssMB: number; + acpChildRssMB: number; + mcpChildrenRssMB: number; + totalRssMB: number; +} + +// --------------------------------------------------------------------------- +// StartupPhasesResult — CLI startup profiler phase breakdown +// --------------------------------------------------------------------------- + +export interface StartupPhasesResult { + moduleLoadMs: number | null; + configInitMs: number | null; + mcpSettledMs: number | null; + fullStartupMs: number | null; + wallClockMs: number; + peakRssMB: number | null; +} + +// --------------------------------------------------------------------------- +// parseTimeOutput +// --------------------------------------------------------------------------- + +export function parseTimeOutput(stderr: string): ProcessResourceMetrics { + const metrics: ProcessResourceMetrics = { + peakRssMB: null, + userTimeMs: null, + sysTimeMs: null, + voluntaryCtxSwitches: null, + involuntaryCtxSwitches: null, + pageFaults: null, + pageReclaims: null, + instructionsRetired: null, + cyclesElapsed: null, + }; + + if (IS_DARWIN) { + const timeLineMatch = stderr.match( + /(\d+\.\d+)\s+real\s+(\d+\.\d+)\s+user\s+(\d+\.\d+)\s+sys/, + ); + if (timeLineMatch) { + metrics.userTimeMs = Math.round(Number(timeLineMatch[2]) * 1000); + metrics.sysTimeMs = Math.round(Number(timeLineMatch[3]) * 1000); + } + + const rssMatch = stderr.match(/(\d+)\s+maximum resident set size/); + if (rssMatch) + metrics.peakRssMB = + Math.round((Number(rssMatch[1]) / 1024 / 1024) * 10) / 10; + + const volCtx = stderr.match(/(\d+)\s+voluntary context switches/); + if (volCtx) metrics.voluntaryCtxSwitches = Number(volCtx[1]); + + const involCtx = stderr.match(/(\d+)\s+involuntary context switches/); + if (involCtx) metrics.involuntaryCtxSwitches = Number(involCtx[1]); + + const pageFaults = stderr.match(/(\d+)\s+page faults/); + if (pageFaults) metrics.pageFaults = Number(pageFaults[1]); + + const pageReclaims = stderr.match(/(\d+)\s+page reclaims/); + if (pageReclaims) metrics.pageReclaims = Number(pageReclaims[1]); + + const instructions = stderr.match(/(\d+)\s+instructions retired/); + if (instructions) metrics.instructionsRetired = Number(instructions[1]); + + const cycles = stderr.match(/(\d+)\s+cycles elapsed/); + if (cycles) metrics.cyclesElapsed = Number(cycles[1]); + } else { + const userTime = stderr.match(/User time.*?:\s*(\d+\.\d+)/); + if (userTime) metrics.userTimeMs = Math.round(Number(userTime[1]) * 1000); + + const sysTime = stderr.match(/System time.*?:\s*(\d+\.\d+)/); + if (sysTime) metrics.sysTimeMs = Math.round(Number(sysTime[1]) * 1000); + + const rss = stderr.match(/Maximum resident set size.*?:\s*(\d+)/); + if (rss) metrics.peakRssMB = Math.round((Number(rss[1]) / 1024) * 10) / 10; + + const volCtx = stderr.match(/Voluntary context switches.*?:\s*(\d+)/); + if (volCtx) metrics.voluntaryCtxSwitches = Number(volCtx[1]); + + const involCtx = stderr.match(/Involuntary context switches.*?:\s*(\d+)/); + if (involCtx) metrics.involuntaryCtxSwitches = Number(involCtx[1]); + + const majorFaults = stderr.match(/Major.*?page faults.*?:\s*(\d+)/); + if (majorFaults) metrics.pageFaults = Number(majorFaults[1]); + + const minorFaults = stderr.match(/Minor.*?page faults.*?:\s*(\d+)/); + if (minorFaults) metrics.pageReclaims = Number(minorFaults[1]); + } + + return metrics; +} + +// --------------------------------------------------------------------------- +// spawnCliWithTime +// --------------------------------------------------------------------------- + +export function spawnCliWithTime( + args: string[], + opts?: { cwd?: string; env?: Record }, +): Promise { + const cliBin = DEFAULT_CLI_BIN; + return new Promise((resolve) => { + const t0 = performance.now(); + + const timeArgs = IS_DARWIN ? ['-l'] : ['-v']; + const child = spawn( + '/usr/bin/time', + [...timeArgs, process.execPath, cliBin, ...args], + { + stdio: ['ignore', 'pipe', 'pipe'], + cwd: opts?.cwd, + env: opts?.env ? { ...process.env, ...opts.env } : undefined, + }, + ); + + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (c: Buffer) => { + stdout += c.toString(); + }); + child.stderr?.on('data', (c: Buffer) => { + stderr += c.toString(); + }); + + child.once('exit', (code) => { + const wallClockMs = performance.now() - t0; + const metrics = parseTimeOutput(stderr); + resolve({ wallClockMs, exitCode: code, stdout, stderr, ...metrics }); + }); + }); +} + +// --------------------------------------------------------------------------- +// measureProcessTreeRss +// --------------------------------------------------------------------------- + +export function measureProcessTreeRss(daemonPid: number): ProcessTreeRss { + const daemonRss = getRssMB(daemonPid); + const desc = countDescendants(daemonPid); + + let acpChildRss = 0; + for (const pid of desc.acpChildren) { + const rss = getRssMB(pid); + if (!Number.isNaN(rss)) acpChildRss += rss; + } + + let mcpChildrenRss = 0; + for (const pid of desc.mcpGrandchildren) { + const rss = getRssMB(pid); + if (!Number.isNaN(rss)) mcpChildrenRss += rss; + } + + const safeDaemonRss = Number.isNaN(daemonRss) ? 0 : daemonRss; + return { + daemonRssMB: safeDaemonRss, + acpChildRssMB: acpChildRss, + mcpChildrenRssMB: mcpChildrenRss, + totalRssMB: safeDaemonRss + acpChildRss + mcpChildrenRss, + }; +} + +// --------------------------------------------------------------------------- +// measureCliStartupWithProfiler +// --------------------------------------------------------------------------- + +export async function measureCliStartupWithProfiler(opts?: { + cwd?: string; +}): Promise { + const perfDir = path.join(os.homedir(), '.qwen', 'startup-perf'); + const beforeFiles = new Set(); + try { + for (const f of fs.readdirSync(perfDir)) beforeFiles.add(f); + } catch { + /* dir might not exist yet */ + } + + const result = await spawnCliWithTime( + ['-p', 'x', '--output-format', 'text'], + { + cwd: opts?.cwd, + env: { + QWEN_CODE_PROFILE_STARTUP: '1', + QWEN_CODE_PROFILE_STARTUP_OUTER: '1', + }, + }, + ); + + const profileData: StartupPhasesResult = { + moduleLoadMs: null, + configInitMs: null, + mcpSettledMs: null, + fullStartupMs: null, + wallClockMs: result.wallClockMs, + peakRssMB: result.peakRssMB, + }; + + try { + const afterFiles = fs.readdirSync(perfDir); + const newFile = afterFiles.find((f) => !beforeFiles.has(f)); + if (newFile) { + const report = JSON.parse( + fs.readFileSync(path.join(perfDir, newFile), 'utf-8'), + ); + const dp = report.derivedPhases ?? {}; + profileData.moduleLoadMs = report.processUptimeAtT0Ms ?? null; + profileData.configInitMs = dp.config_initialize_dur ?? null; + profileData.mcpSettledMs = dp.mcp_all_settled ?? null; + profileData.fullStartupMs = + report.processUptimeAtT0Ms != null && report.totalMs != null + ? Math.round((report.processUptimeAtT0Ms + report.totalMs) * 10) / 10 + : null; + try { + fs.unlinkSync(path.join(perfDir, newFile)); + } catch { + /* best-effort */ + } + } + } catch { + /* profiler output not available — fall back to wall-clock only */ + } + + return profileData; +} + +// --------------------------------------------------------------------------- +// spawnDaemonWithTime +// --------------------------------------------------------------------------- + +export async function spawnDaemonWithTime( + opts: SpawnDaemonOptions = {}, +): Promise< + SpawnedDaemon & { getResourceMetrics: () => ProcessResourceMetrics } +> { + const token = opts.token ?? DEFAULT_TOKEN; + const cliBin = opts.cliBin ?? DEFAULT_CLI_BIN; + const bootTimeoutMs = opts.bootTimeoutMs ?? 10_000; + const extraArgs = opts.extraArgs ?? []; + + const daemonArgs = [ + cliBin, + 'serve', + '--port', + '0', + '--token', + token, + '--hostname', + '127.0.0.1', + '--workspace', + opts.workspaceCwd ?? process.cwd(), + ...extraArgs, + ]; + + const timeArgs = IS_DARWIN ? ['-l'] : ['-v']; + const child = spawn( + '/usr/bin/time', + [...timeArgs, process.execPath, ...daemonArgs], + { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ...opts.env }, + }, + ); + + const stdoutBuf = { value: '' }; + const stderrBuf = { value: '' }; + child.stdout?.on('data', (chunk: Buffer) => { + stdoutBuf.value += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderrBuf.value += chunk.toString(); + }); + + const LISTENING_RE = /listening on http:\/\/127\.0\.0\.1:(\d+)/; + const port = await new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + child.stdout?.off('data', onData); + child.off('exit', onExit); + clearTimeout(bootTimer); + }; + const fail = (err: Error, kill = false) => { + if (settled) return; + settled = true; + cleanup(); + if (kill && child.exitCode === null) child.kill('SIGTERM'); + reject(err); + }; + const bootTimer = setTimeout(() => { + fail( + new Error( + `daemon boot timeout after ${bootTimeoutMs}ms:\n` + + `stdout=${stdoutBuf.value}\nstderr=${stderrBuf.value}`, + ), + true, + ); + }, bootTimeoutMs); + const onData = () => { + const m = stdoutBuf.value.match(LISTENING_RE); + if (m && !settled) { + settled = true; + cleanup(); + resolve(Number(m[1])); + } + }; + const onExit = (code: number | null) => { + fail( + new Error( + `daemon exited with ${code} before listening:\n` + + `stdout=${stdoutBuf.value}\nstderr=${stderrBuf.value}`, + ), + ); + }; + child.stdout!.on('data', onData); + child.once('exit', onExit); + }); + + const base = `http://127.0.0.1:${port}`; + const client = new DaemonClient({ baseUrl: base, token }); + + const dispose = async () => { + if (child.exitCode !== null) return; + try { + const innerPids = execFileSync('pgrep', ['-P', String(child.pid!)], { + encoding: 'utf8', + timeout: 2_000, + stdio: ['ignore', 'pipe', 'ignore'], + }) + .trim() + .split('\n') + .filter(Boolean) + .map(Number); + for (const pid of innerPids) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + /* already gone */ + } + } + } catch { + child.kill('SIGTERM'); + } + await new Promise((resolve) => { + const t = setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + /* gone */ + } + resolve(); + }, 8_000); + child.once('exit', () => { + clearTimeout(t); + resolve(); + }); + }); + await sleep(200); + }; + + const getResourceMetrics = (): ProcessResourceMetrics => + parseTimeOutput(stderrBuf.value); + + return { + client, + daemon: child, + port, + base, + workspaceCwd: opts.workspaceCwd ?? process.cwd(), + token, + stdoutBuf, + stderrBuf, + dispose, + getResourceMetrics, + }; +} diff --git a/integration-tests/cli/_daemon-harness.ts b/integration-tests/cli/_daemon-harness.ts index f5010258b1..869c18f2af 100644 --- a/integration-tests/cli/_daemon-harness.ts +++ b/integration-tests/cli/_daemon-harness.ts @@ -37,6 +37,7 @@ import { type ExecFileSyncOptionsWithStringEncoding, } from 'node:child_process'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { DaemonClient, type SubscribeOptions } from '@qwen-code/sdk'; @@ -461,6 +462,8 @@ export function percentiles(values: number[]): Percentiles { */ export interface ConsumeSseResult { received: number; + /** The last non-undefined `ev.id` observed (for `Last-Event-ID` reconnect). */ + lastSeenId?: number; evictedAt?: number; evictionReason?: string; elapsedMs: number; @@ -481,6 +484,7 @@ export async function consumeSseEvents( const timeoutMs = opts.timeoutMs ?? 30_000; const startedAt = Date.now(); let received = 0; + let lastSeenId: number | undefined; let evictedAt: number | undefined; let evictionReason: string | undefined; @@ -499,6 +503,7 @@ export async function consumeSseEvents( signal: ac.signal, })) { received++; + if (ev.id !== undefined) lastSeenId = ev.id; if (ev.type === 'client_evicted') { evictedAt = ev.id; const data = ev.data as { reason?: string } | undefined; @@ -525,12 +530,37 @@ export async function consumeSseEvents( return { received, + lastSeenId, evictedAt, evictionReason, elapsedMs: Date.now() - startedAt, }; } -function sleep(ms: number): Promise { +export function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } + +export function gitHead(timeoutMs = 5_000): string | null { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf8', + timeout: timeoutMs, + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return null; + } +} + +export function makeTempWorkspace(label: string, prefix = 'qwen-test'): string { + return fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-${label}-`)); +} + +export interface ScenarioResult { + name: string; + status: 'passed' | 'failed' | 'skipped'; + durationMs: number; + error?: string; + metrics?: Record; +} diff --git a/integration-tests/cli/_daemon-perf-report.ts b/integration-tests/cli/_daemon-perf-report.ts new file mode 100644 index 0000000000..358e06663d --- /dev/null +++ b/integration-tests/cli/_daemon-perf-report.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Shared report primitives for daemon performance test suites (baseline, + * benchmark, loadtest). Each suite owns its own SnapshotShape and + * renderMarkdown; this module provides the common building blocks. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { Percentiles } from './_daemon-harness.js'; + +// --------------------------------------------------------------------------- +// Platform info +// --------------------------------------------------------------------------- + +export interface PlatformInfo { + os: string; + arch: string; + nodeVersion: string; +} + +export function collectPlatformInfo(): PlatformInfo { + return { + os: process.platform, + arch: process.arch, + nodeVersion: process.version, + }; +} + +// --------------------------------------------------------------------------- +// Output directory resolution +// --------------------------------------------------------------------------- + +export function resolveOutputDir(label: string): string { + const ts = new Date().toISOString().replace(/[:.]/g, '').replace(/Z$/, ''); + return ( + process.env['INTEGRATION_TEST_FILE_DIR'] ?? + path.join(process.cwd(), '.integration-tests', `${label}-${ts}`) + ); +} + +// --------------------------------------------------------------------------- +// Percentile formatting +// --------------------------------------------------------------------------- + +export function formatPercentiles(p: Percentiles | null | undefined): string { + return p && p.count > 0 + ? `p50=${p.p50.toFixed(0)} p90=${p.p90.toFixed(0)} p99=${p.p99.toFixed(0)} mean=${p.mean.toFixed(0)} (n=${p.count})` + : 'n/a'; +} + +// --------------------------------------------------------------------------- +// Snapshot artifact writer +// --------------------------------------------------------------------------- + +export function writeSnapshotArtifacts( + outputDir: string, + baseName: string, + snapshot: unknown, + markdown: string, + logTag: string, +): void { + fs.mkdirSync(outputDir, { recursive: true }); + const jsonPath = path.join(outputDir, `${baseName}.json`); + fs.writeFileSync(jsonPath, JSON.stringify(snapshot, null, 2)); + fs.writeFileSync(path.join(outputDir, `${baseName}.md`), markdown); + console.log(`[${logTag}] ${baseName}.json written to ${jsonPath}`); +} diff --git a/integration-tests/cli/mock-acp-typecheck.test.ts b/integration-tests/cli/mock-acp-typecheck.test.ts new file mode 100644 index 0000000000..cba2f6c01f --- /dev/null +++ b/integration-tests/cli/mock-acp-typecheck.test.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import type { Agent } from '@agentclientprotocol/sdk'; + +describe('mock ACP agent type compliance', () => { + it('satisfies required Agent interface methods', () => { + const _typeCheck: Pick< + Agent, + 'initialize' | 'authenticate' | 'newSession' | 'prompt' | 'cancel' + > = { + initialize: async () => ({ + protocolVersion: '', + agentInfo: { name: '', version: '' }, + authMethods: [], + agentCapabilities: {}, + }), + authenticate: async () => ({}), + newSession: async () => ({ sessionId: '' }), + prompt: async () => ({ stopReason: 'end_turn' as const }), + cancel: async () => {}, + }; + expect(_typeCheck).toBeDefined(); + }); +}); diff --git a/integration-tests/cli/qwen-daemon-loadtest.test.ts b/integration-tests/cli/qwen-daemon-loadtest.test.ts new file mode 100644 index 0000000000..4af0a79113 --- /dev/null +++ b/integration-tests/cli/qwen-daemon-loadtest.test.ts @@ -0,0 +1,523 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Daemon connection stress test — mock ACP, POSIX-only. + * + * Exercises the daemon's HTTP/SSE surface under concurrent session load + * using a mock ACP child (fixtures/mock-acp-child/agent.mjs) that + * responds in ~100ms without hitting a real model. This validates + * daemon/bridge overhead, session lifecycle, SSE eviction, and crash + * recovery — NOT real model latency or tool execution. + * + * Gated by QWEN_LOADTEST_ENABLED=1. Run via: + * QWEN_LOADTEST_ENABLED=1 npx vitest run \ + * --config integration-tests/vitest.loadtest.config.ts \ + * -- qwen-daemon-loadtest + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { performance } from 'node:perf_hooks'; +import { afterAll, afterEach, describe, expect, it } from 'vitest'; + +import { + spawnDaemon, + percentiles, + consumeSseEvents, + gitHead, + makeTempWorkspace, + sleep, + type SpawnedDaemon, + type ScenarioResult, +} from './_daemon-harness.js'; +import { + resolveOutputDir, + formatPercentiles, + writeSnapshotArtifacts, + collectPlatformInfo, +} from './_daemon-perf-report.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Skip logic +// --------------------------------------------------------------------------- + +const SKIP = + process.env['QWEN_LOADTEST_ENABLED'] !== '1' || + process.platform === 'win32' || + Boolean( + process.env['QWEN_SANDBOX'] && + process.env['QWEN_SANDBOX']!.toLowerCase() !== 'false', + ); + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const MOCK_AGENT_PATH = path.resolve( + __dirname, + '../fixtures/mock-acp-child/agent.mjs', +); +const OUTPUT_DIR = resolveOutputDir('loadtest'); + +const LIFECYCLE_CYCLES = 20; +const BURST_SESSIONS = 10; +const MAX_SESSIONS = 50; + +// --------------------------------------------------------------------------- +// Snapshot +// --------------------------------------------------------------------------- + +interface LoadtestSnapshot { + version: 1; + capturedAt: string; + gitCommit: string | null; + platform: ReturnType; + scenarios: ScenarioResult[]; +} + +const snapshot: LoadtestSnapshot = { + version: 1, + capturedAt: new Date().toISOString(), + gitCommit: gitHead(), + platform: collectPlatformInfo(), + scenarios: [], +}; + +// --------------------------------------------------------------------------- +// Process leak safety net +// --------------------------------------------------------------------------- + +let activeDaemon: SpawnedDaemon | null = null; + +process.on('exit', () => { + if (activeDaemon?.daemon.exitCode === null) { + try { + activeDaemon.daemon.kill('SIGKILL'); + } catch { + /* already gone */ + } + } +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockDaemonEnv(mode = 'echo'): Record { + return { + QWEN_CLI_ENTRY: MOCK_AGENT_PATH, + MOCK_ACP_MODE: mode, + MOCK_ACP_PROMPT_DELAY_MS: '100', + MOCK_ACP_EMIT_CHUNKS: '3', + }; +} + +async function withDaemon( + opts: { + mode?: string; + label: string; + extraArgs?: string[]; + skipWarmup?: boolean; + }, + fn: (d: SpawnedDaemon, ws: string) => Promise, +): Promise { + const ws = makeTempWorkspace(opts.label); + let d: SpawnedDaemon | undefined; + try { + d = await spawnDaemon({ + workspaceCwd: ws, + extraArgs: [ + '--max-sessions', + String(MAX_SESSIONS), + ...(opts.extraArgs ?? []), + ], + env: mockDaemonEnv(opts.mode ?? 'echo'), + }); + activeDaemon = d; + + if (!opts.skipWarmup) { + const anchor = await d.client.createOrAttachSession({ + sessionScope: 'thread', + }); + await d.client.prompt(anchor.sessionId, { + prompt: [{ type: 'text', text: 'warmup' }], + }); + } + + await fn(d, ws); + } catch (err) { + if (d) { + console.error( + `[loadtest:${opts.label}] stdout:\n${d.stdoutBuf.value}\nstderr:\n${d.stderrBuf.value}`, + ); + } + throw err; + } finally { + if (d) { + await d.dispose(); + activeDaemon = null; + } + await sleep(100); + fs.rmSync(ws, { recursive: true, force: true }); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +(SKIP ? describe.skip : describe).sequential( + 'daemon connection stress test (mock ACP, POSIX-only)', + { retry: 0 }, + () => { + afterEach(() => { + if (activeDaemon?.daemon.exitCode === null) { + try { + activeDaemon.daemon.kill('SIGTERM'); + } catch { + /* already gone */ + } + activeDaemon = null; + } + }); + + // Scenario 1: rapid lifecycle + it('rapid lifecycle: create-prompt-close cycles', async () => { + const t0 = performance.now(); + const latencies: number[] = []; + + await withDaemon({ label: 'lifecycle' }, async (d) => { + for (let i = 0; i < LIFECYCLE_CYCLES; i++) { + const t = performance.now(); + const session = await d.client.createOrAttachSession({ + sessionScope: 'thread', + }); + await d.client.prompt(session.sessionId, { + prompt: [{ type: 'text', text: `cycle-${i}` }], + }); + await d.client.closeSession(session.sessionId); + latencies.push(performance.now() - t); + } + }); + + const stats = percentiles(latencies); + let status: 'passed' | 'failed' = 'passed'; + try { + expect(stats.p99).toBeLessThan(30_000); + } catch (err) { + status = 'failed'; + throw err; + } finally { + snapshot.scenarios.push({ + name: 'rapid-lifecycle', + status, + durationMs: performance.now() - t0, + metrics: { cycles: LIFECYCLE_CYCLES, ...stats }, + }); + } + }, 120_000); + + // Scenario 2: SSE slow consumer triggers eviction + it('SSE slow consumer triggers eviction through HTTP', async () => { + const t0 = performance.now(); + let evicted = false; + let received = 0; + + await withDaemon( + { label: 'sse-eviction', extraArgs: ['--event-ring-size', '32'] }, + async (d) => { + const session = await d.client.createOrAttachSession({ + sessionScope: 'thread', + }); + + // Start a slow SSE consumer with a small queue (16 = daemon + // minimum) so eviction triggers before the 60s timeout. + const consumePromise = consumeSseEvents(d.client, session.sessionId, { + consumerDelayMs: 200, + timeoutMs: 60_000, + subscribe: { maxQueued: 16 }, + }); + + // Fire rapid prompts to overwhelm the slow consumer's queue + const promptCount = 20; + for (let i = 0; i < promptCount; i++) { + try { + await d.client.prompt(session.sessionId, { + prompt: [{ type: 'text', text: `flood-${i}` }], + }); + } catch { + break; + } + } + + const result = await consumePromise; + evicted = result.evictionReason !== undefined; + received = result.received; + + await d.client.closeSession(session.sessionId); + }, + ); + + let status: 'passed' | 'failed' = 'passed'; + try { + expect(received).toBeGreaterThan(0); + expect(evicted).toBe(true); + } catch (err) { + status = 'failed'; + throw err; + } finally { + snapshot.scenarios.push({ + name: 'sse-slow-consumer-eviction', + status, + durationMs: performance.now() - t0, + metrics: { evicted, received }, + }); + } + }, 120_000); + + // Scenario 3: Last-Event-ID reconnect under concurrent load + it('Last-Event-ID reconnect under concurrent load', async () => { + const t0 = performance.now(); + let reconnectReceived = 0; + + await withDaemon({ label: 'reconnect' }, async (d) => { + const session = await d.client.createOrAttachSession({ + sessionScope: 'thread', + }); + + // Fire a prompt so the session has events in its ring buffer. + await d.client.prompt(session.sessionId, { + prompt: [{ type: 'text', text: 'seed-events' }], + }); + + // Replay from the beginning to collect seeded events. + const initial = await consumeSseEvents(d.client, session.sessionId, { + maxEvents: 3, + timeoutMs: 10_000, + subscribe: { lastEventId: 0 }, + }); + + // Fire another prompt to push more events into the ring. + await d.client.prompt(session.sessionId, { + prompt: [{ type: 'text', text: 'generate-more' }], + }); + + // Reconnect with the actual last event id from the initial batch. + if (initial.lastSeenId !== undefined) { + const reconnect = await consumeSseEvents( + d.client, + session.sessionId, + { + maxEvents: 5, + timeoutMs: 10_000, + subscribe: { lastEventId: initial.lastSeenId }, + }, + ); + reconnectReceived = reconnect.received; + } + + await d.client.closeSession(session.sessionId); + }); + + let status: 'passed' | 'failed' = 'passed'; + try { + expect(reconnectReceived).toBeGreaterThan(0); + } catch (err) { + status = 'failed'; + throw err; + } finally { + snapshot.scenarios.push({ + name: 'last-event-id-reconnect', + status, + durationMs: performance.now() - t0, + metrics: { reconnectReceived }, + }); + } + }, 120_000); + + // Scenario 4: ACP child crash → session error recovery + it('ACP child crash → session error recovery', async () => { + const t0 = performance.now(); + let crashDetected = false; + let recoverySucceeded = false; + + await withDaemon( + { label: 'crash-recovery', mode: 'crash-on-prompt', skipWarmup: true }, + async (d) => { + const session = await d.client.createOrAttachSession({ + sessionScope: 'thread', + }); + + try { + await d.client.prompt(session.sessionId, { + prompt: [{ type: 'text', text: 'trigger-crash' }], + }); + } catch { + crashDetected = true; + } + + // Poll for daemon recovery, then verify it can serve new work. + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + try { + await d.client.health(); + const recoverySession = await d.client.createOrAttachSession({ + sessionScope: 'thread', + }); + recoverySucceeded = recoverySession.sessionId !== undefined; + break; + } catch { + await sleep(200); + } + } + }, + ); + + let status: 'passed' | 'failed' = 'passed'; + try { + expect(crashDetected).toBe(true); + expect(recoverySucceeded).toBe(true); + } catch (err) { + status = 'failed'; + throw err; + } finally { + snapshot.scenarios.push({ + name: 'acp-crash-recovery', + status, + durationMs: performance.now() - t0, + metrics: { crashDetected, recoverySucceeded }, + }); + } + }, 90_000); + + // Scenario 5: burst concurrent sessions + it('burst: concurrent sessions with mock prompts', async () => { + const t0 = performance.now(); + const latencies: number[] = []; + let successCount = 0; + let failureCount = 0; + + await withDaemon({ label: 'burst' }, async (d) => { + const results = await Promise.allSettled( + Array.from({ length: BURST_SESSIONS }, async (_, i) => { + const t = performance.now(); + const session = await d.client.createOrAttachSession({ + sessionScope: 'thread', + }); + await d.client.prompt(session.sessionId, { + prompt: [{ type: 'text', text: `burst-${i}` }], + }); + await d.client.closeSession(session.sessionId); + return performance.now() - t; + }), + ); + + for (const r of results) { + if (r.status === 'fulfilled') { + successCount++; + latencies.push(r.value); + } else { + failureCount++; + } + } + }); + + const stats = percentiles(latencies); + let status: 'passed' | 'failed' = 'passed'; + try { + expect(failureCount).toBe(0); + expect(stats.p99).toBeLessThan(60_000); + } catch (err) { + status = 'failed'; + throw err; + } finally { + snapshot.scenarios.push({ + name: 'burst-concurrent', + status, + durationMs: performance.now() - t0, + metrics: { + burstSize: BURST_SESSIONS, + successCount, + failureCount, + ...stats, + }, + }); + } + }, 120_000); + + // Report + afterAll(() => { + if (SKIP) return; + writeSnapshotArtifacts( + OUTPUT_DIR, + 'loadtest-report', + snapshot, + renderMarkdown(snapshot), + 'loadtest', + ); + }); + }, +); + +// --------------------------------------------------------------------------- +// Markdown renderer +// --------------------------------------------------------------------------- + +function renderMarkdown(s: LoadtestSnapshot): string { + const lines = [ + `# qwen serve daemon — connection stress test report`, + ``, + `Captured: ${s.capturedAt}`, + `Git: ${s.gitCommit ?? 'unknown'}`, + `Platform: ${s.platform.os}/${s.platform.arch} node=${s.platform.nodeVersion}`, + ``, + `## Scenarios`, + ``, + ]; + + for (const sc of s.scenarios) { + lines.push( + `### ${sc.name}`, + `- Status: ${sc.status}`, + `- Duration: ${sc.durationMs.toFixed(0)}ms`, + ); + if (sc.error) { + lines.push(`- Error: ${sc.error}`); + } + if (sc.metrics) { + const m = sc.metrics; + const pctlKeys = ['count', 'p50', 'p90', 'p99', 'mean', 'min', 'max']; + if ( + 'p50' in m && + typeof m['p50'] === 'number' && + typeof m['count'] === 'number' + ) { + lines.push( + `- Latency: ${formatPercentiles({ + count: m['count'], + p50: m['p50'], + p90: m['p90'] as number, + p99: m['p99'] as number, + mean: m['mean'] as number, + min: m['min'] as number, + max: m['max'] as number, + })}`, + ); + } + for (const [k, v] of Object.entries(m)) { + if (pctlKeys.includes(k)) continue; + lines.push(`- ${k}: ${JSON.stringify(v)}`); + } + } + lines.push(``); + } + + return lines.join('\n'); +} diff --git a/integration-tests/cli/qwen-daemon-vs-cli-benchmark.test.ts b/integration-tests/cli/qwen-daemon-vs-cli-benchmark.test.ts new file mode 100644 index 0000000000..d6215ab74c --- /dev/null +++ b/integration-tests/cli/qwen-daemon-vs-cli-benchmark.test.ts @@ -0,0 +1,1418 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Daemon vs CLI — performance benchmark. + * + * Compares the two qwen-code execution modes across startup latency, + * session creation, memory footprint, and (when a model key is + * available) prompt round-trip latency and concurrent queuing behavior. + * + * Gated by QWEN_BENCHMARK_ENABLED=1 — does NOT run in the default CI + * suite. POSIX only (uses `ps`, `pgrep`, `/usr/bin/time`). + * + * Outputs a JSON + Markdown snapshot to the integration test output + * directory, similar to `qwen-serve-baseline.test.ts`. + */ + +import * as fs from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { DaemonHttpError } from '@qwen-code/sdk'; +import { + spawnDaemon, + percentiles, + gitHead, + makeTempWorkspace, + sleep, + type SpawnedDaemon, + type Percentiles, +} from './_daemon-harness.js'; +import { + spawnDaemonWithTime, + spawnCliWithTime, + measureProcessTreeRss, + measureCliStartupWithProfiler, + type ProcessResourceMetrics, + type ProcessTreeRss, + type StartupPhasesResult, +} from './_daemon-benchmark-helpers.js'; +import { + resolveOutputDir, + formatPercentiles, + writeSnapshotArtifacts, + collectPlatformInfo, +} from './_daemon-perf-report.js'; + +// --------------------------------------------------------------------------- +// Skip logic +// --------------------------------------------------------------------------- + +const SKIP = + process.env['QWEN_BENCHMARK_ENABLED'] !== '1' || + process.platform === 'win32' || + Boolean( + process.env['QWEN_SANDBOX'] && + process.env['QWEN_SANDBOX']!.toLowerCase() !== 'false', + ); + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const HEAVY = process.env['BENCHMARK_HEAVY'] === '1'; +const ITERATIONS = Number( + process.env['BENCHMARK_ITERATIONS'] ?? (HEAVY ? 20 : 5), +); +const CONCURRENT_SESSIONS = Number( + process.env['BENCHMARK_CONCURRENT_SESSIONS'] ?? (HEAVY ? 10 : 5), +); + +const THROUGHPUT_WINDOW_S = Number( + process.env['BENCHMARK_THROUGHPUT_WINDOW_S'] ?? (HEAVY ? 30 : 10), +); +const CHURN_ROUNDS = Number( + process.env['BENCHMARK_CHURN_ROUNDS'] ?? ITERATIONS * 4, +); + +const PROMPT_CREDENTIAL_ENV_KEYS = [ + 'DASHSCOPE_API_KEY', + 'OPENAI_API_KEY', + 'ANTHROPIC_API_KEY', + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + 'QWEN_API_KEY', +]; +const HAS_MODEL_KEY = + PROMPT_CREDENTIAL_ENV_KEYS.some((k) => Boolean(process.env[k])) || + Object.entries(process.env).some( + ([k, v]) => k.startsWith('QWEN_CUSTOM_API_KEY_') && Boolean(v), + ); +const SKIP_PROMPT = !HAS_MODEL_KEY; + +const OUTPUT_DIR = resolveOutputDir('benchmark'); + +const THRESH = { + cliColdStartP99MaxMs: 10_000, + daemonBootP99MaxMs: 30_000, + sessionCreateP99MaxMs: 5_000, + daemonBaselineTreeRssMaxMB: 1_500, + promptP99MaxMs: 120_000, +}; + +// --------------------------------------------------------------------------- +// Snapshot accumulator +// --------------------------------------------------------------------------- + +interface BenchmarkSnapshot { + version: 1; + capturedAt: string; + gitCommit: string | null; + platform: { os: string; arch: string; nodeVersion: string }; + notes: string[]; + config: { + iterations: number; + concurrentSessions: number; + heavy: boolean; + }; + cliColdStart?: Percentiles & { + peakRssMB: number | null; + startupPhases?: { + moduleLoadMs: number | null; + configInitMs: number | null; + mcpSettledMs: number | null; + fullStartupMs: number | null; + }; + }; + daemonBootLatency?: Percentiles; + warmSessionCreation?: Percentiles; + memoryBaseline?: { + cliVersionPeakRssMB: number | null; + daemon0Sessions: ProcessTreeRss | null; + daemon5Sessions: ProcessTreeRss | null; + daemon10Sessions: ProcessTreeRss | null; + growthPerSessionMB: number | null; + }; + singlePromptLatency?: { + cli: Percentiles | null; + daemon: Percentiles | null; + skipped: boolean; + skipReason?: string; + }; + concurrentQueueingLatency?: { + sessionCount: number; + totalPrompts: number; + wallClockMs: number; + promptsPerSec: number; + perPromptLatency: Percentiles; + successCount: number; + failureCount: number; + skipped: boolean; + skipReason?: string; + }; + burstStress?: { + latency: Percentiles; + successRate: number; + concurrency: number; + }; + throughputStress?: { + anchored: { opsPerSec: number; totalOps: number }; + unanchored: { opsPerSec: number; totalOps: number }; + windowSeconds: number; + }; + sessionChurn?: { + latency: Percentiles; + rounds: number; + rssDriftMB: number; + }; + sessionLimitSaturation?: { + limitEnforced: boolean; + recoverySucceeded: boolean; + errorCode: string; + }; + sseConnectionFlood?: { + connectionsOpened: number; + allConnected: boolean; + daemonHealthyAfter: boolean; + }; + resourceProfile?: { + cli: ProcessResourceMetrics | null; + daemon: ProcessResourceMetrics | null; + }; +} + +const snapshot: BenchmarkSnapshot = { + version: 1, + capturedAt: new Date().toISOString(), + gitCommit: gitHead(), + platform: collectPlatformInfo(), + notes: [ + 'CLI cold start uses -p mode with QWEN_CODE_PROFILE_STARTUP=1 to ' + + 'measure full initialization (Node startup + ESM + config + MCP ' + + 'discovery + auth). Profiler-reported fullStartupMs is used when ' + + 'available, else wall-clock time.', + 'Daemon boot latency includes HTTP listener startup. ACP child ' + + 'is preheated after listener start (fire-and-forget); first ' + + 'session creation coalesces onto the preheat if still in flight.', + 'Memory RSS is measured across the full process tree (daemon + ACP ' + + 'child + MCP grandchildren), not just the daemon parent.', + 'Stage 1 daemon uses a single ACP child — concurrent prompts are ' + + 'queued and processed serially at the ACP level. The concurrent ' + + 'queuing latency metric measures HTTP-layer concurrency handling, ' + + 'not true parallel prompt execution.', + ], + config: { + iterations: ITERATIONS, + concurrentSessions: CONCURRENT_SESSIONS, + heavy: HEAVY, + }, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +(SKIP ? describe.skip : describe)( + 'daemon vs CLI benchmark (POSIX-only, QWEN_BENCHMARK_ENABLED=1)', + { retry: 0 }, + () => { + // ----------------------------------------------------------------------- + // Phase 1: no-model metrics + // ----------------------------------------------------------------------- + describe('Phase 1: no-model metrics', () => { + it( + 'CLI cold start latency (full init via startup profiler)', + async () => { + const ws = makeTempWorkspace('cli-coldstart'); + try { + const latencies: number[] = []; + let lastResult: StartupPhasesResult | null = null; + + // Warmup (excluded) + await measureCliStartupWithProfiler({ cwd: ws }); + + for (let i = 0; i < ITERATIONS; i++) { + const result = await measureCliStartupWithProfiler({ cwd: ws }); + // Use fullStartupMs (profiler) if available, else wall-clock + latencies.push(result.fullStartupMs ?? result.wallClockMs); + lastResult = result; + } + + const stats = percentiles(latencies); + snapshot.cliColdStart = { + ...stats, + peakRssMB: lastResult?.peakRssMB ?? null, + startupPhases: lastResult + ? { + moduleLoadMs: lastResult.moduleLoadMs, + configInitMs: lastResult.configInitMs, + mcpSettledMs: lastResult.mcpSettledMs, + fullStartupMs: lastResult.fullStartupMs, + } + : undefined, + }; + + // Capture CLI resource metrics from last iteration + const cliTimeResult = await spawnCliWithTime( + ['-p', 'x', '--output-format', 'text'], + { cwd: ws }, + ); + snapshot.resourceProfile = { + ...snapshot.resourceProfile, + cli: { + peakRssMB: cliTimeResult.peakRssMB, + userTimeMs: cliTimeResult.userTimeMs, + sysTimeMs: cliTimeResult.sysTimeMs, + voluntaryCtxSwitches: cliTimeResult.voluntaryCtxSwitches, + involuntaryCtxSwitches: cliTimeResult.involuntaryCtxSwitches, + pageFaults: cliTimeResult.pageFaults, + pageReclaims: cliTimeResult.pageReclaims, + instructionsRetired: cliTimeResult.instructionsRetired, + cyclesElapsed: cliTimeResult.cyclesElapsed, + }, + daemon: snapshot.resourceProfile?.daemon ?? null, + }; + + expect(stats.p99).toBeLessThan(THRESH.cliColdStartP99MaxMs); + } finally { + fs.rmSync(ws, { recursive: true, force: true }); + } + }, + ITERATIONS * 30_000 + 60_000, + ); + + it( + 'daemon boot latency (including first session)', + async () => { + const latencies: number[] = []; + let daemonResourceMetrics: ProcessResourceMetrics | null = null; + + for (let i = 0; i < ITERATIONS; i++) { + const ws = makeTempWorkspace(`boot-${i}`); + const isLast = i === ITERATIONS - 1; + + if (isLast) { + // Last iteration: use /usr/bin/time wrapper to capture + // resource metrics for the daemon lifecycle. + let timedDaemon: + | (SpawnedDaemon & { + getResourceMetrics: () => ProcessResourceMetrics; + }) + | undefined; + try { + const t0 = performance.now(); + timedDaemon = await spawnDaemonWithTime({ + workspaceCwd: ws, + bootTimeoutMs: 35_000, + }); + await timedDaemon.client.createOrAttachSession({ + workspaceCwd: ws, + }); + latencies.push(performance.now() - t0); + await timedDaemon.dispose(); + daemonResourceMetrics = timedDaemon.getResourceMetrics(); + } finally { + if (timedDaemon) await timedDaemon.dispose(); + fs.rmSync(ws, { recursive: true, force: true }); + } + } else { + let daemon: SpawnedDaemon | undefined; + try { + const t0 = performance.now(); + daemon = await spawnDaemon({ + workspaceCwd: ws, + bootTimeoutMs: 35_000, + }); + await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + }); + latencies.push(performance.now() - t0); + } finally { + if (daemon) await daemon.dispose(); + fs.rmSync(ws, { recursive: true, force: true }); + } + } + } + + const stats = percentiles(latencies); + snapshot.daemonBootLatency = stats; + + if (daemonResourceMetrics) { + snapshot.resourceProfile = { + cli: snapshot.resourceProfile?.cli ?? null, + daemon: daemonResourceMetrics, + }; + } + + expect(stats.p99).toBeLessThan(THRESH.daemonBootP99MaxMs); + }, + ITERATIONS * 50_000 + 60_000, + ); + + it( + 'warm session creation latency', + async () => { + const ws = makeTempWorkspace('warm-session'); + let daemon: SpawnedDaemon | undefined; + try { + daemon = await spawnDaemon({ + workspaceCwd: ws, + bootTimeoutMs: 35_000, + extraArgs: ['--max-sessions', '0'], + }); + + // Warmup: first session triggers ACP child spawn. + await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + }); + + const latencies: number[] = []; + for (let i = 0; i < ITERATIONS; i++) { + const t0 = performance.now(); + await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + sessionScope: 'thread', + }); + latencies.push(performance.now() - t0); + } + + const stats = percentiles(latencies); + snapshot.warmSessionCreation = stats; + + expect(stats.p99).toBeLessThan(THRESH.sessionCreateP99MaxMs); + } finally { + if (daemon) await daemon.dispose(); + fs.rmSync(ws, { recursive: true, force: true }); + } + }, + ITERATIONS * 10_000 + 60_000, + ); + + it('memory baseline (process tree RSS)', async () => { + const ws = makeTempWorkspace('memory'); + + // CLI peak RSS via /usr/bin/time (full init path) + const cliResult = await spawnCliWithTime( + ['-p', 'x', '--output-format', 'text'], + { cwd: ws }, + ); + const cliPeakRss = cliResult.peakRssMB; + + // Daemon RSS at 0/5/10 sessions + let daemon: SpawnedDaemon | undefined; + const sessionIds: string[] = []; + try { + daemon = await spawnDaemon({ + workspaceCwd: ws, + bootTimeoutMs: 35_000, + extraArgs: ['--max-sessions', '0'], + }); + + // Trigger ACP child spawn with first session + const firstSession = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + }); + sessionIds.push(firstSession.sessionId); + await sleep(1000); + const rss0 = daemon.daemon.pid + ? measureProcessTreeRss(daemon.daemon.pid) + : null; + + // Create 4 more sessions (total 5) + for (let i = 0; i < 4; i++) { + const s = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + sessionScope: 'thread', + }); + sessionIds.push(s.sessionId); + } + await sleep(1000); + const rss5 = daemon.daemon.pid + ? measureProcessTreeRss(daemon.daemon.pid) + : null; + + // Create 5 more sessions (total 10) + for (let i = 0; i < 5; i++) { + const s = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + sessionScope: 'thread', + }); + sessionIds.push(s.sessionId); + } + await sleep(1000); + const rss10 = daemon.daemon.pid + ? measureProcessTreeRss(daemon.daemon.pid) + : null; + + const growthPerSession = + rss0 && rss10 + ? Math.round(((rss10.totalRssMB - rss0.totalRssMB) / 9) * 10) / 10 + : null; + + snapshot.memoryBaseline = { + cliVersionPeakRssMB: cliPeakRss, + daemon0Sessions: rss0, + daemon5Sessions: rss5, + daemon10Sessions: rss10, + growthPerSessionMB: growthPerSession, + }; + + if (rss0) { + expect(rss0.totalRssMB).toBeLessThan( + THRESH.daemonBaselineTreeRssMaxMB, + ); + } + } finally { + if (daemon) { + for (const sid of sessionIds) { + try { + await daemon.client.closeSession(sid); + } catch { + /* best-effort */ + } + } + await daemon.dispose(); + } + fs.rmSync(ws, { recursive: true, force: true }); + } + }, 120_000); + }); + + // ----------------------------------------------------------------------- + // Phase 2: model-dependent metrics + // ----------------------------------------------------------------------- + describe('Phase 2: model-dependent metrics', () => { + if (!SKIP_PROMPT) { + it( + 'single prompt latency — CLI vs daemon', + async () => { + const ws = makeTempWorkspace('prompt'); + let daemon: SpawnedDaemon | undefined; + try { + // --- CLI side --- + const cliLatencies: number[] = []; + // Warmup + await spawnCliWithTime( + [ + '-p', + 'reply with the single word ok', + '--output-format', + 'text', + ], + { cwd: ws }, + ); + for (let i = 0; i < ITERATIONS; i++) { + const result = await spawnCliWithTime( + [ + '-p', + 'reply with the single word ok', + '--output-format', + 'text', + ], + { cwd: ws }, + ); + cliLatencies.push(result.wallClockMs); + } + + // --- Daemon side --- + daemon = await spawnDaemon({ + workspaceCwd: ws, + bootTimeoutMs: 35_000, + }); + const session = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + }); + + const daemonLatencies: number[] = []; + // Warmup + await daemon.client.prompt(session.sessionId, { + prompt: [ + { type: 'text', text: 'reply with the single word ok' }, + ], + }); + for (let i = 0; i < ITERATIONS; i++) { + const t0 = performance.now(); + await daemon.client.prompt(session.sessionId, { + prompt: [ + { type: 'text', text: 'reply with the single word ok' }, + ], + }); + daemonLatencies.push(performance.now() - t0); + } + + snapshot.singlePromptLatency = { + cli: percentiles(cliLatencies), + daemon: percentiles(daemonLatencies), + skipped: false, + }; + + expect(snapshot.singlePromptLatency.cli!.p99).toBeLessThan( + THRESH.promptP99MaxMs, + ); + expect(snapshot.singlePromptLatency.daemon!.p99).toBeLessThan( + THRESH.promptP99MaxMs, + ); + } finally { + if (daemon) await daemon.dispose(); + fs.rmSync(ws, { recursive: true, force: true }); + } + }, + ITERATIONS * 180_000 + 60_000, + ); + + it( + 'concurrent queuing latency (daemon only)', + async () => { + const ws = makeTempWorkspace('concurrent'); + let daemon: SpawnedDaemon | undefined; + const sessionIds: string[] = []; + try { + daemon = await spawnDaemon({ + workspaceCwd: ws, + bootTimeoutMs: 35_000, + }); + + // Create M sessions + for (let i = 0; i < CONCURRENT_SESSIONS; i++) { + const s = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + sessionScope: 'thread', + }); + sessionIds.push(s.sessionId); + } + + // Fire all prompts concurrently + const perPromptLatencies: number[] = []; + const wallT0 = performance.now(); + + const results = await Promise.allSettled( + sessionIds.map(async (sid) => { + const t0 = performance.now(); + await daemon!.client.prompt(sid, { + prompt: [ + { type: 'text', text: 'reply with the single word ok' }, + ], + }); + perPromptLatencies.push(performance.now() - t0); + }), + ); + + const wallClockMs = performance.now() - wallT0; + const successCount = results.filter( + (r) => r.status === 'fulfilled', + ).length; + const failureCount = results.filter( + (r) => r.status === 'rejected', + ).length; + + snapshot.concurrentQueueingLatency = { + sessionCount: CONCURRENT_SESSIONS, + totalPrompts: CONCURRENT_SESSIONS, + wallClockMs, + promptsPerSec: + Math.round((successCount / (wallClockMs / 1000)) * 100) / 100, + perPromptLatency: percentiles(perPromptLatencies), + successCount, + failureCount, + skipped: false, + }; + + expect(wallClockMs).toBeLessThan( + CONCURRENT_SESSIONS * THRESH.promptP99MaxMs, + ); + } finally { + if (daemon) { + for (const sid of sessionIds) { + try { + await daemon.client.closeSession(sid); + } catch { + /* best-effort */ + } + } + await daemon.dispose(); + } + fs.rmSync(ws, { recursive: true, force: true }); + } + }, + CONCURRENT_SESSIONS * 180_000 + 120_000, + ); + } + + if (SKIP_PROMPT) { + it('prompt tests skipped (no model credential env)', () => { + snapshot.singlePromptLatency = { + cli: null, + daemon: null, + skipped: true, + skipReason: + 'No recognized model credential env var is set. ' + + 'Prompt benchmarks require real model access.', + }; + snapshot.concurrentQueueingLatency = { + sessionCount: 0, + totalPrompts: 0, + wallClockMs: 0, + promptsPerSec: 0, + perPromptLatency: percentiles([]), + successCount: 0, + failureCount: 0, + skipped: true, + skipReason: + 'No recognized model credential env var is set. ' + + 'Concurrent benchmarks require real model access.', + }; + expect(true).toBe(true); + }); + } + }); + + // ----------------------------------------------------------------------- + // Phase 3: stress tests (no model required) + // ----------------------------------------------------------------------- + describe('Phase 3: stress tests', () => { + it( + 'concurrent burst — N simultaneous session creations (daemon)', + async () => { + const ws = makeTempWorkspace('burst'); + let daemon: SpawnedDaemon | undefined; + const sessionIds: string[] = []; + try { + daemon = await spawnDaemon({ + workspaceCwd: ws, + bootTimeoutMs: 35_000, + extraArgs: ['--max-sessions', '0'], + }); + // First session triggers ACP child spawn (included in boot, + // not in burst measurement). + const warmup = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + }); + sessionIds.push(warmup.sessionId); + + const latencies: number[] = []; + const results = await Promise.allSettled( + Array.from({ length: CONCURRENT_SESSIONS }, async () => { + const t0 = performance.now(); + const s = await daemon!.client.createOrAttachSession({ + workspaceCwd: ws, + sessionScope: 'thread', + }); + latencies.push(performance.now() - t0); + sessionIds.push(s.sessionId); + }), + ); + + const successCount = results.filter( + (r) => r.status === 'fulfilled', + ).length; + + snapshot.burstStress = { + latency: percentiles(latencies), + successRate: successCount / CONCURRENT_SESSIONS, + concurrency: CONCURRENT_SESSIONS, + }; + + expect(successCount).toBe(CONCURRENT_SESSIONS); + expect(snapshot.burstStress.latency.p99).toBeLessThan(15_000); + } finally { + if (daemon) { + for (const sid of sessionIds) { + try { + await daemon.client.closeSession(sid); + } catch { + /* best-effort */ + } + } + await daemon.dispose(); + } + fs.rmSync(ws, { recursive: true, force: true }); + } + }, + CONCURRENT_SESSIONS * 20_000 + 60_000, + ); + + it( + 'sustained throughput — session create+close ops/sec (daemon)', + async () => { + const ws = makeTempWorkspace('throughput'); + let daemon: SpawnedDaemon | undefined; + const anchorIds: string[] = []; + try { + daemon = await spawnDaemon({ + workspaceCwd: ws, + bootTimeoutMs: 35_000, + extraArgs: ['--max-sessions', '0'], + }); + + const windowMs = THROUGHPUT_WINDOW_S * 1000; + + // --- Anchored: 3 anchor sessions keep ACP child alive --- + for (let i = 0; i < 3; i++) { + const s = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + sessionScope: 'thread', + }); + anchorIds.push(s.sessionId); + } + + let anchoredOps = 0; + const anchoredEnd = performance.now() + windowMs; + while (performance.now() < anchoredEnd) { + const s = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + sessionScope: 'thread', + }); + await daemon.client.closeSession(s.sessionId); + anchoredOps++; + } + + // Clean up anchors before unanchored run + for (const sid of anchorIds) { + try { + await daemon.client.closeSession(sid); + } catch { + /* best-effort */ + } + } + anchorIds.length = 0; + + // --- Unanchored: no anchor sessions, each close kills ACP --- + // First create triggers ACP respawn; subsequent cycles include + // full ACP teardown + respawn cost. + let unanchoredOps = 0; + const unanchoredEnd = performance.now() + windowMs; + while (performance.now() < unanchoredEnd) { + const s = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + sessionScope: 'thread', + }); + await daemon.client.closeSession(s.sessionId); + unanchoredOps++; + } + + snapshot.throughputStress = { + anchored: { + opsPerSec: + Math.round((anchoredOps / THROUGHPUT_WINDOW_S) * 100) / 100, + totalOps: anchoredOps, + }, + unanchored: { + opsPerSec: + Math.round((unanchoredOps / THROUGHPUT_WINDOW_S) * 100) / 100, + totalOps: unanchoredOps, + }, + windowSeconds: THROUGHPUT_WINDOW_S, + }; + + expect(anchoredOps).toBeGreaterThan(0); + expect(unanchoredOps).toBeGreaterThan(0); + } finally { + if (daemon) { + for (const sid of anchorIds) { + try { + await daemon.client.closeSession(sid); + } catch { + /* best-effort */ + } + } + await daemon.dispose(); + } + fs.rmSync(ws, { recursive: true, force: true }); + } + }, + THROUGHPUT_WINDOW_S * 2 * 1000 + 120_000, + ); + + it( + 'session churn + leak detection (daemon only)', + async () => { + const ws = makeTempWorkspace('churn'); + let daemon: SpawnedDaemon | undefined; + const anchorIds: string[] = []; + try { + daemon = await spawnDaemon({ + workspaceCwd: ws, + bootTimeoutMs: 35_000, + extraArgs: ['--max-sessions', '0'], + }); + + // 3 anchor sessions keep ACP alive + for (let i = 0; i < 3; i++) { + const s = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + sessionScope: 'thread', + }); + anchorIds.push(s.sessionId); + } + await sleep(500); + + const rssBefore = daemon.daemon.pid + ? measureProcessTreeRss(daemon.daemon.pid) + : null; + + const churnLatencies: number[] = []; + for (let i = 0; i < CHURN_ROUNDS; i++) { + const t0 = performance.now(); + const s = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + sessionScope: 'thread', + }); + await daemon.client.closeSession(s.sessionId); + churnLatencies.push(performance.now() - t0); + } + + await sleep(500); + const rssAfter = daemon.daemon.pid + ? measureProcessTreeRss(daemon.daemon.pid) + : null; + + const rssDrift = + rssBefore && rssAfter + ? Math.round( + (rssAfter.totalRssMB - rssBefore.totalRssMB) * 10, + ) / 10 + : 0; + + snapshot.sessionChurn = { + latency: percentiles(churnLatencies), + rounds: CHURN_ROUNDS, + rssDriftMB: rssDrift, + }; + + expect(Math.abs(rssDrift)).toBeLessThan(100); + } finally { + if (daemon) { + for (const sid of anchorIds) { + try { + await daemon.client.closeSession(sid); + } catch { + /* best-effort */ + } + } + await daemon.dispose(); + } + fs.rmSync(ws, { recursive: true, force: true }); + } + }, + CHURN_ROUNDS * 5_000 + 120_000, + ); + + it('session limit saturation and recovery (daemon only)', async () => { + const MAX = 5; + const ws = makeTempWorkspace('limit'); + let daemon: SpawnedDaemon | undefined; + const sessionIds: string[] = []; + try { + daemon = await spawnDaemon({ + workspaceCwd: ws, + bootTimeoutMs: 35_000, + extraArgs: ['--max-sessions', String(MAX)], + }); + + // Fill to max + for (let i = 0; i < MAX; i++) { + const s = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + sessionScope: 'thread', + }); + sessionIds.push(s.sessionId); + } + + // Attempt to exceed — expect 503 + let limitEnforced = false; + let errorCode = ''; + try { + await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + sessionScope: 'thread', + }); + } catch (err) { + if (err instanceof DaemonHttpError && err.status === 503) { + limitEnforced = true; + const body = err.body as Record | undefined; + errorCode = String(body?.['code'] ?? ''); + } + } + + // Release one slot and recover + await daemon.client.closeSession(sessionIds.shift()!); + let recoverySucceeded = false; + try { + const s = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + sessionScope: 'thread', + }); + sessionIds.push(s.sessionId); + recoverySucceeded = true; + } catch { + recoverySucceeded = false; + } + + snapshot.sessionLimitSaturation = { + limitEnforced, + recoverySucceeded, + errorCode, + }; + + expect(limitEnforced).toBe(true); + expect(errorCode).toBe('session_limit_exceeded'); + expect(recoverySucceeded).toBe(true); + } finally { + if (daemon) { + for (const sid of sessionIds) { + try { + await daemon.client.closeSession(sid); + } catch { + /* best-effort */ + } + } + await daemon.dispose(); + } + fs.rmSync(ws, { recursive: true, force: true }); + } + }, 60_000); + + it('SSE connection flood (daemon only)', async () => { + const N = CONCURRENT_SESSIONS * 2; + const ws = makeTempWorkspace('sse-flood'); + let daemon: SpawnedDaemon | undefined; + const abortControllers: AbortController[] = []; + let sessionId = ''; + try { + daemon = await spawnDaemon({ + workspaceCwd: ws, + bootTimeoutMs: 35_000, + }); + const s = await daemon.client.createOrAttachSession({ + workspaceCwd: ws, + }); + sessionId = s.sessionId; + + // Open N SSE connections concurrently. + // Each waits for replay_complete (proves connection is live). + let connectedCount = 0; + const connectionResults = await Promise.allSettled( + Array.from({ length: N }, async () => { + const ac = new AbortController(); + abortControllers.push(ac); + const timer = setTimeout(() => ac.abort(), 10_000); + try { + for await (const ev of daemon!.client.subscribeEvents( + sessionId, + { signal: ac.signal, lastEventId: 0 }, + )) { + if (ev.type === 'replay_complete') { + connectedCount++; + break; + } + } + } catch (err) { + if ( + err instanceof Error && + (err.name === 'AbortError' || /abort/i.test(err.message)) + ) { + return; + } + throw err; + } finally { + clearTimeout(timer); + } + }), + ); + + // Abort all remaining connections + for (const ac of abortControllers) { + ac.abort(); + } + + // Verify daemon is still healthy + const health = await daemon.client.health(); + const daemonHealthy = health.status === 'ok'; + + const allConnected = + connectionResults.filter((r) => r.status === 'fulfilled').length === + N; + + snapshot.sseConnectionFlood = { + connectionsOpened: N, + allConnected, + daemonHealthyAfter: daemonHealthy, + }; + + expect(daemonHealthy).toBe(true); + expect(connectedCount).toBe(N); + } finally { + for (const ac of abortControllers) { + ac.abort(); + } + if (daemon) { + try { + await daemon.client.closeSession(sessionId); + } catch { + /* best-effort */ + } + await daemon.dispose(); + } + fs.rmSync(ws, { recursive: true, force: true }); + } + }, 30_000); + }); + + // ----------------------------------------------------------------------- + // Output + // ----------------------------------------------------------------------- + afterAll(() => { + if (SKIP) return; + + // Console summary + const fmtP = (p: Percentiles | null | undefined): string => + p && p.count > 0 + ? `p50=${p.p50.toFixed(0)}ms p90=${p.p90.toFixed(0)}ms p99=${p.p99.toFixed(0)}ms (n=${p.count})` + : 'n/a'; + + console.log('\n[benchmark] ---- daemon vs CLI summary ----'); + console.log( + ` CLI cold start: ${fmtP(snapshot.cliColdStart)}${ + snapshot.cliColdStart?.peakRssMB + ? ` RSS=${snapshot.cliColdStart.peakRssMB}MB` + : '' + }`, + ); + const sp = snapshot.cliColdStart?.startupPhases; + if (sp) { + console.log( + ` phases: module=${sp.moduleLoadMs ?? '?'}ms configInit=${sp.configInitMs ?? '?'}ms mcpSettled=${sp.mcpSettledMs ?? '?'}ms`, + ); + } + console.log(` Daemon boot+1st: ${fmtP(snapshot.daemonBootLatency)}`); + console.log( + ` Warm session create: ${fmtP(snapshot.warmSessionCreation)}`, + ); + + if (snapshot.memoryBaseline) { + const mb = snapshot.memoryBaseline; + const d0 = mb.daemon0Sessions; + const d10 = mb.daemon10Sessions; + console.log( + ` Memory (daemon 0s): ${d0 ? `total=${Math.round(d0.totalRssMB * 10) / 10}MB (daemon=${d0.daemonRssMB} acp=${d0.acpChildRssMB} mcp=${d0.mcpChildrenRssMB})` : 'n/a'}`, + ); + console.log( + ` Memory (daemon 10s): ${d10 ? `total=${Math.round(d10.totalRssMB * 10) / 10}MB (+${mb.growthPerSessionMB}MB/session)` : 'n/a'}`, + ); + console.log( + ` Memory (CLI -p init): ${mb.cliVersionPeakRssMB ? `${mb.cliVersionPeakRssMB}MB` : 'n/a'}`, + ); + } + + const spl = snapshot.singlePromptLatency; + if (spl && !spl.skipped) { + console.log(` Prompt CLI: ${fmtP(spl.cli)}`); + console.log(` Prompt daemon: ${fmtP(spl.daemon)}`); + } else { + console.log(' Prompt: skipped (no model key)'); + } + + const cql = snapshot.concurrentQueueingLatency; + if (cql && !cql.skipped) { + console.log( + ` Concurrent (${cql.sessionCount}x): ${cql.promptsPerSec} prompts/sec wall=${cql.wallClockMs.toFixed(0)}ms success=${cql.successCount}/${cql.totalPrompts}`, + ); + } + + // Phase 3 stress tests + const burst = snapshot.burstStress; + if (burst) { + console.log( + ` Burst (${burst.concurrency}x): ${fmtP(burst.latency)} success=${(burst.successRate * 100).toFixed(0)}%`, + ); + } + + const tp = snapshot.throughputStress; + if (tp) { + console.log( + ` Throughput anchored: ${tp.anchored.opsPerSec} ops/sec (${tp.anchored.totalOps} ops in ${tp.windowSeconds}s)`, + ); + console.log( + ` Throughput cold: ${tp.unanchored.opsPerSec} ops/sec (${tp.unanchored.totalOps} ops in ${tp.windowSeconds}s, incl. ACP respawn)`, + ); + } + + const churn = snapshot.sessionChurn; + if (churn) { + console.log( + ` Session churn: ${fmtP(churn.latency)} RSS drift=${churn.rssDriftMB}MB (${churn.rounds} rounds)`, + ); + } + + const lim = snapshot.sessionLimitSaturation; + if (lim) { + console.log( + ` Limit saturation: enforced=${lim.limitEnforced} recovery=${lim.recoverySucceeded} code=${lim.errorCode}`, + ); + } + + const sse = snapshot.sseConnectionFlood; + if (sse) { + console.log( + ` SSE flood: ${sse.connectionsOpened} connections allConnected=${sse.allConnected} healthy=${sse.daemonHealthyAfter}`, + ); + } + + const rp = snapshot.resourceProfile; + if (rp) { + const fmtRes = (label: string, m: ProcessResourceMetrics | null) => { + if (!m) return; + const parts = [ + m.userTimeMs !== null ? `user=${m.userTimeMs}ms` : null, + m.sysTimeMs !== null ? `sys=${m.sysTimeMs}ms` : null, + m.voluntaryCtxSwitches !== null + ? `vol_ctx=${m.voluntaryCtxSwitches}` + : null, + m.involuntaryCtxSwitches !== null + ? `invol_ctx=${m.involuntaryCtxSwitches}` + : null, + m.pageFaults !== null ? `faults=${m.pageFaults}` : null, + m.instructionsRetired !== null + ? `instr=${(m.instructionsRetired / 1e6).toFixed(1)}M` + : null, + ].filter(Boolean); + console.log(` ${label} ${parts.join(' ')}`); + }; + fmtRes('Resources CLI: ', rp.cli); + fmtRes('Resources daemon: ', rp.daemon); + } + + console.log('[benchmark] ---- end summary ----\n'); + + writeSnapshotArtifacts( + OUTPUT_DIR, + 'daemon-vs-cli-benchmark', + snapshot, + renderMarkdown(snapshot), + 'benchmark', + ); + }); + }, +); + +// --------------------------------------------------------------------------- +// Markdown renderer +// --------------------------------------------------------------------------- + +function renderMarkdown(s: BenchmarkSnapshot): string { + const fmtP = formatPercentiles; + + const fmtTree = (r: ProcessTreeRss | null): string => + r + ? `total=${Math.round(r.totalRssMB * 10) / 10}MB (daemon=${r.daemonRssMB} acp=${r.acpChildRssMB} mcp=${r.mcpChildrenRssMB})` + : 'n/a'; + + const lines = [ + `# qwen daemon vs CLI — performance benchmark`, + ``, + `Captured: ${s.capturedAt}`, + `Git: ${s.gitCommit ?? 'unknown'}`, + `Platform: ${s.platform.os}/${s.platform.arch} node=${s.platform.nodeVersion}`, + `Iterations: ${s.config.iterations} Concurrent: ${s.config.concurrentSessions} Heavy: ${s.config.heavy}`, + ``, + `> **Note:** ${s.notes.join(' ')}`, + ``, + `## Phase 1: No-Model Metrics`, + ``, + `### CLI Cold Start (full init)`, + s.cliColdStart + ? (() => { + const lines2 = [ + `- Latency: ${fmtP(s.cliColdStart)}`, + `- Peak RSS: ${s.cliColdStart.peakRssMB ?? 'n/a'} MB`, + ]; + const sp2 = s.cliColdStart.startupPhases; + if (sp2) { + lines2.push( + `- Phase breakdown: module_load=${sp2.moduleLoadMs ?? '?'}ms, config_init=${sp2.configInitMs ?? '?'}ms, mcp_settled=${sp2.mcpSettledMs ?? '?'}ms`, + ); + } + lines2.push( + `- *Measures full CLI init: Node startup + ESM + config + MCP discovery + auth (via startup profiler)*`, + ); + return lines2.join('\n'); + })() + : 'not run', + ``, + `### Daemon Boot (incl. first session)`, + s.daemonBootLatency + ? `- Latency: ${fmtP(s.daemonBootLatency)}\n- *Includes HTTP listener + ACP child spawn + first session creation*` + : 'not run', + ``, + `### Warm Session Creation`, + s.warmSessionCreation + ? `- Latency: ${fmtP(s.warmSessionCreation)}\n- *ACP child already running; measures session creation overhead only*` + : 'not run', + ``, + `### Memory Baseline (process tree RSS)`, + ]; + + if (s.memoryBaseline) { + const mb = s.memoryBaseline; + lines.push( + `- CLI peak RSS (full init): ${mb.cliVersionPeakRssMB ?? 'n/a'} MB`, + `- Daemon at 1 session: ${fmtTree(mb.daemon0Sessions)}`, + `- Daemon at 5 sessions: ${fmtTree(mb.daemon5Sessions)}`, + `- Daemon at 10 sessions: ${fmtTree(mb.daemon10Sessions)}`, + `- Growth per session: ${mb.growthPerSessionMB ?? 'n/a'} MB`, + ); + } else { + lines.push('not run'); + } + + lines.push(``, `## Phase 2: Model-Dependent Metrics`, ``); + + const spl = s.singlePromptLatency; + lines.push(`### Single Prompt Latency`); + if (spl) { + if (spl.skipped) { + lines.push(`skipped (${spl.skipReason})`); + } else { + lines.push( + `| Metric | CLI | Daemon |`, + `|--------|-----|--------|`, + `| p50 | ${spl.cli?.p50.toFixed(0) ?? '-'}ms | ${spl.daemon?.p50.toFixed(0) ?? '-'}ms |`, + `| p90 | ${spl.cli?.p90.toFixed(0) ?? '-'}ms | ${spl.daemon?.p90.toFixed(0) ?? '-'}ms |`, + `| p99 | ${spl.cli?.p99.toFixed(0) ?? '-'}ms | ${spl.daemon?.p99.toFixed(0) ?? '-'}ms |`, + `| mean | ${spl.cli?.mean.toFixed(0) ?? '-'}ms | ${spl.daemon?.mean.toFixed(0) ?? '-'}ms |`, + ``, + `*CLI = end-to-end (spawn+init+model+exit). Daemon = HTTP round-trip+model. Difference ≈ CLI startup amortization.*`, + ); + } + } else { + lines.push('not run'); + } + + lines.push(``); + const cql = s.concurrentQueueingLatency; + lines.push(`### Concurrent Queuing Latency (daemon)`); + if (cql) { + if (cql.skipped) { + lines.push(`skipped (${cql.skipReason})`); + } else { + lines.push( + `- Sessions: ${cql.sessionCount}`, + `- Wall clock: ${cql.wallClockMs.toFixed(0)}ms`, + `- Throughput: ${cql.promptsPerSec} prompts/sec`, + `- Success: ${cql.successCount}/${cql.totalPrompts}`, + `- Per-prompt latency: ${fmtP(cql.perPromptLatency)}`, + ``, + `*Stage 1 single-ACP-child mode — prompts queue serially at the ACP level.*`, + ); + } + } else { + lines.push('not run'); + } + + // Phase 3: stress tests + lines.push(``, `## Phase 3: Stress Tests`, ``); + + const burst = s.burstStress; + lines.push(`### Concurrent Burst (daemon)`); + if (burst) { + lines.push( + `- Concurrency: ${burst.concurrency}`, + `- Latency: ${fmtP(burst.latency)}`, + `- Success rate: ${(burst.successRate * 100).toFixed(0)}%`, + `- *Measures ${burst.concurrency} simultaneous session creations on a warm daemon (ACP child already running)*`, + ); + } else { + lines.push('not run'); + } + + lines.push(``); + const tp = s.throughputStress; + lines.push(`### Sustained Throughput (daemon)`); + if (tp) { + lines.push( + `| Mode | ops/sec | total ops | window |`, + `|------|---------|-----------|--------|`, + `| Anchored (ACP stays alive) | ${tp.anchored.opsPerSec} | ${tp.anchored.totalOps} | ${tp.windowSeconds}s |`, + `| Unanchored (ACP respawns each cycle) | ${tp.unanchored.opsPerSec} | ${tp.unanchored.totalOps} | ${tp.windowSeconds}s |`, + ``, + `*Anchored: 3 sessions keep ACP child alive during create+close cycles (steady-state). Unanchored: each close kills ACP child, next create respawns it (cold-cycle cost).*`, + ); + } else { + lines.push('not run'); + } + + lines.push(``); + const churn = s.sessionChurn; + lines.push(`### Session Churn + Leak Detection (daemon)`); + if (churn) { + lines.push( + `- Rounds: ${churn.rounds}`, + `- Latency: ${fmtP(churn.latency)}`, + `- RSS drift: ${churn.rssDriftMB} MB`, + `- *Drift < 100MB is normal V8 fragmentation; > 100MB indicates potential leak*`, + ); + } else { + lines.push('not run'); + } + + lines.push(``); + const lim = s.sessionLimitSaturation; + lines.push(`### Session Limit Saturation (daemon)`); + if (lim) { + lines.push( + `- Limit enforced: ${lim.limitEnforced}`, + `- Error code: ${lim.errorCode}`, + `- Recovery after close: ${lim.recoverySucceeded}`, + ); + } else { + lines.push('not run'); + } + + lines.push(``); + const sse = s.sseConnectionFlood; + lines.push(`### SSE Connection Flood (daemon)`); + if (sse) { + lines.push( + `- Connections opened: ${sse.connectionsOpened}`, + `- All connected: ${sse.allConnected}`, + `- Daemon healthy after: ${sse.daemonHealthyAfter}`, + ); + } else { + lines.push('not run'); + } + + // Resource profile + const rp = s.resourceProfile; + lines.push(``, `## Resource Profile (via /usr/bin/time)`, ``); + if (rp && (rp.cli || rp.daemon)) { + const fmtVal = (v: number | null, unit = '') => + v !== null ? `${v}${unit}` : '-'; + const fmtM = (v: number | null) => + v !== null ? `${(v / 1e6).toFixed(1)}M` : '-'; + + lines.push( + `| Metric | CLI (-p, full init) | Daemon (boot+session+exit) |`, + `|--------|---------------------|---------------------------|`, + `| Peak RSS | ${fmtVal(rp.cli?.peakRssMB ?? null, ' MB')} | ${fmtVal(rp.daemon?.peakRssMB ?? null, ' MB')} |`, + `| User CPU | ${fmtVal(rp.cli?.userTimeMs ?? null, ' ms')} | ${fmtVal(rp.daemon?.userTimeMs ?? null, ' ms')} |`, + `| System CPU | ${fmtVal(rp.cli?.sysTimeMs ?? null, ' ms')} | ${fmtVal(rp.daemon?.sysTimeMs ?? null, ' ms')} |`, + `| Voluntary ctx switches | ${fmtVal(rp.cli?.voluntaryCtxSwitches ?? null)} | ${fmtVal(rp.daemon?.voluntaryCtxSwitches ?? null)} |`, + `| Involuntary ctx switches | ${fmtVal(rp.cli?.involuntaryCtxSwitches ?? null)} | ${fmtVal(rp.daemon?.involuntaryCtxSwitches ?? null)} |`, + `| Page faults (major) | ${fmtVal(rp.cli?.pageFaults ?? null)} | ${fmtVal(rp.daemon?.pageFaults ?? null)} |`, + `| Page reclaims (minor) | ${fmtVal(rp.cli?.pageReclaims ?? null)} | ${fmtVal(rp.daemon?.pageReclaims ?? null)} |`, + `| Instructions retired | ${fmtM(rp.cli?.instructionsRetired ?? null)} | ${fmtM(rp.daemon?.instructionsRetired ?? null)} |`, + `| Cycles elapsed | ${fmtM(rp.cli?.cyclesElapsed ?? null)} | ${fmtM(rp.daemon?.cyclesElapsed ?? null)} |`, + ``, + `*CLI = single -p invocation (full init path). Daemon = full boot → first session → SIGTERM lifecycle.*`, + ); + } else { + lines.push('not run'); + } + + lines.push(``); + return lines.join('\n'); +} diff --git a/integration-tests/cli/qwen-serve-baseline.test.ts b/integration-tests/cli/qwen-serve-baseline.test.ts index 97f5674c15..f30dd40e29 100644 --- a/integration-tests/cli/qwen-serve-baseline.test.ts +++ b/integration-tests/cli/qwen-serve-baseline.test.ts @@ -33,9 +33,7 @@ * as `acp-integration.test.ts` / `cron-tools.test.ts`. */ -import { execFileSync } from 'node:child_process'; import * as fs from 'node:fs'; -import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterAll, describe, expect, it } from 'vitest'; @@ -48,10 +46,19 @@ import { countDescendants, percentiles, writeWorkspaceSettings, + gitHead, + makeTempWorkspace, + sleep, type SpawnedDaemon, type DescendantCount, type Percentiles, } from './_daemon-harness.js'; +import { + resolveOutputDir, + formatPercentiles, + writeSnapshotArtifacts, + collectPlatformInfo, +} from './_daemon-perf-report.js'; // Minimal type-shape for the SSE backpressure unit suite — we only assert // `.type`, so we avoid coupling tests to the full BridgeEvent surface. @@ -107,10 +114,7 @@ const MCP_FIXTURE_PGREP_FILTER = 'idle-mcp/server\\.mjs'; const MCP_DESCENDANT_WAIT_TIMEOUT_MS = 10_000; const MCP_DESCENDANT_POLL_MS = 250; const RSS_DROPPED_SAMPLE_RATIO_MAX = 0.2; -const RUN_TS = new Date().toISOString().replace(/[:.]/g, '').replace(/Z$/, ''); -const OUTPUT_DIR = - process.env['INTEGRATION_TEST_FILE_DIR'] ?? - path.join(process.cwd(), '.integration-tests', `baseline-${RUN_TS}`); +const OUTPUT_DIR = resolveOutputDir('baseline'); // Catastrophic-regression upper bounds. These are intentionally loose — // tightening them is a deliberate one-line PR after a regression is @@ -184,11 +188,7 @@ const snapshot: SnapshotShape = { version: 1, capturedAt: new Date().toISOString(), gitCommit: gitHead(), - platform: { - os: process.platform, - arch: process.arch, - nodeVersion: process.version, - }, + platform: collectPlatformInfo(), notes: [ 'Daemon defaults to sessionScope: "single", so N successive ' + 'createOrAttachSession calls against the same workspace return the ' + @@ -207,27 +207,6 @@ const snapshot: SnapshotShape = { }, }; -function gitHead(): string | null { - try { - return execFileSync('git', ['rev-parse', 'HEAD'], { - encoding: 'utf8', - timeout: 2_000, - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - } catch { - return null; - } -} - -function makeTempWorkspace(label: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), `qwen-baseline-${label}-`)); - return dir; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } @@ -704,25 +683,19 @@ async function measureRssAtSessionCount(sessionCount: number): Promise<{ afterAll(() => { if (SKIP) return; - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - const jsonPath = path.join(OUTPUT_DIR, 'perf-baseline.json'); - fs.writeFileSync(jsonPath, JSON.stringify(snapshot, null, 2)); - fs.writeFileSync( - path.join(OUTPUT_DIR, 'perf-baseline.md'), + writeSnapshotArtifacts( + OUTPUT_DIR, + 'perf-baseline', + snapshot, renderMarkdown(snapshot), + 'baseline', ); - // Echo the path so a reviewer / CI logs surface where the artifact - // landed. - console.log(`[baseline] perf-baseline.json written to ${jsonPath}`); }); }, ); function renderMarkdown(s: SnapshotShape): string { - const fmt = (p: Percentiles | null | undefined): string => - p - ? `p50=${p.p50.toFixed(0)} p90=${p.p90.toFixed(0)} p99=${p.p99.toFixed(0)} mean=${p.mean.toFixed(0)} (n=${p.count})` - : 'n/a'; + const fmt = formatPercentiles; return [ `# qwen serve daemon — perf baseline`, ``, diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 2878b96b19..8bdc031b73 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -214,6 +214,7 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_preflight', 'session_context', 'session_supported_commands', + 'session_tasks', 'session_close', 'session_metadata', 'mcp_guardrails', diff --git a/integration-tests/fixtures/mock-acp-child/agent.mjs b/integration-tests/fixtures/mock-acp-child/agent.mjs new file mode 100644 index 0000000000..53c86a0646 --- /dev/null +++ b/integration-tests/fixtures/mock-acp-child/agent.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Mock ACP agent for daemon connection stress tests. Uses the real +// AgentSideConnection from @agentclientprotocol/sdk so the NDJSON +// handshake, session lifecycle, and error shapes match production. +// +// Controlled via environment variables (spawnChannel's QWEN_CLI_ENTRY +// only accepts a path — cannot attach argv): +// +// MOCK_ACP_MODE echo | reject | crash-on-prompt | hang +// MOCK_ACP_PROMPT_DELAY_MS per-prompt delay (default 100) +// MOCK_ACP_EMIT_CHUNKS text chunks per prompt (default 3) + +import process from 'node:process'; +import { setTimeout } from 'node:timers/promises'; +import { + AgentSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + RequestError, +} from '@agentclientprotocol/sdk'; +import { Writable, Readable } from 'node:stream'; + +// Protect the stdout NDJSON pipe — any console method that writes to +// stdout would corrupt the framing. +/* eslint-disable no-undef */ +console.log = console.error; +console.info = console.error; +console.debug = console.error; +console.dir = console.error; +/* eslint-enable no-undef */ + +const mode = process.env.MOCK_ACP_MODE ?? 'echo'; +const delayMs = parseInt(process.env.MOCK_ACP_PROMPT_DELAY_MS || '100', 10); +const emitChunks = parseInt(process.env.MOCK_ACP_EMIT_CHUNKS || '3', 10); +let sessionCounter = 0; + +new AgentSideConnection( + (connection) => ({ + async initialize() { + return { + protocolVersion: PROTOCOL_VERSION, + agentInfo: { name: 'mock-acp', version: '0.0.1' }, + authMethods: [], + agentCapabilities: {}, + }; + }, + + async authenticate() { + return {}; + }, + + async newSession() { + return { sessionId: `mock-${++sessionCounter}` }; + }, + + async prompt(params) { + const { sessionId } = params; + + for (let i = 0; i < emitChunks; i++) { + await connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `chunk-${i}` }, + }, + }); + } + + if (delayMs > 0) { + await setTimeout(delayMs); + } + + if (mode === 'reject') { + throw new RequestError(-32603, 'injected error'); + } + if (mode === 'crash-on-prompt') { + process.exit(1); + } + if (mode === 'hang') { + return new Promise(() => {}); + } + + return { stopReason: 'end_turn' }; + }, + + async cancel() {}, + }), + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +); + +process.stdin.on('end', () => process.exit(0)); diff --git a/integration-tests/vitest.config.ts b/integration-tests/vitest.config.ts index 52405d7d34..e7b0e0e5ce 100644 --- a/integration-tests/vitest.config.ts +++ b/integration-tests/vitest.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ exclude: [ '**/terminal-bench/*.test.ts', '**/hook-integration/**', + '**/qwen-daemon-loadtest*', '**/node_modules/**', ], retry: 2, diff --git a/integration-tests/vitest.loadtest.config.ts b/integration-tests/vitest.loadtest.config.ts new file mode 100644 index 0000000000..fcd3f89901 --- /dev/null +++ b/integration-tests/vitest.loadtest.config.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { defineConfig } from 'vitest/config'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + testTimeout: 10 * 60 * 1000, + root: __dirname, + globalSetup: './globalSetup.ts', + reporters: ['default'], + include: ['**/qwen-daemon-loadtest.test.ts'], + retry: 0, + fileParallelism: false, + }, + resolve: { + alias: { + '@qwen-code/sdk': resolve( + __dirname, + '../packages/sdk-typescript/dist/index.mjs', + ), + }, + }, +}); diff --git a/package-lock.json b/package-lock.json index 4a9297bc42..30e0041fbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -160,6 +160,28 @@ "node": ">=6.0.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/install-pkg/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@anthropic-ai/sdk": { "version": "0.36.3", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.36.3.tgz", @@ -744,6 +766,12 @@ "node": ">=18" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", @@ -791,6 +819,12 @@ "node": ">=6" } }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, "node_modules/@chromatic-com/storybook": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.0.0.tgz", @@ -812,6 +846,87 @@ "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", + "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz", + "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", @@ -1705,6 +1820,23 @@ "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", "license": "ISC" }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@inquirer/confirm": { "version": "5.1.14", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.14.tgz", @@ -2278,6 +2410,30 @@ "ws": "^8.19.0" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@lydell/node-pty": { "version": "1.2.0-beta.10", "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.2.0-beta.10.tgz", @@ -2371,6 +2527,12 @@ "win32" ] }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@mdx-js/react": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", @@ -2389,6 +2551,15 @@ "react": ">=16" } }, + "node_modules/@mermaid-js/parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", + "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.1" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -3250,6 +3421,10 @@ "resolved": "packages/sdk-typescript", "link": true }, + "node_modules/@qwen-code/web-shell": { + "resolved": "packages/web-shell", + "link": true + }, "node_modules/@qwen-code/web-templates": { "resolved": "packages/web-templates", "link": true @@ -3821,6 +3996,75 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@shikijs/core": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.29.2.tgz", + "integrity": "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==", + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "1.29.2", + "@shikijs/engine-oniguruma": "1.29.2", + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.29.2.tgz", + "integrity": "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1", + "oniguruma-to-es": "^2.2.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.29.2.tgz", + "integrity": "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1" + } + }, + "node_modules/@shikijs/langs": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-1.29.2.tgz", + "integrity": "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2" + } + }, + "node_modules/@shikijs/themes": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-1.29.2.tgz", + "integrity": "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2" + } + }, + "node_modules/@shikijs/types": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.29.2.tgz", + "integrity": "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.34.37", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", @@ -4104,6 +4348,33 @@ "node": ">=6" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@teddyzhu/clipboard": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard/-/clipboard-0.0.5.tgz", @@ -4549,6 +4820,268 @@ "@types/node": "*" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4584,9 +5117,17 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/express": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", @@ -4619,6 +5160,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/gradient-string": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@types/gradient-string/-/gradient-string-1.1.6.tgz", @@ -4664,6 +5211,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -4689,6 +5242,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", @@ -4741,6 +5303,12 @@ "@types/node": "*" } }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.1.tgz", @@ -4784,13 +5352,6 @@ "kleur": "^3.0.3" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/proper-lockfile": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", @@ -4819,7 +5380,6 @@ "version": "19.2.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4967,6 +5527,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5303,9 +5870,18 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, "license": "ISC" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -6754,6 +7330,16 @@ } } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -7170,6 +7756,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -7215,6 +7811,46 @@ "node": ">=8" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chardet": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", @@ -7576,6 +8212,21 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -7613,6 +8264,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/command-exists": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", @@ -7851,6 +8512,15 @@ "node": ">= 0.10" } }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -7920,6 +8590,12 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -8021,9 +8697,516 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -8101,6 +9284,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -8132,6 +9321,19 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -8260,6 +9462,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -8449,6 +9660,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -8584,6 +9804,12 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "license": "MIT" + }, "node_modules/empathic": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", @@ -9489,6 +10715,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -10730,6 +11966,12 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -10829,6 +12071,197 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -10912,6 +12345,26 @@ "node": ">=14" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -11105,6 +12558,16 @@ "node": ">=8" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -11524,6 +12987,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -11539,6 +13008,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -11557,6 +13035,30 @@ "node": ">= 0.10" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -11711,6 +13213,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -11793,6 +13305,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-in-ci": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", @@ -11926,6 +13448,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -12518,6 +14052,31 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keytar": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", @@ -12541,6 +14100,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -12584,6 +14148,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -12851,6 +14421,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -13006,6 +14582,16 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -13116,6 +14702,16 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", @@ -13137,6 +14733,307 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -13212,6 +15109,60 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", + "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "es-toolkit": "^1.45.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mermaid/node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -13222,6 +15173,588 @@ "node": ">= 0.6" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -14197,6 +16730,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oniguruma-to-es": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz", + "integrity": "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==", + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^5.1.1", + "regex-recursion": "^5.1.1" + } + }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -14361,6 +16905,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -14374,6 +16924,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", @@ -14427,7 +17002,6 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -14467,7 +17041,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -14514,6 +17087,12 @@ "dev": true, "license": "MIT" }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -14726,7 +17305,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -14741,6 +17319,22 @@ "node": ">=4" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -15071,6 +17665,16 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -15442,6 +18046,33 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-reconciler": { "version": "0.33.0", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", @@ -15720,6 +18351,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regex": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz", + "integrity": "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-5.1.1.tgz", + "integrity": "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==", + "license": "MIT", + "dependencies": { + "regex": "^5.1.1", + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -15768,6 +18424,107 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -15954,6 +18711,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", @@ -15994,6 +18757,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -16062,6 +18837,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -16351,6 +19132,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shiki": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.29.2.tgz", + "integrity": "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "1.29.2", + "@shikijs/engine-javascript": "1.29.2", + "@shikijs/engine-oniguruma": "1.29.2", + "@shikijs/langs": "1.29.2", + "@shikijs/themes": "1.29.2", + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -16593,6 +19390,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -16932,6 +19739,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -17036,6 +19857,36 @@ "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -17718,6 +20569,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -17803,6 +20664,26 @@ "tree-sitter-wasms": "^0.1.11" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -17820,7 +20701,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.10" @@ -18156,6 +21036,121 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -18445,6 +21440,48 @@ "url": "https://bevry.me/fund" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", @@ -18664,6 +21701,12 @@ "dev": true, "license": "MIT" }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -18677,6 +21720,16 @@ "node": ">=18" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -19311,6 +22364,16 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "packages/acp-bridge": { "name": "@qwen-code/acp-bridge", "version": "0.17.1", @@ -19787,13 +22850,17 @@ }, "packages/sdk-typescript": { "name": "@qwen-code/sdk", - "version": "0.1.7", + "version": "0.1.8", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "zod": "^3.25.0" }, + "bin": { + "qwen-serve-mcp": "dist/daemon-mcp/serve-bridge/bin.js" + }, "devDependencies": { + "@qwen-code/acp-bridge": "file:../acp-bridge", "@types/node": "^22.0.0", "@typescript-eslint/eslint-plugin": "^7.13.0", "@typescript-eslint/parser": "^7.13.0", @@ -22304,6 +25371,545 @@ "dev": true, "license": "MIT" }, + "packages/web-shell": { + "name": "@qwen-code/web-shell", + "version": "0.0.1-preview.0", + "dependencies": { + "@codemirror/autocomplete": "^6.18.0", + "@codemirror/commands": "^6.7.0", + "@codemirror/language": "^6.10.0", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.0", + "@tanstack/react-virtual": "^3.13.26", + "codemirror": "^6.0.0", + "katex": "^0.16.47", + "mermaid": "^11.15.0", + "react-markdown": "^9.0.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "shiki": "^1.0.0" + }, + "devDependencies": { + "@qwen-code/sdk": "file:../sdk-typescript", + "@qwen-code/webui": "file:../webui", + "@types/node": "^22.0.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.2.0", + "react": "^19.2.0", + "react-dom": "^19.0.0", + "typescript": "^5.3.3", + "vite": "^5.0.0", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "@qwen-code/sdk": ">=0.1.8", + "@qwen-code/webui": ">=0.0.1", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "packages/web-shell/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-shell/node_modules/@types/node": { + "version": "22.19.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz", + "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/web-shell/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/web-shell/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "packages/web-templates": { "name": "@qwen-code/web-templates", "version": "0.17.1", @@ -22712,27 +26318,6 @@ "node": ">=12" } }, - "packages/web-templates/node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "packages/web-templates/node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, "packages/web-templates/node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -22837,6 +26422,7 @@ "version": "0.17.1", "license": "MIT", "dependencies": { + "@qwen-code/sdk": "~0.1.8", "markdown-it": "^14.1.0" }, "devDependencies": { @@ -22860,7 +26446,8 @@ "tailwindcss": "^3.4.0", "typescript": "^5.0.0", "vite": "^5.0.0", - "vite-plugin-dts": "^4.5.4" + "vite-plugin-dts": "^4.5.4", + "vitest": "^3.2.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/package.json b/package.json index efdf9b4220..654635298e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "scripts": { "start": "cross-env node scripts/start.js", "dev": "node scripts/dev.js", + "dev:daemon": "node scripts/daemon-dev.js", "debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js", "generate": "node scripts/generate-git-commit-info.js", "generate:settings-schema": "node --import tsx/esm scripts/generate-settings-schema.ts", diff --git a/packages/acp-bridge/README.md b/packages/acp-bridge/README.md index 2baba0078e..3a441d06c4 100644 --- a/packages/acp-bridge/README.md +++ b/packages/acp-bridge/README.md @@ -3,14 +3,15 @@ Shared ACP bridge primitives consumed by `qwen serve`, channels, IDE, TUI, and remote-control adapters. Lives in the monorepo, not published to npm. -This is **PR 22a** of the Mode B daemon roadmap (#4175 Wave 5). The full -extraction is split: +Lift history (#4175 Mode B daemon roadmap): -| Slice | Scope | Status | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | -| **PR 22a** (this) | Skeleton + `EventBus` + `inMemoryChannel` + `AcpChannel` types + `PermissionMediator` type-only stub | this PR | -| **PR 22b** | Lift `BridgeClient` + `createHttpAcpBridge` + `defaultSpawnChannelFactory` from `cli/src/serve/httpAcpBridge.ts` | after PR 17 (#4282) and PR 14b (#4271) merge | -| **PR 24** | Implement the four `PermissionMediator` strategies (`first-responder`, `designated`, `consensus`, `local-only`) + pair-token revocation + audit log | Wave 5 | +| Slice | Scope | Status | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| **PR 22a** (#4295) | Skeleton + `EventBus` + `inMemoryChannel` + `AcpChannel` types + `PermissionMediator` type-only stub | ✅ merged | +| **PR 22b/1** (#4298) | Lift `status` + `workspacePaths` + `bridgeErrors` + `bridgeTypes` | ✅ merged | +| **PR 22b/2** (#4304) | Lift `BridgeOptions` + new `DaemonStatusProvider` injection seam | ✅ merged | +| **F1** (this PR) | Lift `defaultSpawnChannelFactory` + `BridgeClient` + `createHttpAcpBridge` factory closure + new `BridgeFileSystem` injection seam (22b' scope) | ✅ in this PR | +| **F3 PR 24** | Implement the four `PermissionMediator` strategies (`first-responder`, `designated`, `consensus`, `local-only`) + pair-token revocation + audit log | F3 in the feature-cohesive plan | ## What's here today @@ -21,18 +22,19 @@ extraction is split: used for in-process bridge tests and the parked Mode A (`qwen --serve`) path. - `channel` — `AcpChannel` / `AcpChannelExitInfo` / `ChannelFactory` - type contract that `httpAcpBridge.ts` already injects via + type contract that `createHttpAcpBridge` (now in this package) plus + the channels / VSCode IDE companion's own-spawn paths consume via `BridgeOptions.channelFactory`. - `permission` — type-only `PermissionMediator` interface, `PermissionPolicy` literal union (4 strategies), and `PermissionResolution` discriminated union. **No implementation yet** — first-responder voting still lives in - `cli/src/serve/httpAcpBridge.ts BridgeClient.requestPermission`. - PR 24 will move that and add the other three policies behind this - interface. + `BridgeClient.requestPermission` (in `bridgeClient.ts` after F1). + F3 PR 24 will move that and add the other three policies behind + this interface. - `status` (PR 22b/1) — wire-contract status types for `/workspace/{mcp,skills,providers,env,preflight}` and - `/session/:id/{context,supported-commands}` routes, the + `/session/:id/{context,supported-commands,tasks}` routes, the `STATUS_SCHEMA_VERSION` / `SERVE_*_EXT_METHODS` constants, `BridgeTimeoutError` / `MissingCliEntryError` / `BridgeChannelClosedError` typed exceptions, and the @@ -56,28 +58,50 @@ extraction is split: - `bridgeOptions` (PR 22b/2) — `BridgeOptions` interface (factory construction contract: `boundWorkspace`, `channelFactory`, `maxSessions`, `eventRingSize`, `permissionResponseTimeoutMs`, - persistence callbacks, etc.) and the new `DaemonStatusProvider` + persistence callbacks, etc.) plus the `DaemonStatusProvider` injection seam for daemon-host env / preflight cells (production - impl in `cli/src/serve/daemonStatusProvider.ts`). - -## What's not here yet - -- The bridge core itself (`BridgeClient`, `createHttpAcpBridge`, - `defaultSpawnChannelFactory`, all the `BridgeSession*` types). - It stays in `packages/cli/src/serve/httpAcpBridge.ts` until the - in-flight Wave 4 PRs that touch the bridge surface (#4282 PR 17 and - #4271 PR 14b) merge — moving it now would create a 3-way merge - on a 4400-LOC file for no win. -- The per-session FileSystemService injection point (PR 18 #4250 - introduced the boundary; PR 22b will parameterize bridge writes - through it instead of the inline `BridgeClient.writeTextFile`). + impl in `cli/src/serve/daemonStatusProvider.ts`) and the F1 + `BridgeFileSystem` injection seam for the ACP fs proxy. +- `spawnChannel` (F1) — `defaultSpawnChannelFactory` + `killChild` + + `SCRUBBED_CHILD_ENV_KEYS` denylist + `scrubChildEnv` pure env-policy + helper (exported for adapter reuse + unit-test access; isolates the + scrub + override + defense-in-depth ordering invariant the security + argument relies on). Production spawn of the `qwen --acp` child + with stderr prefix-and-forward, kill cascade, and env passthrough. + Channels (`packages/channels/base/AcpBridge.ts`) and the VSCode IDE + companion consume this directly instead of each reimplementing the + child lifecycle. +- `bridgeClient` (F1) — `BridgeClient` class implementing the ACP + `Client` surface: first-responder permission flow, session-update + fan-out into `EventBus`, child-side `extNotification` routing, + early-event buffer + tombstone bookkeeping, inline fs proxy for + `writeTextFile` / `readTextFile`. Exports the supporting + `PendingPermission` / `PermissionResolutionRecord` / + `BridgeClientSessionEntry` types + `MAX_RESOLVED_PERMISSION_RECORDS` + cap that the factory's bookkeeping maps consume. +- `bridge` (F1) — `createHttpAcpBridge` factory closure (~3000 LOC) + - `ChannelInfo` / `SessionEntry` interfaces + factory-only + helpers (`withTimeout`, `canonicalizeExistingAncestor`, + `verifyParentWithinWorkspace`, debug log helpers, + `hasControlCharacter`) + factory constants. Builds the + bookkeeping closures (`resolveEntry`, `registerPending`, etc.) + and wires them into `BridgeClient`. +- `bridgeFileSystem` (F1) — `BridgeFileSystem` interface for the + ACP fs proxy. When wired through `BridgeOptions.fileSystem`, + `BridgeClient.readTextFile` / `BridgeClient.writeTextFile` + delegate to it instead of the inline `fs.realpath` / + `fs.writeFile` / `fs.readFile` proxy. Production `qwen serve` + follow-up wraps PR 18's `WorkspaceFileSystem` here so writes + get TOCTOU + symlink + trust-gate + audit guarantees. ## Imports — root vs subpaths The package exposes both a barrel root (`@qwen-code/acp-bridge`) and per-module subpaths (`/eventBus`, `/inMemoryChannel`, `/channel`, -`/permission`). They re-export the same symbols, so either form -resolves to the same module at runtime. Pick by intent: +`/permission`, `/status`, `/workspacePaths`, `/bridgeErrors`, +`/bridgeTypes`, `/bridgeOptions`, `/spawnChannel`, `/bridgeClient`, +`/bridge`, `/bridgeFileSystem`). They re-export the same symbols, so +either form resolves to the same module at runtime. Pick by intent: - **Root** for application/test code that uses several primitives at once — concise and matches how `serve/` imports landed today. @@ -85,7 +109,7 @@ resolves to the same module at runtime. Pick by intent: `remoteControl`) that only consume one slice — keeps the dependency surface explicit and lets bundlers tree-shake the rest. -Both variants are stable. PR 22b will not change either set. +Both variants are stable across the F1 lift. ## Backward compatibility @@ -95,13 +119,20 @@ re-export wrappers, so every existing relative import inside `serve/` and the one external import in `cli/src/commands/serve.ts` keeps resolving without churn. -`httpAcpBridge.ts` continues to export `AcpChannel` / -`AcpChannelExitInfo` / `ChannelFactory` (now via re-export from this -package) so any external consumer of those types is unaffected. +After F1, `packages/cli/src/serve/httpAcpBridge.ts` shrinks to a +~97-line re-export shim that forwards every previously-exported +symbol (`createHttpAcpBridge`, `defaultSpawnChannelFactory`, +`BridgeClient`, all the typed errors, all the type aliases) from +the lifted subpaths. Every relative `./httpAcpBridge.js` import in +`server.ts` / `runQwenServe.ts` / `workspaceAgents.ts` / +`workspaceMemory.ts` / `index.ts` / the bridge test suite keeps +resolving without any call-site changes. ## See also -- #4175 Wave 5 PR 22 row +- #4175 Mode B daemon roadmap (feature-cohesive F1-F5 plan targeting + `daemon_mode_b_main`) - #3803 `Stage 1.5-prereq AcpChannel lift` (chiga0's original framing) -- `httpAcpBridge.ts:1096-1106` (FIXME pointing at the four - `PermissionMediator` strategies this package now declares) +- F3 PR 24 will replace the inline first-responder logic in + `BridgeClient.requestPermission` with the four `PermissionMediator` + strategies declared in `permission.ts`. diff --git a/packages/acp-bridge/package.json b/packages/acp-bridge/package.json index 76eae473ce..f2612c1081 100644 --- a/packages/acp-bridge/package.json +++ b/packages/acp-bridge/package.json @@ -1,7 +1,7 @@ { "name": "@qwen-code/acp-bridge", "version": "0.17.1", - "description": "Shared ACP bridge primitives (EventBus, AcpChannel, in-memory channel, PermissionMediator interface) used by qwen serve, channels, IDE, TUI, and remote-control adapters.", + "description": "Shared ACP bridge core (createHttpAcpBridge factory, BridgeClient, defaultSpawnChannelFactory, BridgeFileSystem injection seam) + primitives (EventBus, AcpChannel, in-memory channel, PermissionMediator interface) used by qwen serve, channels, IDE, TUI, and remote-control adapters.", "repository": { "type": "git", "url": "git+https://github.com/QwenLM/qwen-code.git", @@ -51,6 +51,34 @@ "types": "./dist/bridgeOptions.d.ts", "import": "./dist/bridgeOptions.js" }, + "./spawnChannel": { + "types": "./dist/spawnChannel.d.ts", + "import": "./dist/spawnChannel.js" + }, + "./bridgeClient": { + "types": "./dist/bridgeClient.d.ts", + "import": "./dist/bridgeClient.js" + }, + "./bridge": { + "types": "./dist/bridge.d.ts", + "import": "./dist/bridge.js" + }, + "./bridgeFileSystem": { + "types": "./dist/bridgeFileSystem.d.ts", + "import": "./dist/bridgeFileSystem.js" + }, + "./mcpTimeouts": { + "types": "./dist/mcpTimeouts.d.ts", + "import": "./dist/mcpTimeouts.js" + }, + "./internal/testUtils": { + "types": "./dist/internal/testUtils.d.ts", + "import": "./dist/internal/testUtils.js" + }, + "./compactionEngine": { + "types": "./dist/compactionEngine.d.ts", + "import": "./dist/compactionEngine.js" + }, "./package.json": "./package.json" }, "scripts": { @@ -62,7 +90,9 @@ "typecheck": "tsc --noEmit" }, "files": [ - "dist" + "dist", + "!dist/internal/testUtils.*", + "!dist/**/*.test.*" ], "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", diff --git a/packages/cli/src/serve/httpAcpBridge.test.ts b/packages/acp-bridge/src/bridge.test.ts similarity index 56% rename from packages/cli/src/serve/httpAcpBridge.test.ts rename to packages/acp-bridge/src/bridge.test.ts index 90c5f81ce9..cfb4eec57b 100644 --- a/packages/cli/src/serve/httpAcpBridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { afterEach, beforeEach, describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import { randomBytes } from 'node:crypto'; import { promises as fsp } from 'node:fs'; import * as os from 'node:os'; @@ -17,340 +17,41 @@ import { } from '@agentclientprotocol/sdk'; import type { Agent, - AuthenticateRequest, - AuthenticateResponse, - CancelNotification, - InitializeRequest, InitializeResponse, - LoadSessionRequest, LoadSessionResponse, - NewSessionRequest, - NewSessionResponse, PromptRequest, PromptResponse, - ResumeSessionRequest, ResumeSessionResponse, - SetSessionConfigOptionRequest, - SetSessionConfigOptionResponse, - SetSessionModeRequest, - SetSessionModeResponse, + RequestPermissionResponse, } from '@agentclientprotocol/sdk'; -import { createDaemonStatusProvider } from './daemonStatusProvider.js'; import { - createHttpAcpBridge, - detachSessionIdFromEntryChannel, - findChannelInfoForEntry, InvalidClientIdError, InvalidPermissionOptionError, InvalidSessionMetadataError, InvalidSessionScopeError, - MAX_WORKSPACE_PATH_LENGTH, + NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE, RestoreInProgressError, SessionNotFoundError, - WorkspaceInitConflictError, WorkspaceMismatchError, - type AcpChannel, - type BridgeOptions, - type ChannelFactory, - type HttpAcpBridge, -} from './httpAcpBridge.js'; +} from './bridgeErrors.js'; +import { MAX_WORKSPACE_PATH_LENGTH } from './workspacePaths.js'; +import { extractErrorMessage, extractErrorCode } from './bridge.js'; +import type { ChannelFactory } from './channel.js'; +import type { BridgeTelemetry } from './bridgeOptions.js'; import { createInMemoryChannel } from './inMemoryChannel.js'; import type { BridgeEvent } from './eventBus.js'; import { ApprovalMode } from '@qwen-code/qwen-code-core'; +import { + FakeAgent, + type ChannelHandle, + makeBridge, + makeChannel, + WS_A, + WS_B, + SESS_A, +} from './internal/testUtils.js'; -// Workspace fixtures must round-trip through `path.resolve` so the -// expected values match what the bridge canonicalizes internally on -// every platform — a literal `/work/a` resolves to `D:\work\a` on -// Windows and the assertion drifts. Same for the FakeAgent's -// `sess:` synthetic id, since the cwd it sees is the post-resolve -// value the bridge passes through `connection.newSession`. -const WS_A = path.resolve(path.sep, 'work', 'a'); -const WS_B = path.resolve(path.sep, 'work', 'b'); -const SESS_A = `sess:${WS_A}`; - -/** - * Convenience wrapper: `createHttpAcpBridge` now requires `boundWorkspace` - * (per #3803 §02 — 1 daemon = 1 workspace). Tests that only ever talk to - * `WS_A` would otherwise repeat `boundWorkspace: WS_A` everywhere; this - * helper defaults it. Tests that need a different bind path (e.g. the - * mismatch test) pass `boundWorkspace` explicitly. - * - * #4175 PR 22b/2: also defaults `statusProvider` to the production daemon - * impl so existing env / preflight tests (which exercise the bridge's - * delegation path) keep seeing populated cells. Tests that want to - * exercise the no-provider idle fallback can override with - * `{ statusProvider: undefined }`. - */ -function makeBridge(opts: Partial = {}): HttpAcpBridge { - return createHttpAcpBridge({ - boundWorkspace: WS_A, - statusProvider: createDaemonStatusProvider(), - ...opts, - }); -} - -interface FakeAgentOpts { - /** What the fake agent returns from `newSession`. */ - sessionIdPrefix?: string; - /** Inject a per-call delay before responding to `initialize`. */ - initializeDelayMs?: number; - /** Force `initialize` to throw. */ - initializeThrows?: Error; - /** - * Custom prompt handler. Default returns `end_turn` synchronously. Useful - * for test cases that want to observe prompt ordering. - */ - promptImpl?: ( - p: PromptRequest, - self: FakeAgent, - ) => Promise | PromptResponse; - /** - * Custom `newSession` handler. Default returns a synthesized id (see - * `newSession` below). Used by tests that need to exercise the - * doSpawn newSession-failure path (e.g. throwing to cover the - * `isDying`-mark-then-kill cleanup). - */ - newSessionImpl?: ( - p: NewSessionRequest, - self: FakeAgent, - ) => Promise | NewSessionResponse; - loadSessionImpl?: ( - p: LoadSessionRequest, - self: FakeAgent, - ) => Promise | LoadSessionResponse; - resumeSessionImpl?: ( - p: ResumeSessionRequest, - self: FakeAgent, - ) => Promise | ResumeSessionResponse; - extMethodImpl?: ( - method: string, - params: Record, - self: FakeAgent, - ) => Promise> | Record; -} - -class FakeAgent implements Agent { - newSessionCalls: NewSessionRequest[] = []; - loadSessionCalls: LoadSessionRequest[] = []; - resumeSessionCalls: ResumeSessionRequest[] = []; - promptCalls: PromptRequest[] = []; - cancelCalls: CancelNotification[] = []; - extMethodCalls: Array<{ method: string; params: Record }> = - []; - constructor(private readonly opts: FakeAgentOpts = {}) {} - - async initialize(_p: InitializeRequest): Promise { - if (this.opts.initializeThrows) throw this.opts.initializeThrows; - if (this.opts.initializeDelayMs) { - await new Promise((r) => setTimeout(r, this.opts.initializeDelayMs)); - } - return { - protocolVersion: PROTOCOL_VERSION, - agentInfo: { name: 'fake-agent', version: '0' }, - authMethods: [], - agentCapabilities: {}, - }; - } - - async newSession(p: NewSessionRequest): Promise { - this.newSessionCalls.push(p); - if (this.opts.newSessionImpl) { - return this.opts.newSessionImpl(p, this); - } - const prefix = this.opts.sessionIdPrefix ?? 'sess'; - // Stage 1.5 multi-session: one FakeAgent can host multiple - // sessions (same as the real ACP agent), so each newSession call - // returns a fresh id. Suffix by call-count so tests that issue - // multiple newSession on the same channel get distinct ids. - const count = this.newSessionCalls.length; - const suffix = count === 1 ? '' : `#${count}`; - return { sessionId: `${prefix}:${p.cwd}${suffix}` }; - } - - async loadSession(p: LoadSessionRequest): Promise { - this.loadSessionCalls.push(p); - if (this.opts.loadSessionImpl) { - return this.opts.loadSessionImpl(p, this); - } - return {}; - } - async unstable_resumeSession( - p: ResumeSessionRequest, - ): Promise { - this.resumeSessionCalls.push(p); - if (this.opts.resumeSessionImpl) { - return this.opts.resumeSessionImpl(p, this); - } - return {}; - } - async authenticate(_p: AuthenticateRequest): Promise { - throw new Error('not implemented in test fake'); - } - async prompt(p: PromptRequest): Promise { - this.promptCalls.push(p); - if (this.opts.promptImpl) { - return this.opts.promptImpl(p, this); - } - return { stopReason: 'end_turn' }; - } - async cancel(p: CancelNotification): Promise { - this.cancelCalls.push(p); - } - async setSessionMode( - _p: SetSessionModeRequest, - ): Promise { - throw new Error('not implemented in test fake'); - } - async setSessionConfigOption( - _p: SetSessionConfigOptionRequest, - ): Promise { - throw new Error('not implemented in test fake'); - } - async extMethod( - method: string, - params: Record, - ): Promise> { - this.extMethodCalls.push({ method, params }); - if (this.opts.extMethodImpl) { - return this.opts.extMethodImpl(method, params, this); - } - return {}; - } -} - -interface ChannelHandle { - channel: AcpChannel; - agent: FakeAgent; - killed: boolean; - /** - * Resolve `channel.exited` without going through `kill()`. Optionally - * supply exit info so the bridge's `session_died` event carries the - * same `exitCode` / `signalCode` it would in a real crash (BX9_P). - */ - crash: (info?: { - exitCode: number | null; - signalCode: NodeJS.Signals | null; - }) => void; -} - -/** - * Create a paired in-memory NDJSON channel: bridge sees `clientChannel`, - * fake agent sees `agentStream`. Each `TransformStream` carries one - * direction. - * - * Not migrated to `createInMemoryChannel()` (used by the other 10 sites - * in this file): `kill()` below needs the underlying `ab` / `ba` - * writables to simulate child-process termination, which the bare - * helper deliberately does not expose. See `inMemoryChannel.ts` JSDoc - * for the rationale. - */ -function makeChannel(opts: FakeAgentOpts = {}): ChannelHandle { - const ab = new TransformStream(); - const ba = new TransformStream(); - const clientStream = ndJsonStream(ab.writable, ba.readable); - const agentStream = ndJsonStream(ba.writable, ab.readable); - let resolveExited: - | ((info?: { - exitCode: number | null; - signalCode: NodeJS.Signals | null; - }) => void) - | undefined; - const exited = new Promise< - { exitCode: number | null; signalCode: NodeJS.Signals | null } | undefined - >((res) => { - resolveExited = res; - }); - const handle: ChannelHandle = { - channel: undefined as unknown as AcpChannel, - agent: new FakeAgent(opts), - killed: false, - /** Test hook: simulate an unexpected child crash. */ - crash: (info?: { - exitCode: number | null; - signalCode: NodeJS.Signals | null; - }) => resolveExited!(info), - }; - // Spin up the fake agent on the agent side. - new AgentSideConnection(() => handle.agent, agentStream); - handle.channel = { - stream: clientStream, - exited, - kill: async () => { - handle.killed = true; - try { - await ab.writable.close(); - } catch { - /* ignore */ - } - try { - await ba.writable.close(); - } catch { - /* ignore */ - } - resolveExited!(); - }, - killSync: () => { - // Test fake: just mark killed; the async streams will close - // naturally on test cleanup. Mirrors the real spawn factory's - // SIGKILL semantics (fire-and-forget). - handle.killed = true; - resolveExited!(); - }, - }; - return handle; -} - -describe('createHttpAcpBridge', () => { - it('selects a session entry channel when the current attach channel has changed', () => { - // Model the channel-overlap window directly: old channel A still has - // an entry, while new channel B is the current attach target. - const channelA = { name: 'A' }; - const channelB = { name: 'B' }; - const infoA = { channel: channelA, label: 'old-dying-channel' }; - const infoB = { channel: channelB, label: 'fresh-attach-channel' }; - - expect( - findChannelInfoForEntry(infoB, [infoA, infoB], { - channel: channelA, - }), - ).toBe(infoA); - expect( - findChannelInfoForEntry(infoB, [infoB], { - channel: channelA, - }), - ).toBeUndefined(); - }); - - it('detaches a session from its entry channel during channel overlap', () => { - // Model the close/kill overlap window directly: old channel A still owns - // the session entry while fresh channel B is the current attach target. - // The detach helper used by closeSession/killSession must decrement A, - // not the current B channel. - const channelA = { name: 'A' }; - const channelB = { name: 'B' }; - const infoA = { - channel: channelA, - sessionIds: new Set(['session-a']), - label: 'old-dying-channel', - }; - const infoB = { - channel: channelB, - sessionIds: new Set(['session-b']), - label: 'fresh-attach-channel', - }; - - const selected = detachSessionIdFromEntryChannel( - infoB, - [infoA, infoB], - { channel: channelA }, - 'session-a', - ); - - expect(selected).toBe(infoA); - expect(infoA.sessionIds.has('session-a')).toBe(false); - expect(Array.from(infoB.sessionIds)).toEqual(['session-b']); - }); - +describe('createAcpSessionBridge', () => { it('accepts a valid BridgeOptions.eventRingSize at construction time', () => { // Smoke: positive finite integers are accepted; the underlying // EventBus ring-size threading is exercised end-to-end in @@ -385,6 +86,71 @@ describe('createHttpAcpBridge', () => { ); }); + it('uses bridge telemetry for channel/session/prompt dispatch and prompt metadata injection', async () => { + const handle = makeChannel(); + const operations: string[] = []; + const spanAttributes = new Map>(); + const telemetry: BridgeTelemetry = { + captureContext: () => ({ captured: true }), + async runWithContext(_captured, fn) { + return await fn(); + }, + async withSpan(operation, attributes, fn) { + operations.push(operation); + spanAttributes.set(operation, attributes); + return await fn(); + }, + event() {}, + injectPromptContext(request) { + const meta = + (request as { _meta?: Record })._meta ?? {}; + return { + ...request, + _meta: { + ...meta, + 'qwen.telemetry.traceparent': 'daemon-traceparent', + }, + }; + }, + }; + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + telemetry, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'hello' }], + _meta: { + keep: 'value', + 'qwen.telemetry.traceparent': 'client-spoof', + }, + } as PromptRequest, + undefined, + { clientId: session.clientId }, + ); + + expect(operations).toEqual( + expect.arrayContaining([ + 'channel.spawn', + 'channel.initialize', + 'session.new', + 'prompt.dispatch', + ]), + ); + expect(handle.agent.promptCalls[0]!._meta).toMatchObject({ + keep: 'value', + 'qwen.telemetry.traceparent': 'daemon-traceparent', + }); + expect(session.clientId).toBeDefined(); + expect(spanAttributes.get('prompt.dispatch')).toMatchObject({ + 'qwen-code.client_id': session.clientId, + }); + }); + it('forwards childEnvOverrides to the channelFactory at spawn time (#4247 R6 line 216)', async () => { // Round 6 (wenshao R5 line 216): pre-fix `runQwenServe` set // `process.env` globally to pass the MCP budget config to the @@ -478,389 +244,6 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); - it('does not spawn a channel for idle workspace status snapshots', async () => { - const handles: ChannelHandle[] = []; - const bridge = makeBridge({ - channelFactory: async () => { - const h = makeChannel(); - handles.push(h); - return h.channel; - }, - }); - - await expect(bridge.getWorkspaceMcpStatus()).resolves.toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: false, - servers: [], - }); - await expect(bridge.getWorkspaceSkillsStatus()).resolves.toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: false, - skills: [], - }); - await expect(bridge.getWorkspaceProvidersStatus()).resolves.toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: false, - providers: [], - }); - expect(handles).toHaveLength(0); - }); - - it('requests workspace status through the existing ACP channel', async () => { - const handles: ChannelHandle[] = []; - const bridge = makeBridge({ - channelFactory: async () => { - const h = makeChannel({ - extMethodImpl: (method) => { - if (method === 'qwen/status/workspace/mcp') { - return { - v: 1, - workspaceCwd: WS_A, - initialized: true, - servers: [], - }; - } - if (method === 'qwen/status/workspace/skills') { - return { - v: 1, - workspaceCwd: WS_A, - initialized: true, - skills: [], - }; - } - return { - v: 1, - workspaceCwd: WS_A, - initialized: true, - providers: [], - }; - }, - }); - handles.push(h); - return h.channel; - }, - }); - - await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - await expect(bridge.getWorkspaceMcpStatus()).resolves.toMatchObject({ - initialized: true, - }); - await expect(bridge.getWorkspaceSkillsStatus()).resolves.toMatchObject({ - initialized: true, - }); - await expect(bridge.getWorkspaceProvidersStatus()).resolves.toMatchObject({ - initialized: true, - }); - - expect(handles).toHaveLength(1); - expect(handles[0]?.agent.extMethodCalls.map((c) => c.method)).toEqual([ - 'qwen/status/workspace/mcp', - 'qwen/status/workspace/skills', - 'qwen/status/workspace/providers', - ]); - expect(handles[0]?.agent.extMethodCalls.map((c) => c.params)).toEqual([ - { cwd: WS_A }, - { cwd: WS_A }, - { cwd: WS_A }, - ]); - - await bridge.shutdown(); - }); - - it('answers /workspace/env from process state without consulting ACP, idle or live', async () => { - const handles: ChannelHandle[] = []; - const bridge = makeBridge({ - channelFactory: async () => { - const h = makeChannel(); - handles.push(h); - return h.channel; - }, - }); - - // Idle path — daemon answers env from `process.*`; no ACP child spawn. - const idle = await bridge.getWorkspaceEnvStatus(); - expect(idle).toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: true, - acpChannelLive: false, - }); - expect(idle.cells.length).toBeGreaterThan(0); - expect(handles).toHaveLength(0); - - // Live path — bridge still answers locally; the ACP child sees no - // ext-method invocation for env. - await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const live = await bridge.getWorkspaceEnvStatus(); - expect(live.acpChannelLive).toBe(true); - expect(handles).toHaveLength(1); - expect( - handles[0]?.agent.extMethodCalls.some((c) => - c.method.includes('/workspace/env'), - ), - ).toBe(false); - - await bridge.shutdown(); - }); - - it('returns idle env envelope when statusProvider is omitted (Mode A fallback)', async () => { - // PR 22b/2 fold-in: covers the no-provider branch in - // `getWorkspaceEnvStatus`. Production `runQwenServe` and - // `createServeApp` both wire `createDaemonStatusProvider()`, but - // direct embeds (Mode A in-process consumers, future) may omit it. - // The bridge must still answer the route — falling back to the - // shared `createIdleEnvStatus` helper rather than throwing. - const bridge = makeBridge({ statusProvider: undefined }); - - const idle = await bridge.getWorkspaceEnvStatus(); - expect(idle).toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: true, - acpChannelLive: false, - cells: [], - }); - - await bridge.shutdown(); - }); - - it('returns empty daemon preflight cells when statusProvider is omitted (Mode A fallback)', async () => { - // PR 22b/2 fold-in: covers the no-provider branch in - // `getWorkspacePreflightStatus`. ACP-side cells still render - // (idle `not_started` placeholders here since no channel is up); - // only the daemon-host half is empty. - const bridge = makeBridge({ statusProvider: undefined }); - - const status = await bridge.getWorkspacePreflightStatus(); - expect(status).toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: true, - acpChannelLive: false, - }); - - // No daemon cells; only ACP-side `not_started` placeholders. - const daemonCells = status.cells.filter((c) => c.locality === 'daemon'); - const acpCells = status.cells.filter((c) => c.locality === 'acp'); - expect(daemonCells).toHaveLength(0); - expect(acpCells.length).toBeGreaterThan(0); - expect(acpCells.every((c) => c.status === 'not_started')).toBe(true); - - await bridge.shutdown(); - }); - - it('falls back to idle env envelope when statusProvider.getEnvStatus throws', async () => { - // PR 22b/2 wenshao [Critical] fold-in: a custom provider that - // throws would otherwise propagate past the bridge into the route - // handler as a 500. The catch-and-log preserves the - // pre-injection invariant that `/workspace/env` always answers, - // even when the daemon-host helper is sick. - const throwingProvider = { - async getEnvStatus(): Promise { - throw new Error('boom — env collector crashed'); - }, - async getDaemonPreflightCells(): Promise { - return []; - }, - }; - const bridge = makeBridge({ statusProvider: throwingProvider }); - - const env = await bridge.getWorkspaceEnvStatus(); - expect(env).toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: true, - acpChannelLive: false, - cells: [], - }); - - await bridge.shutdown(); - }); - - it('falls back to empty daemon cells when statusProvider.getDaemonPreflightCells throws', async () => { - // PR 22b/2 wenshao [Critical] fold-in: parallel to env — a - // throwing preflight provider must NOT take down the route, so - // the ACP-side cells still render even when the daemon-side - // collector is sick. - const throwingProvider = { - async getEnvStatus(): Promise { - throw new Error('unused'); - }, - async getDaemonPreflightCells(): Promise { - throw new Error('boom — preflight collector crashed'); - }, - }; - const bridge = makeBridge({ statusProvider: throwingProvider }); - - const status = await bridge.getWorkspacePreflightStatus(); - expect(status).toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: true, - acpChannelLive: false, - }); - const daemonCells = status.cells.filter((c) => c.locality === 'daemon'); - const acpCells = status.cells.filter((c) => c.locality === 'acp'); - expect(daemonCells).toHaveLength(0); - expect(acpCells.length).toBeGreaterThan(0); - - await bridge.shutdown(); - }); - - it('returns daemon preflight cells with not_started ACP cells when idle', async () => { - const handles: ChannelHandle[] = []; - const bridge = makeBridge({ - channelFactory: async () => { - const h = makeChannel(); - handles.push(h); - return h.channel; - }, - }); - - const status = await bridge.getWorkspacePreflightStatus(); - expect(status).toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: true, - acpChannelLive: false, - }); - - // Daemon-level cells are always populated. - const daemonKinds = status.cells - .filter((c) => c.locality === 'daemon') - .map((c) => c.kind); - expect(daemonKinds).toEqual( - expect.arrayContaining([ - 'node_version', - 'cli_entry', - 'workspace_dir', - 'ripgrep', - 'git', - 'npm', - ]), - ); - - // ACP cells fall back to `not_started` placeholders without spawning. - const acpCells = status.cells.filter((c) => c.locality === 'acp'); - expect(acpCells.map((c) => c.kind)).toEqual([ - 'auth', - 'mcp_discovery', - 'skills', - 'providers', - 'tool_registry', - 'egress', - ]); - for (const cell of acpCells) { - expect(cell.status).toBe('not_started'); - } - - expect(handles).toHaveLength(0); - }); - - it('merges daemon cells with live ACP-side preflight cells when a channel is up', async () => { - const handles: ChannelHandle[] = []; - const acpCells = [ - { kind: 'auth', status: 'ok', locality: 'acp' }, - { kind: 'mcp_discovery', status: 'ok', locality: 'acp' }, - { kind: 'skills', status: 'ok', locality: 'acp' }, - { kind: 'providers', status: 'ok', locality: 'acp' }, - { kind: 'tool_registry', status: 'ok', locality: 'acp' }, - { kind: 'egress', status: 'not_started', locality: 'acp' }, - ]; - const bridge = makeBridge({ - channelFactory: async () => { - const h = makeChannel({ - extMethodImpl: (method) => { - if (method === 'qwen/status/workspace/preflight') { - return { cells: acpCells }; - } - return { cells: [] }; - }, - }); - handles.push(h); - return h.channel; - }, - }); - - await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const status = await bridge.getWorkspacePreflightStatus(); - expect(status.acpChannelLive).toBe(true); - // Daemon cells precede ACP cells in the merged response. - const daemonKinds = status.cells - .filter((c) => c.locality === 'daemon') - .map((c) => c.kind); - expect(daemonKinds).toEqual( - expect.arrayContaining([ - 'node_version', - 'cli_entry', - 'workspace_dir', - 'ripgrep', - 'git', - 'npm', - ]), - ); - const liveAcpCells = status.cells.filter((c) => c.locality === 'acp'); - expect(liveAcpCells.map((c) => [c.kind, c.status])).toEqual([ - ['auth', 'ok'], - ['mcp_discovery', 'ok'], - ['skills', 'ok'], - ['providers', 'ok'], - ['tool_registry', 'ok'], - ['egress', 'not_started'], - ]); - expect(status.errors).toBeUndefined(); - - await bridge.shutdown(); - }); - - it('falls back to idle ACP cells + envelope error when extMethod throws mid-preflight', async () => { - const handles: ChannelHandle[] = []; - const bridge = makeBridge({ - channelFactory: async () => { - const h = makeChannel({ - extMethodImpl: () => { - throw new Error('agent channel closed mid-request'); - }, - }); - handles.push(h); - return h.channel; - }, - }); - - await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const status = await bridge.getWorkspacePreflightStatus(); - // Daemon cells must still render — that's the route's resilience contract. - const daemonKinds = status.cells - .filter((c) => c.locality === 'daemon') - .map((c) => c.kind); - expect(daemonKinds.length).toBeGreaterThan(0); - // ACP cells fall back to `not_started` placeholders since the extMethod - // call rejected. - const acpCells = status.cells.filter((c) => c.locality === 'acp'); - expect(acpCells.length).toBe(6); - for (const cell of acpCells) { - expect(cell.status).toBe('not_started'); - } - // The envelope's `errors` array carries the bridge-side failure - // describing which surface failed without sinking the whole route. - // `errorKind` is best-effort via `mapDomainErrorToErrorKind`; here the - // ACP SDK wraps the inner throw as a generic JSON-RPC "Internal - // error" which doesn't match any of the helper's recognition rules - // (the typed `BridgeChannelClosedError` follow-up will close that - // gap), so we only assert the structural shape, not the tag. - expect(status.errors).toBeDefined(); - expect(status.errors![0]).toMatchObject({ - kind: 'preflight', - status: 'error', - }); - expect(status.errors![0].error).toBeTruthy(); - - await bridge.shutdown(); - }); - it('requests session status through the existing ACP channel', async () => { const handles: ChannelHandle[] = []; const bridge = makeBridge({ @@ -875,6 +258,14 @@ describe('createHttpAcpBridge', () => { state: {}, }; } + if (method === 'qwen/status/session/tasks') { + return { + v: 1, + sessionId: params['sessionId'], + now: 1_700_000_000_000, + tasks: [], + }; + } return { v: 1, sessionId: params['sessionId'], @@ -902,14 +293,75 @@ describe('createHttpAcpBridge', () => { availableCommands: [], availableSkills: [], }); + await expect( + bridge.getSessionTasksStatus(session.sessionId), + ).resolves.toMatchObject({ + sessionId: session.sessionId, + tasks: [], + }); expect(handles[0]?.agent.extMethodCalls.map((c) => c.method)).toEqual([ 'qwen/status/session/context', 'qwen/status/session/supported_commands', + 'qwen/status/session/tasks', ]); await bridge.shutdown(); }); + it('requests session tasks status without waiting for the prompt queue', async () => { + let releasePrompt: (() => void) | undefined; + const handles: ChannelHandle[] = []; + const bridge = makeBridge({ + channelFactory: async () => { + const h = makeChannel({ + promptImpl: async () => { + await new Promise((resolve) => { + releasePrompt = resolve; + }); + return { stopReason: 'end_turn' }; + }, + extMethodImpl: (method, params) => { + if (method === 'qwen/status/session/tasks') { + return { + v: 1, + sessionId: params['sessionId'], + now: 1_700_000_000_000, + tasks: [], + }; + } + throw new Error(`unexpected extMethod ${method}`); + }, + }); + handles.push(h); + return h.channel; + }, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const prompt = bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'never resolves until released' }], + }); + + await vi.waitFor(() => { + expect(handles[0]?.agent.promptCalls).toHaveLength(1); + }); + + await expect( + bridge.getSessionTasksStatus(session.sessionId), + ).resolves.toMatchObject({ + sessionId: session.sessionId, + tasks: [], + }); + expect(handles[0]?.agent.promptCalls).toHaveLength(1); + expect(handles[0]?.agent.extMethodCalls.map((c) => c.method)).toEqual([ + 'qwen/status/session/tasks', + ]); + + releasePrompt?.(); + await prompt; + await bridge.shutdown(); + }); + it('rejects session status requests for unknown sessions', async () => { const bridge = makeBridge({ channelFactory: async () => makeChannel().channel, @@ -921,6 +373,9 @@ describe('createHttpAcpBridge', () => { await expect( bridge.getSessionSupportedCommandsStatus('missing'), ).rejects.toBeInstanceOf(SessionNotFoundError); + await expect( + bridge.getSessionTasksStatus('missing'), + ).rejects.toBeInstanceOf(SessionNotFoundError); }); it('reuses an echoed daemon-issued client id on attach', async () => { @@ -1087,20 +542,23 @@ describe('createHttpAcpBridge', () => { const bridge = makeBridge({ channelFactory: async () => makeChannel().channel, }); - const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - bridge.recordHeartbeat(session.sessionId, { clientId: session.clientId }); + // Attach two clients so detaching one doesn't trigger + // close-on-last-detach (which would remove the session entirely). + const s1 = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + bridge.recordHeartbeat(s1.sessionId, { clientId: s1.clientId }); - const before = bridge.getHeartbeatState(session.sessionId); - expect(before?.clientLastSeenAt.get(session.clientId!)).toBeDefined(); + const before = bridge.getHeartbeatState(s1.sessionId); + expect(before?.clientLastSeenAt.get(s1.clientId!)).toBeDefined(); - await bridge.detachClient(session.sessionId, session.clientId); + await bridge.detachClient(s1.sessionId, s1.clientId); - const after = bridge.getHeartbeatState(session.sessionId); + const after = bridge.getHeartbeatState(s1.sessionId); // session watermark stays — diagnostics still see "this session - // was alive at T"; per-client entry is gone since the client - // ref-count hit zero. + // was alive at T"; per-client entry for s1 is gone since its + // ref-count hit zero; s2's clientId is still present. expect(after?.sessionLastSeenAt).toBe(before?.sessionLastSeenAt); - expect(after?.clientLastSeenAt.size).toBe(0); + expect(after?.clientLastSeenAt.has(s1.clientId!)).toBe(false); await bridge.shutdown(); }); @@ -1148,6 +606,9 @@ describe('createHttpAcpBridge', () => { clientId: expect.stringMatching(/^client_/), createdAt: expect.any(String), state: { configOptions: [] }, + compactedReplay: [], + liveJournal: [], + lastEventId: 0, }); expect(handles[0]?.agent.loadSessionCalls).toEqual([ { sessionId: 'persisted-1', cwd: WS_A, mcpServers: [] }, @@ -1249,6 +710,7 @@ describe('createHttpAcpBridge', () => { clientId: expect.stringMatching(/^client_/), createdAt: expect.any(String), state: { modes: null }, + lastEventId: 0, }); expect(handles[0]?.agent.loadSessionCalls).toHaveLength(0); expect(handles[0]?.agent.resumeSessionCalls).toEqual([ @@ -1292,6 +754,7 @@ describe('createHttpAcpBridge', () => { clientId: expect.stringMatching(/^client_/), createdAt: expect.any(String), state: { _meta: { tag: 'restored-foo' } }, + lastEventId: expect.any(Number), }); expect(attached.clientId).not.toBe(loaded.clientId); expect(handles[0]?.agent.loadSessionCalls).toHaveLength(1); @@ -2500,6 +1963,480 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); + it('echoes user_message_chunk to ALL session subscribers (cross-client sync)', async () => { + // Cross-client sync fix: a prompt sent by client A must be visible + // to every SSE subscriber of the same session — not just the + // originator. Before the fix, the interactive prompt path forwarded + // straight to the agent without publishing `user_message_chunk` to + // the bus, so peer clients (B, C, ...) never saw A's input. + const factory: ChannelFactory = async () => + makeChannel({ promptImpl: () => ({ stopReason: 'end_turn' }) }).channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const abortA = new AbortController(); + const abortB = new AbortController(); + const iterA = bridge.subscribeEvents(session.sessionId, { + signal: abortA.signal, + }); + const iterB = bridge.subscribeEvents(session.sessionId, { + signal: abortB.signal, + }); + + // Collect the first user_message_chunk each subscriber sees. + const firstUserChunk = async ( + iter: AsyncIterable<{ + type: string; + data: unknown; + originatorClientId?: string; + }>, + ): Promise<{ originatorClientId?: string; data: unknown }> => { + for await (const e of iter) { + if (e.type !== 'session_update') continue; + const update = (e.data as { update?: { sessionUpdate?: string } }) + ?.update; + if (update?.sessionUpdate === 'user_message_chunk') { + return { originatorClientId: e.originatorClientId, data: e.data }; + } + } + throw new Error('no user_message_chunk observed'); + }; + + const aPromise = firstUserChunk(iterA); + const bPromise = firstUserChunk(iterB); + + // Client A sends the prompt with its trusted clientId. + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'hello from A' }], + }, + undefined, + { clientId: session.clientId }, + ); + + const [aChunk, bChunk] = await Promise.all([aPromise, bPromise]); + + // Both subscribers saw the user input echoed to the bus. + for (const chunk of [aChunk, bChunk]) { + const update = ( + chunk.data as { + update: { + sessionUpdate: string; + content: unknown; + _meta?: unknown; + }; + } + ).update; + expect(update.sessionUpdate).toBe('user_message_chunk'); + expect(update.content).toEqual({ type: 'text', text: 'hello from A' }); + // Originator stamp present so SDK `suppressOwnUserEcho` can dedup + // on the originator's own UI. + expect(chunk.originatorClientId).toBe(session.clientId); + // Source marker distinguishes the bridge echo from agent content. + expect((update._meta as { source?: string })?.source).toBe( + 'bridge-echo', + ); + } + + abortA.abort(); + abortB.abort(); + await bridge.shutdown(); + }); + + it('echoes one user_message_chunk per content block (multi-modal)', async () => { + const factory: ChannelFactory = async () => + makeChannel({ promptImpl: () => ({ stopReason: 'end_turn' }) }).channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + const collected: Array<{ sessionUpdate: string; content: unknown }> = []; + const drain = (async () => { + for await (const e of iter) { + if (e.type !== 'session_update') continue; + const update = ( + e.data as { update?: { sessionUpdate?: string; content?: unknown } } + )?.update; + if (update?.sessionUpdate === 'user_message_chunk') { + collected.push({ + sessionUpdate: update.sessionUpdate, + content: update.content, + }); + if (collected.length === 2) break; + } + } + })(); + + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [ + { type: 'text', text: 'describe this' }, + { type: 'resource_link', uri: 'file:///x.png', name: 'x.png' }, + ], + }, + undefined, + { clientId: session.clientId }, + ); + + await drain; + // One echo frame per content block, in order. + expect(collected).toHaveLength(2); + expect(collected[0]?.content).toEqual({ + type: 'text', + text: 'describe this', + }); + expect(collected[1]?.content).toMatchObject({ type: 'resource_link' }); + + abort.abort(); + await bridge.shutdown(); + }); + + it('broadcasts prompt_cancelled with originator attribution on cancelSession', async () => { + // Cross-client sync: a cancel must surface as a first-class event + // so peer subscribers don't have to infer it from the absence of + // further agent chunks. + const factory: ChannelFactory = async () => + makeChannel({ promptImpl: () => ({ stopReason: 'end_turn' }) }).channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const firstCancel = (async () => { + for await (const e of iter) { + if (e.type === 'prompt_cancelled') return e; + } + throw new Error('no prompt_cancelled observed'); + })(); + + await bridge.cancelSession(session.sessionId, undefined, { + clientId: session.clientId, + }); + + const evt = await firstCancel; + expect(evt.type).toBe('prompt_cancelled'); + expect((evt.data as { sessionId: string }).sessionId).toBe( + session.sessionId, + ); + expect(evt.originatorClientId).toBe(session.clientId); + + abort.abort(); + await bridge.shutdown(); + }); + + it('broadcasts prompt_cancelled to peers when the originator SSE aborts mid-prompt', async () => { + // Cross-client sync: client disconnect (tab close / network drop / + // laptop sleep) is the most common cancel trigger in production. + // The `sendPrompt` `onAbort` path must publish `prompt_cancelled` + // to peer subscribers — not just the explicit `cancelSession` + // route. A regression here would silently re-open the gap. + let releasePrompt: (() => void) | undefined; + const factory: ChannelFactory = async () => + makeChannel({ + // Hang the prompt so it stays in-flight while we abort. + promptImpl: async () => { + await new Promise((res) => { + releasePrompt = res; + }); + return { stopReason: 'cancelled' }; + }, + }).channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + // Peer subscriber (a DIFFERENT client watching the same session). + const peerAbort = new AbortController(); + const peerIter = bridge.subscribeEvents(session.sessionId, { + signal: peerAbort.signal, + }); + const peerCancel = (async () => { + for await (const e of peerIter) { + if (e.type === 'prompt_cancelled') return e; + } + throw new Error('peer never saw prompt_cancelled'); + })(); + + // Originator sends the (hanging) prompt, then its SSE/HTTP signal + // aborts mid-flight (connection dropped). + const promptAbort = new AbortController(); + const promptPromise = bridge + .sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'long running' }], + }, + promptAbort.signal, + { clientId: session.clientId }, + ) + .catch(() => { + // AbortError is expected — the originator's connection dropped. + }); + + // Give the queue worker a tick to start the prompt, then abort. + await new Promise((r) => setTimeout(r, 10)); + promptAbort.abort(); + + const evt = await peerCancel; + expect(evt.type).toBe('prompt_cancelled'); + expect((evt.data as { sessionId: string }).sessionId).toBe( + session.sessionId, + ); + // Attributed to the prompt's originator (whose connection dropped). + expect(evt.originatorClientId).toBe(session.clientId); + + // Let the hung promptImpl settle so shutdown doesn't wait on it. + releasePrompt?.(); + await promptPromise; + peerAbort.abort(); + await bridge.shutdown(); + }); + + it('emits prompt_cancelled at most once when cancelSession races the SSE abort (D2)', async () => { + // doudouOUC #4484 post-merge review (D2): a client that POSTs + // /cancel and then immediately drops its socket triggers BOTH + // `cancelSession` and the `sendPrompt` abort path for the same turn. + // The `cancelBroadcast` latch must dedup so peers see exactly one + // `prompt_cancelled`. + let releasePrompt: (() => void) | undefined; + const factory: ChannelFactory = async () => + makeChannel({ + promptImpl: async () => { + await new Promise((res) => { + releasePrompt = res; + }); + return { stopReason: 'cancelled' }; + }, + }).channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const peerAbort = new AbortController(); + const peerIter = bridge.subscribeEvents(session.sessionId, { + signal: peerAbort.signal, + }); + const cancelEvents: BridgeEvent[] = []; + const collecting = (async () => { + for await (const e of peerIter) { + if (e.type === 'prompt_cancelled') cancelEvents.push(e); + } + })(); + + const promptAbort = new AbortController(); + const promptPromise = bridge + .sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'long running' }], + }, + promptAbort.signal, + { clientId: session.clientId }, + ) + .catch(() => {}); + + await new Promise((r) => setTimeout(r, 10)); + // Both cancel routes fire for the same active prompt. + await bridge.cancelSession( + session.sessionId, + { sessionId: session.sessionId }, + { clientId: session.clientId }, + ); + promptAbort.abort(); + + releasePrompt?.(); + await promptPromise; + await new Promise((r) => setTimeout(r, 10)); + peerAbort.abort(); + await collecting; + // Exactly one broadcast despite two cancel triggers. + expect(cancelEvents).toHaveLength(1); + await bridge.shutdown(); + }); + + it('resets the cancel-broadcast latch per prompt (a second prompt re-broadcasts)', async () => { + // Guards the `entry.cancelBroadcast = false` reset at prompt start: if it + // were removed, every cancel after the first deduped turn would be + // silently suppressed. Cancel prompt 1 (latch sets), then cancel prompt 2 + // — peers must see a SECOND prompt_cancelled. + const releasers: Array<() => void> = []; + const factory: ChannelFactory = async () => + makeChannel({ + promptImpl: async () => + new Promise<{ stopReason: 'cancelled' }>((res) => { + releasers.push(() => res({ stopReason: 'cancelled' })); + }), + }).channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const peerAbort = new AbortController(); + const peerIter = bridge.subscribeEvents(session.sessionId, { + signal: peerAbort.signal, + }); + const cancelEvents: BridgeEvent[] = []; + const collecting = (async () => { + for await (const e of peerIter) { + if (e.type === 'prompt_cancelled') cancelEvents.push(e); + } + })(); + + const runTurn = async () => { + const p = bridge + .sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'x' }], + }, + undefined, + { clientId: session.clientId }, + ) + .catch(() => {}); + await new Promise((r) => setTimeout(r, 10)); + await bridge.cancelSession( + session.sessionId, + { sessionId: session.sessionId }, + { clientId: session.clientId }, + ); + releasers.shift()?.(); + await p; + await new Promise((r) => setTimeout(r, 5)); + }; + + await runTurn(); // prompt 1: latch sets, 1 broadcast + await runTurn(); // prompt 2: latch was reset at start → re-broadcasts + peerAbort.abort(); + await collecting; + expect(cancelEvents).toHaveLength(2); + await bridge.shutdown(); + }); + + it('emits a compensating prompt_cancelled{forward_failed} when the prompt forward rejects (C3)', async () => { + // doudouOUC #4484 post-merge review (C3): the user echo is published + // before the forward. If the forward itself rejects (transport died / + // ACP error) without a user cancel, peers must still see the turn end + // — otherwise they sit forever on the echoed input with no response. + const h = makeChannel({ + promptImpl: async () => { + throw new Error('forward boom'); + }, + }); + const cancelSpy = vi.spyOn(h.agent, 'cancel'); + const factory: ChannelFactory = async () => h.channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const peerAbort = new AbortController(); + const peerIter = bridge.subscribeEvents(session.sessionId, { + signal: peerAbort.signal, + }); + const peerCancel = (async () => { + for await (const e of peerIter) { + if (e.type === 'prompt_cancelled') return e; + } + throw new Error('peer never saw prompt_cancelled'); + })(); + + await bridge + .sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'will fail to forward' }], + }, + undefined, + { clientId: session.clientId }, + ) + .catch(() => { + // forward rejection surfaces to the caller too. + }); + + const evt = await peerCancel; + expect(evt.type).toBe('prompt_cancelled'); + expect((evt.data as { reason?: string }).reason).toBe('forward_failed'); + await vi.waitFor(() => { + expect(cancelSpy).toHaveBeenCalledWith({ + sessionId: session.sessionId, + }); + }); + peerAbort.abort(); + await bridge.shutdown(); + }); + + it('stamps envelope originatorClientId on session_closed', async () => { + const factory: ChannelFactory = async () => makeChannel().channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const firstClosed = (async () => { + for await (const e of iter) { + if (e.type === 'session_closed') return e; + } + throw new Error('no session_closed observed'); + })(); + + await bridge.closeSession(session.sessionId, { + clientId: session.clientId, + }); + + const evt = await firstClosed; + // Envelope-level stamp (new) — sibling events use this field. + expect(evt.originatorClientId).toBe(session.clientId); + // Back-compat `data.closedBy` retained. + expect((evt.data as { closedBy?: string }).closedBy).toBe( + session.clientId, + ); + + abort.abort(); + await bridge.shutdown(); + }); + + it('stamps envelope originatorClientId on session_metadata_updated', async () => { + const factory: ChannelFactory = async () => makeChannel().channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const firstMeta = (async () => { + for await (const e of iter) { + if (e.type === 'session_metadata_updated') return e; + } + throw new Error('no session_metadata_updated observed'); + })(); + + bridge.updateSessionMetadata( + session.sessionId, + { displayName: 'renamed session' }, + { clientId: session.clientId }, + ); + + const evt = await firstMeta; + expect(evt.originatorClientId).toBe(session.clientId); + expect((evt.data as { displayName?: string }).displayName).toBe( + 'renamed session', + ); + + abort.abort(); + await bridge.shutdown(); + }); + it('overrides a stale sessionId in the body with the routing id', async () => { const handles: ChannelHandle[] = []; const factory: ChannelFactory = async () => { @@ -2659,6 +2596,65 @@ describe('createHttpAcpBridge', () => { SessionNotFoundError, ); }); + + it('treats idle agent cancel as success', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel({ + cancelImpl: () => { + throw { + code: -32603, + message: 'Internal error', + data: { details: 'Not currently generating' }, + }; + }, + }); + handles.push(h); + return h.channel; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await expect( + bridge.cancelSession(session.sessionId), + ).resolves.toBeUndefined(); + expect(handles[0]?.agent.cancelCalls).toHaveLength(1); + + await bridge.shutdown(); + }); + + it('treats idle agent cancel wording variants as success', async () => { + const variants: unknown[] = [ + new Error(`${NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE} (session idle)`), + { + code: -32603, + message: 'Internal error', + data: { details: 'not currently generating' }, + }, + ]; + + for (const err of variants) { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel({ + cancelImpl: () => { + throw err; + }, + }); + handles.push(h); + return h.channel; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await expect( + bridge.cancelSession(session.sessionId), + ).resolves.toBeUndefined(); + expect(handles[0]?.agent.cancelCalls).toHaveLength(1); + + await bridge.shutdown(); + } + }); }); describe('permission flow', () => { @@ -2757,6 +2753,237 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); + it('forwards permission vote metadata back to the agent response', async () => { + const { bridge, session, conn } = await setupForPermission(); + + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + + const respPromise = ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { + toolCallId: 'tc-ask', + title: 'AskUserQuestion: Ask user 1 question', + }, + options: [ + { optionId: 'proceed_once', name: 'Submit', kind: 'allow_once' }, + { optionId: 'cancel', name: 'Cancel', kind: 'reject_once' }, + ], + }); + + const it = iter[Symbol.asyncIterator](); + const next = await it.next(); + expect(next.done).toBe(false); + const payload = next.value!.data as { requestId: string }; + + const responseWithAnswers = { + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + answers: { + name: 'Alice', + grade: 'Primary', + }, + ignored: 'not forwarded', + } satisfies RequestPermissionResponse & { + answers: Record; + ignored: string; + }; + const accepted = bridge.respondToPermission( + payload.requestId, + responseWithAnswers, + ); + expect(accepted).toBe(true); + + const response = await respPromise; + expect(response).toMatchObject({ + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + answers: { + name: 'Alice', + grade: 'Primary', + }, + }); + expect(response).not.toHaveProperty('ignored'); + + subAbort.abort(); + await bridge.shutdown(); + }); + + it('forwards session-scoped permission answers without arbitrary metadata', async () => { + const { bridge, session, conn } = await setupForPermission(); + + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + + const respPromise = ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { + toolCallId: 'tc-ask-scoped', + title: 'AskUserQuestion: Ask user 1 question', + }, + options: [ + { optionId: 'proceed_once', name: 'Submit', kind: 'allow_once' }, + { optionId: 'cancel', name: 'Cancel', kind: 'reject_once' }, + ], + }); + + const it = iter[Symbol.asyncIterator](); + const next = await it.next(); + expect(next.done).toBe(false); + const payload = next.value!.data as { requestId: string }; + + const responseWithAnswers = { + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + answers: { + name: 'Alice', + }, + ignored: 'not forwarded', + } satisfies RequestPermissionResponse & { + answers: Record; + ignored: string; + }; + const accepted = bridge.respondToSessionPermission( + session.sessionId, + payload.requestId, + responseWithAnswers, + { clientId: session.clientId }, + ); + expect(accepted).toBe(true); + + const response = await respPromise; + expect(response).toMatchObject({ + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + answers: { + name: 'Alice', + }, + }); + expect(response).not.toHaveProperty('ignored'); + + subAbort.abort(); + await bridge.shutdown(); + }); + + it('returns false (not InvalidClientIdError) when session exists but requestId is unknown and clientId is unregistered (#4335 / 3271978329 / 3272493792 / 3273077272)', async () => { + // Wenshao review #4335 / 3271978329 (Critical) — error + // precedence regression: the session-scoped vote route must + // return `false` (→ 404) when the requestId isn't known to + // the mediator, BEFORE validating `context.clientId`. + // Without this guard a probe could fabricate a requestId, + // supply an arbitrary `X-Qwen-Client-Id`, and distinguish + // "this clientId is registered to this session" (proceeds + // past resolveTrustedClientId then returns false → 404) from + // "this clientId is not registered" (InvalidClientIdError → + // 400) — a session-membership oracle. + // + // Wenshao review #4335 / 3272493792 — explicit test for the + // fix from Round 7 so a future refactor can't silently + // remove the short-circuit. + // + // Wenshao review #4335 / 3273077272 — also assert the stderr + // breadcrumb that Round 8 promoted from debug-gated to + // unconditional (`writeStderrLine`). Pinning the log call + // means a future refactor that drops or downgrades the line + // is caught even when the return value still happens to be + // false for some other reason. + const stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + try { + const { bridge, session } = await setupForPermission(); + + // Session exists, requestId is unknown, clientId is fake. + // The bridge MUST return false; pre-fix it threw + // InvalidClientIdError (400). + const result = bridge.respondToSessionPermission( + session.sessionId, + 'unknown-req-id', + { outcome: { outcome: 'cancelled' } }, + { clientId: 'fabricated-client-id' }, + ); + expect(result).toBe(false); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('rejected permission vote'), + ); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('unknown-req-id'), + ); + + await bridge.shutdown(); + } finally { + stderrSpy.mockRestore(); + } + }); + + it('rejects cancel sentinel injection via {selected,"__cancelled__"} (#4335 / 3271420267)', async () => { + // wenshao/qwen-latest review #4335 (3271420267) — the most + // security-critical guard in this PR. The mediator recognizes + // CANCEL_VOTE_SENTINEL ('__cancelled__') BEFORE validating the + // option against allowedOptionIds, so a wire client sending + // `{outcome:'selected', optionId:'__cancelled__'}` could + // bypass ALL policy dispatch (designated/consensus/local-only) + // and resolve the request as cancelled. The bridge guards + // against this by throwing InvalidPermissionOptionError + // BEFORE forwarding to mediator.vote — without a test, a + // future refactor could silently remove the check. + const { bridge, session, conn } = await setupForPermission(); + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + const respPromise = ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { toolCallId: 'tc-1', title: 'rm -rf /' }, + options: [ + { optionId: 'allow', name: 'Allow', kind: 'allow_once' }, + { optionId: 'deny', name: 'Deny', kind: 'reject_once' }, + ], + }); + const it = iter[Symbol.asyncIterator](); + const next = await it.next(); + const payload = next.value!.data as { requestId: string }; + + // Wire-injected sentinel via `selected` outcome — must + // throw InvalidPermissionOptionError before reaching the + // mediator. + expect(() => + bridge.respondToSessionPermission( + session.sessionId, + payload.requestId, + { + outcome: { outcome: 'selected', optionId: '__cancelled__' }, + }, + ), + ).toThrow(InvalidPermissionOptionError); + + // Pending was preserved — a legitimate vote still resolves. + expect(bridge.pendingPermissionCount).toBe(1); + bridge.respondToSessionPermission(session.sessionId, payload.requestId, { + outcome: { outcome: 'selected', optionId: 'allow' }, + }); + const response = (await respPromise) as { + outcome: { outcome: string; optionId?: string }; + }; + expect(response.outcome.outcome).toBe('selected'); + expect(response.outcome.optionId).toBe('allow'); + + subAbort.abort(); + await bridge.shutdown(); + }); + it('rejects votes whose optionId was not in the agent-offered set (BkwQI)', async () => { // BkwQI: bridge.respondToPermission validates the voter's // `optionId` against the original `options` the agent sent. @@ -3151,13 +3378,28 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); - it('rejects unknown permission votes with unregistered client ids', async () => { + it('returns false uniformly for unknown permission votes regardless of clientId registration (#4335 / 3272493777)', async () => { + // Wenshao review #4335 / 3272493777 — error precedence: an + // unknown requestId must return `false` (→ 404) regardless of + // whether the supplied `clientId` is registered in any + // session. The previous PR #4231 boundary returned 400 for + // unregistered clientIds and 404 for registered ones, which + // turned out to be a cross-session client-registration + // oracle: a remote prober posting `POST /permission/` + // with various `X-Qwen-Client-Id` headers could distinguish + // "this clientId is registered in some active session" (404) + // from "not registered anywhere" (400). The session-scoped + // route's matching fix landed in Round 7 (#3271978329); this + // pins the symmetric posture for the legacy route and + // explicitly inverts the assertion the pre-Round-7 test used + // to make. const bridge = makeBridge({ channelFactory: async () => makeChannel().channel, }); const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - expect(() => + // Unregistered clientId — must NOT throw; uniform `false`. + expect( bridge.respondToPermission( 'does-not-exist', { @@ -3165,7 +3407,8 @@ describe('createHttpAcpBridge', () => { }, { clientId: 'client-not-issued' }, ), - ).toThrow(InvalidClientIdError); + ).toBe(false); + // Registered clientId — also `false`. expect( bridge.respondToPermission( 'does-not-exist', @@ -3175,6 +3418,12 @@ describe('createHttpAcpBridge', () => { { clientId: session.clientId }, ), ).toBe(false); + // No clientId at all — `false` (unchanged behavior). + expect( + bridge.respondToPermission('does-not-exist', { + outcome: { outcome: 'cancelled' }, + }), + ).toBe(false); await bridge.shutdown(); }); @@ -3610,6 +3859,77 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); + it('does NOT reconcile when applyModelServiceId roundtrip fails on attach', async () => { + // F4oaj: the attach-time model apply (`applyModelServiceId`) gates + // reconcile on the same `succeeded` flag as `setSessionModel`. When the + // agent rejects `unstable_setSessionModel`, `publishModelSwitched` never + // runs and the cache is unchanged, so reconciliation must be skipped (no + // status read) — otherwise a corrective `model_switched` would be paired + // with the `model_switch_failed`. The agent's status deliberately drifts + // so any (incorrect) reconcile would produce an observable corrective. + let statusReads = 0; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent({ + extMethodImpl: (method) => { + if (method === 'qwen/status/session/context') { + statusReads += 1; + return Promise.resolve({ + state: { models: { currentModelId: 'qwen-turbo' } }, + }); + } + return Promise.resolve({}); + }, + }); + const augmented = new Proxy(fakeAgent, { + get(target, prop) { + if (prop === 'unstable_setSessionModel') { + return async () => { + throw new Error('agent denied'); + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target as any)[prop]; + }, + }); + new AgentSideConnection(() => augmented as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + // Spawn WITHOUT a model so the only model apply is the failing one on the + // second attach (a spawn-time apply would succeed and legitimately read + // status, muddying the assertion). + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + // Attach with a model — the agent rejects it. The attach swallows the + // failure (shared session stays alive) and surfaces it as a bus event. + await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + modelServiceId: 'rejected', + }); + + const it = iter[Symbol.asyncIterator](); + const failed = await it.next(); + expect(failed.value?.type).toBe('model_switch_failed'); + // Give any (incorrectly) scheduled reconcile a tick to fire. + await new Promise((r) => setTimeout(r, 10)); + expect(statusReads).toBe(0); + abort.abort(); + await bridge.shutdown(); + }); + it('serializes concurrent model-change calls (FIFO)', async () => { const callOrder: string[] = []; const factory: ChannelFactory = async () => { @@ -4533,6 +4853,224 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); + it('serializes concurrent approval-mode changes through the per-session queue (A3)', async () => { + // doudouOUC #4484 post-merge review (A3): two concurrent + // `setSessionApprovalMode` calls must not interleave their ACP + // roundtrips, otherwise the last `approval_mode_changed` published + // can disagree with the mode the child actually settled on. The + // `approvalModeQueue` enforces FIFO. Detect by tracking concurrent + // in-flight ext calls (must never exceed 1) and the start/end order. + let inFlight = 0; + let maxInFlight = 0; + const order: string[] = []; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const agent = new FakeAgent({ + extMethodImpl: async (method, params) => { + if (method === 'qwen/control/session/approval_mode') { + const mode = (params as { mode: string }).mode; + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + order.push(`start:${mode}`); + await new Promise((r) => setTimeout(r, 10)); + order.push(`end:${mode}`); + inFlight -= 1; + return { previous: 'default', current: mode }; + } + return {}; + }, + }); + new AgentSideConnection(() => agent as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await Promise.all([ + bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.YOLO, + { persist: false }, + undefined, + ), + bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.DEFAULT, + { persist: false }, + undefined, + ), + ]); + // Never overlapped, and the second roundtrip began only after the + // first fully completed. + expect(maxInFlight).toBe(1); + expect(order).toEqual([ + 'start:yolo', + 'end:yolo', + 'start:default', + 'end:default', + ]); + await bridge.shutdown(); + }); + + it('serializes persist + publish too, not just the extMethod (A3, persist:true)', async () => { + // Regression for the wenshao Critical: covering only the extMethod left + // persist+publish outside the queue, so two concurrent persist:true + // changes could interleave their persist phases and publish out of + // order. Make persist slow + inversely ordered to the calls; assert the + // published approval_mode_changed events still come out in call order + // (A then B), proving persist+publish run inside the serialized work. + const { factory } = approvalModeFactoryWithCallTracker(); + // persist for 'yolo' is SLOWER than for 'default' — if persist ran + // outside the queue, 'default' would publish before 'yolo'. + const persistDelay: Record = { yolo: 30, default: 1 }; + const bridge = makeBridge({ + channelFactory: factory, + persistApprovalMode: async (_ws: string, mode: string) => { + await new Promise((r) => setTimeout(r, persistDelay[mode] ?? 1)); + }, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const published: string[] = []; + const collecting = (async () => { + for await (const e of iter) { + if (e.type === 'approval_mode_changed') { + published.push((e.data as { next: string }).next); + } + } + })(); + + await Promise.all([ + bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.YOLO, + { persist: true }, + undefined, + ), + bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.DEFAULT, + { persist: true }, + undefined, + ), + ]); + await new Promise((r) => setTimeout(r, 20)); + abort.abort(); + await collecting; + // In call order despite yolo's slower persist — persist+publish are + // serialized inside the queue, so default can't overtake yolo. + expect(published).toEqual(['yolo', 'default']); + await bridge.shutdown(); + }); + + it('a failed approval-mode change does not poison the queue (A3 tail-swallow)', async () => { + // The approvalModeQueue tail-swallows failures so a rejected change + // can't wedge every subsequent one. First call rejects; the second + // must still run and succeed. + let call = 0; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const agent = new FakeAgent({ + extMethodImpl: async (method, params) => { + if (method === 'qwen/control/session/approval_mode') { + call += 1; + if (call === 1) throw new Error('approval boom'); + return { + previous: 'default', + current: (params as { mode: string }).mode, + }; + } + return {}; + }, + }); + new AgentSideConnection(() => agent as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + // The ACP layer wraps the agent-side throw as a generic JSON-RPC + // error; we only care that the first change rejects. + await expect( + bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.YOLO, + { persist: false }, + undefined, + ), + ).rejects.toThrow(); + + // Queue not poisoned — the next change still resolves. + const res = await bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.DEFAULT, + { persist: false }, + undefined, + ); + expect(res.mode).toBe('default'); + await bridge.shutdown(); + }); + + it('echoPromptToSessionBus tolerates a non-array prompt (D6 guard)', async () => { + // The Array.isArray guard means a malformed body that slips past the + // type contract degrades to "no echo" rather than throwing mid-send. + const { factory } = approvalModeFactoryWithCallTracker(); + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const userChunks: BridgeEvent[] = []; + const collecting = (async () => { + for await (const e of iter) { + const u = (e.data as { update?: { sessionUpdate?: string } })?.update; + if (u?.sessionUpdate === 'user_message_chunk') userChunks.push(e); + } + })(); + + // prompt is not an array → the Array.isArray guard returns early. + // Capture the outcome rather than swallowing it: if the guard were + // removed, echoPromptToSessionBus would throw a TypeError on + // `undefined.length` and sendPrompt would reject WITH that TypeError — + // so asserting the error (if any) is NOT a TypeError makes the test + // fail when the guard is gone (the previous `.catch(() => {})` passed + // regardless — dead-code-safe, wenshao). + const caught: unknown = await bridge + .sendPrompt( + session.sessionId, + { sessionId: session.sessionId, prompt: undefined as never }, + undefined, + { clientId: session.clientId }, + ) + .catch((e) => e); + + await new Promise((r) => setTimeout(r, 10)); + abort.abort(); + await collecting; + expect(caught).not.toBeInstanceOf(TypeError); + expect(userChunks).toHaveLength(0); + await bridge.shutdown(); + }); + it('broadcasts approval_mode_changed to peer sessions when persisted (#4282 fold-in 4 S2)', async () => { // When `persist:true` succeeds the change becomes the workspace // default, so a peer session needs to know its next ACP child @@ -4565,15 +5103,29 @@ describe('createHttpAcpBridge', () => { { persist: true }, undefined, ); - // Session A receives both the session-scoped event and the - // workspace-scoped mirror; collect two events. + // #4297 fold-in 1: requester gets the event exactly once (via + // its own session-scoped publish); the broadcast skips the + // requester so the SDK reducer's `approvalModeChangedCount` + // increments by 1, not 2, on the requesting client. const aFirst = await itA.next(); - const aSecond = await itA.next(); - const aTypes = [aFirst.value?.type, aSecond.value?.type]; - expect(aTypes.filter((t) => t === 'approval_mode_changed').length).toBe( - 2, - ); - // Session B receives only the workspace-scoped mirror. + expect(aFirst.value?.type).toBe('approval_mode_changed'); + expect(aFirst.value?.data).toMatchObject({ + sessionId: a.sessionId, + previous: 'default', + next: 'yolo', + persisted: true, + }); + // Race A's next event against a 50ms timer to confirm no second + // delivery (which would be the duplicate the broadcast used to + // produce). + const aTimedSecond = await Promise.race([ + itA.next().then((v) => ({ kind: 'event' as const, v })), + new Promise((r) => setTimeout(r, 50)).then(() => ({ + kind: 'timeout' as const, + })), + ]); + expect(aTimedSecond.kind).toBe('timeout'); + // Peer session B still receives the workspace-scoped mirror. const bFirst = await itB.next(); expect(bFirst.value?.type).toBe('approval_mode_changed'); expect(bFirst.value?.data).toMatchObject({ @@ -4630,54 +5182,23 @@ describe('createHttpAcpBridge', () => { }); }); - describe('setWorkspaceToolEnabled (#4175 Wave 4 PR 17)', () => { - it('throws when no persistDisabledTools callback is wired', async () => { - const bridge = makeBridge(); - await expect( - bridge.setWorkspaceToolEnabled('Bash', false, undefined), - ).rejects.toThrow(/persistDisabledTools/); - }); - - it('invokes the persist callback with the workspace + name + enabled flag', async () => { - const calls: Array<{ - workspace: string; - toolName: string; - enabled: boolean; - }> = []; - const bridge = makeBridge({ - persistDisabledTools: async (workspace, toolName, enabled) => { - calls.push({ workspace, toolName, enabled }); - }, - }); - const result = await bridge.setWorkspaceToolEnabled( - 'Bash', - false, - undefined, - ); - expect(result).toEqual({ toolName: 'Bash', enabled: false }); - expect(calls).toEqual([ - { workspace: WS_A, toolName: 'Bash', enabled: false }, - ]); - }); - - it('does NOT spawn an ACP child even when called repeatedly', async () => { - let factoryCalls = 0; - const bridge = makeBridge({ - channelFactory: async () => { - factoryCalls += 1; - throw new Error('channel factory should not be invoked'); - }, - persistDisabledTools: async () => {}, - }); - await bridge.setWorkspaceToolEnabled('Bash', false, undefined); - await bridge.setWorkspaceToolEnabled('Read', true, undefined); - expect(factoryCalls).toBe(0); - }); - - it('fan-outs tool_toggled events to every live session bus', async () => { - const factory: ChannelFactory = async () => { + describe('generateSessionRecap (#4175 follow-up)', () => { + function recapFactory( + respond: ( + params: Record, + ) => Record | Promise>, + ): ChannelFactory { + return async () => { const { clientStream, agentStream } = createInMemoryChannel(); - new AgentSideConnection(() => new FakeAgent() as Agent, agentStream); + const agent = new FakeAgent({ + extMethodImpl: (method, params) => { + if (method === 'qwen/control/session/recap') { + return Promise.resolve(respond(params)); + } + return Promise.resolve({}); + }, + }); + new AgentSideConnection(() => agent as Agent, agentStream); return { stream: clientStream, exited: new Promise< @@ -4688,42 +5209,79 @@ describe('createHttpAcpBridge', () => { killSync: () => {}, }; }; + } + + it('forwards through the ACP child and returns the recap verbatim', async () => { + const recapText = + 'Refactoring the auth middleware. Next: regenerate the integration fixtures.'; + let observedParams: Record | undefined; const bridge = makeBridge({ - channelFactory: factory, - persistDisabledTools: async () => {}, + channelFactory: recapFactory((params) => { + observedParams = params; + return { sessionId: params['sessionId'], recap: recapText }; + }), }); - // Two thread-scope sessions on the same workspace, so both - // entries live in the byId map and both should observe the - // workspace-scoped fan-out. - const a = await bridge.spawnOrAttach({ - workspaceCwd: WS_A, - sessionScope: 'thread', + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const result = await bridge.generateSessionRecap(session.sessionId); + expect(result).toEqual({ + sessionId: session.sessionId, + recap: recapText, }); - const b = await bridge.spawnOrAttach({ - workspaceCwd: WS_A, - sessionScope: 'thread', - }); - const aborts = [new AbortController(), new AbortController()]; - const itA = bridge - .subscribeEvents(a.sessionId, { signal: aborts[0]!.signal }) - [Symbol.asyncIterator](); - const itB = bridge - .subscribeEvents(b.sessionId, { signal: aborts[1]!.signal }) - [Symbol.asyncIterator](); - await bridge.setWorkspaceToolEnabled('Bash', false, undefined); - const [evA, evB] = await Promise.all([itA.next(), itB.next()]); - expect(evA.value?.type).toBe('tool_toggled'); - expect(evB.value?.type).toBe('tool_toggled'); - expect(evA.value?.data).toEqual({ toolName: 'Bash', enabled: false }); - expect(evB.value?.data).toEqual({ toolName: 'Bash', enabled: false }); - aborts.forEach((a) => a.abort()); + expect(observedParams).toEqual({ sessionId: session.sessionId }); await bridge.shutdown(); }); - it('stamps tool_toggled with the originator clientId when supplied', async () => { - const factory: ChannelFactory = async () => { + it('preserves a null recap (best-effort failure surface)', async () => { + const bridge = makeBridge({ + channelFactory: recapFactory((params) => ({ + sessionId: params['sessionId'], + recap: null, + })), + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const result = await bridge.generateSessionRecap(session.sessionId); + expect(result.recap).toBeNull(); + await bridge.shutdown(); + }); + + it('throws SessionNotFoundError for unknown sessionId', async () => { + const bridge = makeBridge({ + channelFactory: recapFactory(() => ({ + sessionId: 'never', + recap: null, + })), + }); + await expect( + bridge.generateSessionRecap('does-not-exist'), + ).rejects.toBeInstanceOf(SessionNotFoundError); + await bridge.shutdown(); + }); + }); + + describe('addRuntimeMcpServer (T2.8 #4514)', () => { + /** + * Build a channel factory whose ACP `extMethod` handler returns a + * configurable response for `qwen/control/workspace/mcp/runtime-add`. + */ + function runtimeAddFactory( + respond: ( + params: Record, + ) => + | Record + | Promise> + | Promise, + ): ChannelFactory { + return async () => { const { clientStream, agentStream } = createInMemoryChannel(); - new AgentSideConnection(() => new FakeAgent() as Agent, agentStream); + const agent = new FakeAgent({ + extMethodImpl: (method, params) => { + if (method === 'qwen/control/workspace/mcp/runtime-add') { + return Promise.resolve(respond(params)); + } + return Promise.resolve({}); + }, + }); + new AgentSideConnection(() => agent as Agent, agentStream); return { stream: clientStream, exited: new Promise< @@ -4734,16 +5292,124 @@ describe('createHttpAcpBridge', () => { killSync: () => {}, }; }; + } + + it('returns the success shape and broadcasts mcp_server_added', async () => { const bridge = makeBridge({ - channelFactory: factory, - persistDisabledTools: async () => {}, + channelFactory: runtimeAddFactory((params) => ({ + name: params['name'], + transport: 'stdio', + replaced: false, + shadowedSettings: false, + toolCount: 3, + originatorClientId: params['originatorClientId'], + })), }); const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); const abort = new AbortController(); const it = bridge .subscribeEvents(session.sessionId, { signal: abort.signal }) [Symbol.asyncIterator](); - await bridge.setWorkspaceToolEnabled('Bash', false, session.clientId); + const result = await bridge.addRuntimeMcpServer( + 'test-server', + { command: 'node', args: ['server.js'] }, + 'client-1', + ); + expect(result).toEqual({ + name: 'test-server', + transport: 'stdio', + replaced: false, + shadowedSettings: false, + toolCount: 3, + originatorClientId: 'client-1', + }); + const next = await it.next(); + expect(next.value?.type).toBe('mcp_server_added'); + expect(next.value?.data).toMatchObject({ + name: 'test-server', + transport: 'stdio', + replaced: false, + shadowedSettings: false, + toolCount: 3, + originatorClientId: 'client-1', + }); + abort.abort(); + await bridge.shutdown(); + }); + + it('does not emit event when result is skipped (budget_warning_only)', async () => { + const bridge = makeBridge({ + channelFactory: runtimeAddFactory(() => ({ + name: 'test-server', + skipped: true, + reason: 'budget_warning_only', + })), + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const it = bridge + .subscribeEvents(session.sessionId, { signal: abort.signal }) + [Symbol.asyncIterator](); + const result = await bridge.addRuntimeMcpServer( + 'test-server', + { command: 'node', args: ['server.js'] }, + 'client-1', + ); + expect(result).toEqual({ + name: 'test-server', + skipped: true, + reason: 'budget_warning_only', + }); + // No event should have been emitted — verify by checking that + // the async iterator has nothing ready (next() would hang). + // Use Promise.race with a short timeout to confirm no event. + const noEvent = await Promise.race([ + it.next().then(() => 'got_event'), + new Promise((r) => setTimeout(() => r('timeout'), 50)), + ]); + expect(noEvent).toBe('timeout'); + abort.abort(); + await bridge.shutdown(); + }); + + it('throws with errorKind acp_channel_unavailable when no ACP channel is live', async () => { + // Create a bridge but do NOT spawn any session + const bridge = makeBridge({}); + const err = await bridge + .addRuntimeMcpServer( + 'test-server', + { command: 'node', args: ['server.js'] }, + 'client-1', + ) + .catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect((err as { data?: { errorKind?: string } }).data?.errorKind).toBe( + 'acp_channel_unavailable', + ); + await bridge.shutdown(); + }); + + it('stamps mcp_server_added with the originator clientId', async () => { + const bridge = makeBridge({ + channelFactory: runtimeAddFactory((params) => ({ + name: params['name'], + transport: 'sse', + replaced: true, + shadowedSettings: true, + toolCount: 5, + originatorClientId: params['originatorClientId'], + })), + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const it = bridge + .subscribeEvents(session.sessionId, { signal: abort.signal }) + [Symbol.asyncIterator](); + await bridge.addRuntimeMcpServer( + 'my-mcp', + { url: 'http://localhost:3000/sse' }, + session.clientId!, + ); const next = await it.next(); expect(next.value?.originatorClientId).toBe(session.clientId); abort.abort(); @@ -4751,86 +5417,30 @@ describe('createHttpAcpBridge', () => { }); }); - describe('initWorkspace (#4175 Wave 4 PR 17)', () => { + describe('removeRuntimeMcpServer (T2.8 #4514)', () => { /** - * Per-test workspace temp dir so the bridge's writeFile lands on a - * real path the tests can stat. Cleaned up by `afterEach`. + * Build a channel factory whose ACP `extMethod` handler returns a + * configurable response for `qwen/control/workspace/mcp/runtime-remove`. */ - let tmpWs: string; - - beforeEach(async () => { - tmpWs = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-init-workspace-')); - }); - - afterEach(async () => { - await fsp.rm(tmpWs, { recursive: true, force: true }); - }); - - it('creates an empty QWEN.md on a fresh workspace', async () => { - const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); - const res = await bridge.initWorkspace({}, undefined); - expect(res.action).toBe('created'); - expect(res.path).toBe(path.join(tmpWs, 'QWEN.md')); - const written = await fsp.readFile(res.path, 'utf8'); - expect(written).toBe(''); - }); - - it('treats whitespace-only file as a noop without force (no 409, no write)', async () => { - // #4282 fold-in 1 (wenshao H4): whitespace-only existing file is - // a no-op rather than a silent overwrite. Original whitespace - // content is preserved; the response surface signals `'noop'` - // so the SSE event accurately reflects "no on-disk change." - const target = path.join(tmpWs, 'QWEN.md'); - const original = ' \n\t\n'; - await fsp.writeFile(target, original, 'utf8'); - const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); - const res = await bridge.initWorkspace({}, undefined); - expect(res.action).toBe('noop'); - const onDisk = await fsp.readFile(target, 'utf8'); - expect(onDisk).toBe(original); - }); - - it('throws WorkspaceInitConflictError when content exists and force is omitted', async () => { - const target = path.join(tmpWs, 'QWEN.md'); - const original = '# Project notes\n\nimportant stuff'; - await fsp.writeFile(target, original, 'utf8'); - const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); - const err = await bridge.initWorkspace({}, undefined).catch((e) => e); - expect(err).toBeInstanceOf(WorkspaceInitConflictError); - expect((err as WorkspaceInitConflictError).path).toBe(target); - expect((err as WorkspaceInitConflictError).existingSize).toBe( - Buffer.byteLength(original, 'utf8'), - ); - // Original content must be preserved on conflict. - expect(await fsp.readFile(target, 'utf8')).toBe(original); - }); - - it('overwrites with action:overwrote when force is true', async () => { - const target = path.join(tmpWs, 'QWEN.md'); - await fsp.writeFile(target, '# Old', 'utf8'); - const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); - const res = await bridge.initWorkspace({ force: true }, undefined); - expect(res.action).toBe('overwrote'); - expect(await fsp.readFile(target, 'utf8')).toBe(''); - }); - - it('does NOT spawn an ACP child', async () => { - let factoryCalls = 0; - const bridge = createHttpAcpBridge({ - boundWorkspace: tmpWs, - channelFactory: async () => { - factoryCalls += 1; - throw new Error('channel factory should not be invoked'); - }, - }); - await bridge.initWorkspace({}, undefined); - expect(factoryCalls).toBe(0); - }); - - it('fan-outs workspace_initialized to live session buses', async () => { - const factory: ChannelFactory = async () => { + function runtimeRemoveFactory( + respond: ( + params: Record, + ) => + | Record + | Promise> + | Promise, + ): ChannelFactory { + return async () => { const { clientStream, agentStream } = createInMemoryChannel(); - new AgentSideConnection(() => new FakeAgent() as Agent, agentStream); + const agent = new FakeAgent({ + extMethodImpl: (method, params) => { + if (method === 'qwen/control/workspace/mcp/runtime-remove') { + return Promise.resolve(respond(params)); + } + return Promise.resolve({}); + }, + }); + new AgentSideConnection(() => agent as Agent, agentStream); return { stream: clientStream, exited: new Promise< @@ -4841,26 +5451,83 @@ describe('createHttpAcpBridge', () => { killSync: () => {}, }; }; - const bridge = createHttpAcpBridge({ - boundWorkspace: tmpWs, - channelFactory: factory, + } + + it('returns the removed shape and broadcasts mcp_server_removed', async () => { + const bridge = makeBridge({ + channelFactory: runtimeRemoveFactory((params) => ({ + name: params['name'], + removed: true, + wasShadowingSettings: false, + originatorClientId: params['originatorClientId'], + })), }); - const session = await bridge.spawnOrAttach({ workspaceCwd: tmpWs }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); const abort = new AbortController(); const it = bridge .subscribeEvents(session.sessionId, { signal: abort.signal }) [Symbol.asyncIterator](); - const res = await bridge.initWorkspace({}, session.clientId); - const next = await it.next(); - expect(next.value?.type).toBe('workspace_initialized'); - expect(next.value?.data).toEqual({ - path: res.path, - action: 'created', + const result = await bridge.removeRuntimeMcpServer( + 'test-server', + 'client-2', + ); + expect(result).toEqual({ + name: 'test-server', + removed: true, + wasShadowingSettings: false, + originatorClientId: 'client-2', + }); + const next = await it.next(); + expect(next.value?.type).toBe('mcp_server_removed'); + expect(next.value?.data).toMatchObject({ + name: 'test-server', + wasShadowingSettings: false, + originatorClientId: 'client-2', }); - expect(next.value?.originatorClientId).toBe(session.clientId); abort.abort(); await bridge.shutdown(); }); + + it('does not emit event when result is skipped (not_present)', async () => { + const bridge = makeBridge({ + channelFactory: runtimeRemoveFactory(() => ({ + name: 'ghost', + skipped: true, + reason: 'not_present', + })), + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const it = bridge + .subscribeEvents(session.sessionId, { signal: abort.signal }) + [Symbol.asyncIterator](); + const result = await bridge.removeRuntimeMcpServer('ghost', 'client-2'); + expect(result).toEqual({ + name: 'ghost', + skipped: true, + reason: 'not_present', + }); + // No event should have been emitted + const noEvent = await Promise.race([ + it.next().then(() => 'got_event'), + new Promise((r) => setTimeout(() => r('timeout'), 50)), + ]); + expect(noEvent).toBe('timeout'); + abort.abort(); + await bridge.shutdown(); + }); + + it('throws with errorKind acp_channel_unavailable when no ACP channel is live', async () => { + const bridge = makeBridge({}); + const err = await bridge + .removeRuntimeMcpServer('test-server', 'client-2') + .catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect((err as { data?: { errorKind?: string } }).data?.errorKind).toBe( + 'acp_channel_unavailable', + ); + await bridge.shutdown(); + }); }); describe('subscribeEvents', () => { @@ -5503,6 +6170,436 @@ describe('createHttpAcpBridge', () => { }); }); + describe('extNotification — in-session model update (A1, #4511)', () => { + it('promotes current_model_update to model_switched when no bridge roundtrip is in flight', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + void capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: session.sessionId, + currentModelId: 'qwen-max', + }); + + const collected: Array<{ type: string; data: unknown }> = []; + for await (const e of iter) { + collected.push({ type: e.type, data: e.data }); + if (collected.length === 1) break; + } + // Promoted to model_switched with currentModelId mapped to modelId. + expect(collected[0]?.type).toBe('model_switched'); + expect(collected[0]?.data).toEqual({ + sessionId: session.sessionId, + modelId: 'qwen-max', + }); + abort.abort(); + await bridge.shutdown(); + }); + + it('suppresses current_model_update while a bridge model roundtrip is in flight', async () => { + // Hang the agent's unstable_setSessionModel so the bridge roundtrip + // stays in flight (modelRoundtripInFlight = true). The concurrent + // in-session current_model_update must be suppressed; only the bridge's + // own model_switched (after the roundtrip) reaches the bus. + let releaseModel: (() => void) | undefined; + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + const augmented = new Proxy(fakeAgent, { + get(target, prop) { + if (prop === 'unstable_setSessionModel') { + return () => + new Promise>((res) => { + releaseModel = () => res({}); + }); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target as any)[prop]; + }, + }); + capturedConn = new AgentSideConnection( + () => augmented as Agent, + agentStream, + ); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + // Start a bridge-driven model change; it hangs → roundtrip in flight. + const modelChange = bridge + .setSessionModel( + session.sessionId, + { sessionId: session.sessionId, modelId: 'qwen-max' }, + undefined, + ) + .catch(() => {}); + await new Promise((r) => setTimeout(r, 10)); + + // Concurrent in-session notification — must be SUPPRESSED. + void capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: session.sessionId, + currentModelId: 'qwen-turbo', + }); + await new Promise((r) => setTimeout(r, 10)); + + // Release the hung roundtrip → the bridge publishes its authoritative one. + releaseModel?.(); + await modelChange; + + const collected: Array<{ type: string; data: unknown }> = []; + for await (const e of iter) { + collected.push({ type: e.type, data: e.data }); + if (collected.length === 1) break; + } + // Exactly the bridge's model_switched (qwen-max) — the suppressed + // qwen-turbo notification did NOT produce a second model_switched. + expect(collected[0]?.type).toBe('model_switched'); + expect((collected[0]?.data as { modelId?: string }).modelId).toBe( + 'qwen-max', + ); + abort.abort(); + await bridge.shutdown(); + }); + + it('drops malformed model-update params (non-string ids) without throwing or emitting', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + capturedConn = new AgentSideConnection( + () => new FakeAgent(), + agentStream, + ); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const seen: string[] = []; + const collecting = (async () => { + for await (const e of iter) seen.push(e.type); + })(); + + // Non-string currentModelId / missing sessionId → early return, no throw. + await capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: session.sessionId, + currentModelId: 123 as unknown as string, + }); + await capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + currentModelId: 'qwen-max', + }); + await new Promise((r) => setTimeout(r, 10)); + abort.abort(); + await collecting; + expect(seen.filter((t) => t === 'model_switched')).toEqual([]); + await bridge.shutdown(); + }); + + it('drops a model-update for an unknown sessionId (no entry, no buffer)', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + capturedConn = new AgentSideConnection( + () => new FakeAgent(), + agentStream, + ); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const seen: string[] = []; + const collecting = (async () => { + for await (const e of iter) seen.push(e.type); + })(); + + await capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: 'nonexistent-session', + currentModelId: 'qwen-max', + }); + await new Promise((r) => setTimeout(r, 10)); + abort.abort(); + await collecting; + // Unlike the MCP-budget path (which buffers unknown ids), model-update + // drops them — the real session's bus sees nothing. + expect(seen.filter((t) => t === 'model_switched')).toEqual([]); + await bridge.shutdown(); + }); + + it('stamps originatorClientId from the active prompt on the promoted model_switched', async () => { + // While a prompt with a clientId is in flight, the session entry carries + // activePromptOriginatorClientId; the promoted model_switched must + // inherit it so peers can attribute the change. + let releasePrompt: (() => void) | undefined; + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent({ + promptImpl: async () => { + await new Promise((res) => { + releasePrompt = res; + }); + return { stopReason: 'end_turn' }; + }, + }); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + // Hang a prompt with a clientId → activePromptOriginatorClientId set. + const promptDone = bridge + .sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'hi' }], + }, + undefined, + { clientId: session.clientId }, + ) + .catch(() => {}); + await new Promise((r) => setTimeout(r, 10)); + + void capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: session.sessionId, + currentModelId: 'qwen-max', + }); + + const collected: Array<{ type: string; originatorClientId?: string }> = + []; + for await (const e of iter) { + if (e.type === 'model_switched') { + collected.push({ + type: e.type, + originatorClientId: e.originatorClientId, + }); + break; + } + } + expect(collected[0]?.originatorClientId).toBe(session.clientId); + releasePrompt?.(); + await promptDone; + abort.abort(); + await bridge.shutdown(); + }); + }); + + describe('extNotification — followup_suggestion', () => { + it('publishes followup_suggestion when the child fires a prompt-suggestion notification', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + void capturedConn!.extNotification( + 'qwen/notify/session/prompt-suggestion', + { + v: 1, + sessionId: session.sessionId, + suggestion: 'Run the tests?', + promptId: `${session.sessionId}########3`, + }, + ); + + const collected: Array<{ type: string; data: unknown }> = []; + for await (const e of iter) { + collected.push({ type: e.type, data: e.data }); + if (collected.length === 1) break; + } + expect(collected[0]?.type).toBe('followup_suggestion'); + expect(collected[0]?.data).toMatchObject({ + sessionId: session.sessionId, + suggestion: 'Run the tests?', + promptId: `${session.sessionId}########3`, + }); + abort.abort(); + await bridge.shutdown(); + }); + + it('drops malformed prompt-suggestion payloads', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + capturedConn = new AgentSideConnection( + () => new FakeAgent(), + agentStream, + ); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const seen: string[] = []; + const collecting = (async () => { + for await (const e of iter) seen.push(e.type); + })(); + + void capturedConn!.extNotification( + 'qwen/notify/session/prompt-suggestion', + { v: 1, sessionId: session.sessionId, suggestion: '' }, + ); + void capturedConn!.extNotification( + 'qwen/notify/session/prompt-suggestion', + { v: 1, sessionId: session.sessionId, promptId: 'p1' }, + ); + void capturedConn!.extNotification( + 'qwen/notify/session/prompt-suggestion', + { v: 1 }, + ); + void capturedConn!.extNotification( + 'qwen/notify/session/prompt-suggestion', + { + v: 1, + sessionId: session.sessionId, + suggestion: 123 as unknown as string, + promptId: 'p1', + }, + ); + await new Promise((r) => setTimeout(r, 10)); + abort.abort(); + await collecting; + expect(seen.filter((t) => t === 'followup_suggestion')).toEqual([]); + await bridge.shutdown(); + }); + + it('drops prompt-suggestion after session is closed', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + capturedConn = new AgentSideConnection( + () => new FakeAgent(), + agentStream, + ); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.closeSession(session.sessionId); + + void capturedConn!.extNotification( + 'qwen/notify/session/prompt-suggestion', + { + v: 1, + sessionId: session.sessionId, + suggestion: 'stale', + promptId: 'p1', + }, + ); + // No throw — silently dropped. + await bridge.shutdown(); + }); + }); + describe('maxSessions cap (chiga0 Rec 3)', () => { it('refuses NEW spawns past the cap with SessionLimitExceededError', async () => { let n = 0; @@ -5937,37 +7034,6 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); - it('keeps a shared ACP channel alive while closing one of several sessions', async () => { - const handles: ChannelHandle[] = []; - const factory: ChannelFactory = async () => { - const h = makeChannel({ sessionIdPrefix: `s${handles.length}` }); - handles.push(h); - return h.channel; - }; - const bridge = makeBridge({ - channelFactory: factory, - sessionScope: 'thread', - }); - const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const c = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - expect(handles).toHaveLength(1); - - await bridge.closeSession(b.sessionId); - expect(bridge.sessionCount).toBe(2); - expect(handles[0]?.killed).toBe(false); - - await bridge.closeSession(a.sessionId); - expect(bridge.sessionCount).toBe(1); - expect(handles[0]?.killed).toBe(false); - - await bridge.closeSession(c.sessionId); - expect(bridge.sessionCount).toBe(0); - expect(handles[0]?.killed).toBe(true); - - await bridge.shutdown(); - }); - it('throws SessionNotFoundError for unknown session', async () => { const bridge = makeBridge(); await expect(bridge.closeSession('nonexistent')).rejects.toThrow( @@ -6042,6 +7108,95 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); + + it('routes per-entry channel bookkeeping via channelInfoForEntry, not the module-scoped channelInfo (#4325)', async () => { + // Regression guard for #4325 (wenshao review on F1 #4319). + // + // The bug pre-fix: `closeSession` (and `killSession`) captured + // `const ci = channelInfo` — the module-scoped CURRENT attach + // target — rather than `channelInfoForEntry(entry)`. The two + // diverge during the channel-overlap window (A dying, B freshly + // spawned as `channelInfo`): closing a session whose `entry.channel + // = A` would (1) skip `A.sessionIds.delete()` because + // `B.channel !== A.channel`, leaving A's sessionIds set pinned past + // the close, and (2) call `markSessionClosed` on B's client + // instead of A's, evaluating B's kill condition with stale + // assumptions about its session count — potentially killing B + // unnecessarily and forcing a third spawn. + // + // Constructing the exact overlap state deterministically requires + // factory-internal hooks not currently exposed (A only becomes + // `isDying` when its sessionIds drains to 0, and that drain path + // also removes the session from `byId` synchronously — so by the + // time channelInfo could move to B, every session that was on A is + // gone from `byId` and thus unreachable to `closeSession`). The + // full overlap regression test is deferred to a follow-up that + // adds the necessary test-only factory inspection seam. + // + // What this smoke test guards: under the normal single-channel + // case, `closeSession` still drives the channel's lifecycle + // correctly — channel kill fires after the last session closes, + // which is the most-load-bearing behavior in the fix's neighborhood + // and would fail trivially if a future refactor reverted to the + // module-scoped `channelInfo` capture without thinking through + // the case where the helper returns `undefined`. + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel(); + handles.push(h); + return h.channel; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(handles).toHaveLength(1); + expect(handles[0]?.killed).toBe(false); + + await bridge.closeSession(session.sessionId); + + // Channel kill must have fired — proves `closeSession` correctly + // located the entry's channel via `channelInfoForEntry(entry)` + // (which returns the channel matching `entry.channel`) and + // triggered the L2163-2165 "kill on last session" branch. A + // reverted fix that captured `channelInfo` after the entry was + // gone from `byId` would also pass this assertion, but the + // diff-review-time visibility of the `channelInfoForEntry` call + // is the primary defense. + expect(handles[0]?.killed).toBe(true); + expect(bridge.sessionCount).toBe(0); + + await bridge.shutdown(); + }); + + it('killSession routes per-entry channel bookkeeping via channelInfoForEntry (#4325 symmetric)', async () => { + // Symmetric smoke guard for #4325 (wenshao review on this PR). + // `killSession` received the same `channelInfo` → + // `channelInfoForEntry(entry)` fix at `bridge.ts:3182` as + // `closeSession` did. The closeSession smoke above doesn't + // exercise the killSession code path, so a future refactor + // reverting only killSession would pass that test trivially. + // Same single-channel caveat: the channel-overlap race itself + // isn't deterministic without test-only factory hooks; this + // smoke verifies the most-load-bearing behavior — kill fires + // and tears down the channel — which would fail if a revert + // captured a stale module-scoped `channelInfo`. + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel(); + handles.push(h); + return h.channel; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(handles).toHaveLength(1); + expect(handles[0]?.killed).toBe(false); + + await bridge.killSession(session.sessionId); + + expect(handles[0]?.killed).toBe(true); + expect(bridge.sessionCount).toBe(0); + + await bridge.shutdown(); + }); }); describe('updateSessionMetadata', () => { @@ -6227,3 +7382,2275 @@ describe('createHttpAcpBridge', () => { }); }); }); + +// ============================================================ +// F3 Commit 8 — bridge-level integration for the multi-client +// permission mediator. Mediator unit tests cover strategy logic +// (35 tests in `permissionMediator.test.ts`); these exercise the +// HTTP-bridge surface specifically: +// - `bridge.permissionPolicy` accessor wired through the mediator +// - F3 BridgeOptions validation (positive-integer quorum) +// ============================================================ +describe('createAcpSessionBridge — F3 multi-client permission coordination', () => { + it('exposes the active permission policy through bridge.permissionPolicy (default first-responder)', () => { + const bridge = makeBridge({}); + expect(bridge.permissionPolicy).toBe('first-responder'); + }); + + it('reflects the configured policy when BridgeOptions.permissionPolicy is set', () => { + const bridge = makeBridge({ permissionPolicy: 'consensus' }); + expect(bridge.permissionPolicy).toBe('consensus'); + }); + + it('throws on non-positive-integer permissionConsensusQuorum', () => { + expect(() => + makeBridge({ + permissionPolicy: 'consensus', + permissionConsensusQuorum: 0, + }), + ).toThrow(/positive integer/); + expect(() => + makeBridge({ + permissionPolicy: 'consensus', + permissionConsensusQuorum: 1.5, + }), + ).toThrow(/positive integer/); + }); +}); + +// ============================================================ +// BridgeOptions.onDiagnosticLine — verify the tee callback +// receives writeServeDebugLine output when QWEN_SERVE_DEBUG=1. +// ============================================================ +describe('onDiagnosticLine', () => { + const originalDebug = process.env['QWEN_SERVE_DEBUG']; + afterEach(() => { + if (originalDebug === undefined) delete process.env['QWEN_SERVE_DEBUG']; + else process.env['QWEN_SERVE_DEBUG'] = originalDebug; + }); + + it('receives writeServeDebugLine output when QWEN_SERVE_DEBUG=1', async () => { + process.env['QWEN_SERVE_DEBUG'] = '1'; + const captured: Array<{ line: string; level?: string }> = []; + + // Thread scope → two distinct sessions sharing one channel. + let capturedConn: InstanceType | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + const conn = new AgentSideConnection(() => fakeAgent, agentStream); + capturedConn = conn; + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + + const bridge = makeBridge({ + sessionScope: 'thread', + channelFactory: factory, + onDiagnosticLine: (line, level) => captured.push({ line, level }), + }); + + const sessionA = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const sessionB = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(sessionA.sessionId).not.toBe(sessionB.sessionId); + + // Issue a permission request on session A via the agent side. + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(sessionA.sessionId, { + signal: subAbort.signal, + }); + + // Fire requestPermission from the agent side (same pattern as + // setupForPermission in the permission_request tests above). + void ( + capturedConn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: sessionA.sessionId, + toolCall: { toolCallId: 'tc-diag', title: 'test-tool' }, + options: [ + { optionId: 'allow', name: 'Allow', kind: 'allow_once' }, + { optionId: 'deny', name: 'Deny', kind: 'reject_once' }, + ], + }); + + // Read the permission_request event to get the requestId. + const it2 = iter[Symbol.asyncIterator](); + const next = await it2.next(); + expect(next.done).toBe(false); + const payload = next.value!.data as { requestId: string }; + + // Vote using session B's sessionId → cross-session rejection path + // which triggers teeServeDebugLine (bridge.ts line ~2253). + const accepted = bridge.respondToSessionPermission( + sessionB.sessionId, + payload.requestId, + { outcome: { outcome: 'cancelled' } }, + ); + expect(accepted).toBe(false); + + // Verify the onDiagnosticLine callback received the debug line. + expect(captured.some((e) => e.line.includes('qwen serve debug: '))).toBe( + true, + ); + expect( + captured.some((e) => e.line.includes('rejected permission vote')), + ).toBe(true); + expect( + captured.every((e) => e.level === undefined || e.level === 'info'), + ).toBe(true); + + subAbort.abort(); + await bridge.shutdown(); + }); + + it('does not invoke callback when QWEN_SERVE_DEBUG is off', async () => { + delete process.env['QWEN_SERVE_DEBUG']; + const captured: Array<{ line: string; level?: string }> = []; + + let capturedConn: InstanceType | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + const conn = new AgentSideConnection(() => fakeAgent, agentStream); + capturedConn = conn; + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + + const bridge = makeBridge({ + sessionScope: 'thread', + channelFactory: factory, + onDiagnosticLine: (line, level) => captured.push({ line, level }), + }); + + const sessionA = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const sessionB = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(sessionA.sessionId, { + signal: subAbort.signal, + }); + + void ( + capturedConn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: sessionA.sessionId, + toolCall: { toolCallId: 'tc-diag2', title: 'test-tool' }, + options: [ + { optionId: 'allow', name: 'Allow', kind: 'allow_once' }, + { optionId: 'deny', name: 'Deny', kind: 'reject_once' }, + ], + }); + + const it2 = iter[Symbol.asyncIterator](); + const next = await it2.next(); + const payload = next.value!.data as { requestId: string }; + + // Same cross-session vote — but QWEN_SERVE_DEBUG is off. + bridge.respondToSessionPermission(sessionB.sessionId, payload.requestId, { + outcome: { outcome: 'cancelled' }, + }); + + // Callback must NOT have been invoked. + expect(captured).toHaveLength(0); + + subAbort.abort(); + await bridge.shutdown(); + }); +}); + +describe('extractErrorMessage', () => { + it('extracts message from Error instance', () => { + expect(extractErrorMessage(new Error('boom'))).toBe('boom'); + }); + + it('extracts details from JSON-RPC error object', () => { + expect( + extractErrorMessage({ + code: -32603, + message: 'Internal error', + data: { details: 'session not found' }, + }), + ).toBe('session not found'); + }); + + it('extracts provider messages from JSON-RPC error data', () => { + expect( + extractErrorMessage({ + code: -32603, + message: 'Internal error', + data: { + code: 'ServiceUnavailable', + message: '<503> model serving is throttled', + }, + }), + ).toBe('<503> model serving is throttled'); + }); + + it('extracts details from Error subclasses with JSON-RPC data', () => { + expect( + extractErrorMessage( + new RequestError(-32603, 'Internal error', { + details: 'session not found', + }), + ), + ).toBe('session not found'); + }); + + it('extracts string data from Error subclasses with JSON-RPC data', () => { + expect( + extractErrorMessage( + new RequestError(-32603, 'Internal error', 'session not found'), + ), + ).toBe('session not found'); + }); + + it('extracts string data from JSON-RPC error object', () => { + expect( + extractErrorMessage({ + code: -32603, + message: 'Internal error', + data: 'session not found', + }), + ).toBe('session not found'); + }); + + it('falls back to message when data.details is missing', () => { + expect( + extractErrorMessage({ code: -32600, message: 'Invalid Request' }), + ).toBe('Invalid Request'); + }); + + it('falls back to message when data.details is empty string', () => { + expect( + extractErrorMessage({ + code: -32603, + message: 'Internal error', + data: { details: '' }, + }), + ).toBe('Internal error'); + }); + + it('converts string to itself', () => { + expect(extractErrorMessage('plain string')).toBe('plain string'); + }); + + it('converts null via String()', () => { + expect(extractErrorMessage(null)).toBe('null'); + }); + + it('converts undefined via String()', () => { + expect(extractErrorMessage(undefined)).toBe('undefined'); + }); + + it('extracts message from plain object with message property', () => { + expect(extractErrorMessage({ message: 'custom error' })).toBe( + 'custom error', + ); + }); + + it('converts object without message via String()', () => { + expect(extractErrorMessage({ foo: 'bar' })).toBe('[object Object]'); + }); +}); + +describe('extractErrorCode', () => { + it('returns string code as-is', () => { + expect(extractErrorCode({ code: 'NETWORK_ERROR' })).toBe('NETWORK_ERROR'); + }); + + it('converts numeric code to string', () => { + expect(extractErrorCode({ code: -32603 })).toBe('-32603'); + }); + + it('returns undefined for non-object', () => { + expect(extractErrorCode('not an object')).toBeUndefined(); + }); + + it('returns undefined for null', () => { + expect(extractErrorCode(null)).toBeUndefined(); + }); + + it('returns undefined when code is missing', () => { + expect(extractErrorCode({ message: 'no code' })).toBeUndefined(); + }); + + it('returns undefined when code is not string or number', () => { + expect(extractErrorCode({ code: true })).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// §2.3 side-channel state layer: publish helpers + reconciliation + snapshot +// --------------------------------------------------------------------------- + +describe('createHttpAcpBridge — side-channel state layer (#4511)', () => { + describe('publish helpers cache + generation', () => { + it('publishModelSwitched updates cache and publishes model_switched', async () => { + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + const augmented = new Proxy(fakeAgent, { + get(target, prop) { + if (prop === 'unstable_setSessionModel') { + return async () => ({}); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target as any)[prop]; + }, + }); + new AgentSideConnection(() => augmented as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + await bridge.setSessionModel( + session.sessionId, + { sessionId: session.sessionId, modelId: 'qwen-max' }, + undefined, + ); + + const it2 = iter[Symbol.asyncIterator](); + const next = await it2.next(); + expect(next.value?.type).toBe('model_switched'); + expect((next.value?.data as { modelId: string }).modelId).toBe( + 'qwen-max', + ); + abort.abort(); + await bridge.shutdown(); + }); + + it('publishApprovalModeChanged publishes approval_mode_changed on setSessionApprovalMode', async () => { + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const agent = new FakeAgent({ + extMethodImpl: (method, params) => { + if (method === 'qwen/control/session/approval_mode') { + return Promise.resolve({ + previous: 'default', + current: (params as { mode: string }).mode, + }); + } + return Promise.resolve({}); + }, + }); + new AgentSideConnection(() => agent as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + await bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.YOLO, + { persist: false }, + ); + + const it2 = iter[Symbol.asyncIterator](); + const next = await it2.next(); + expect(next.value?.type).toBe('approval_mode_changed'); + expect((next.value?.data as { next: string }).next).toBe( + ApprovalMode.YOLO, + ); + abort.abort(); + await bridge.shutdown(); + }); + }); + + describe('extNotification — in-session mode update (A2)', () => { + it('promotes current_mode_update to approval_mode_changed when no bridge roundtrip is in flight', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + void capturedConn!.extNotification('qwen/notify/session/mode-update', { + v: 1, + sessionId: session.sessionId, + currentModeId: 'auto-edit', + }); + + const collected: Array<{ type: string; data: unknown }> = []; + for await (const e of iter) { + collected.push({ type: e.type, data: e.data }); + if (collected.length === 1) break; + } + expect(collected[0]?.type).toBe('approval_mode_changed'); + expect((collected[0]?.data as { next: string }).next).toBe('auto-edit'); + abort.abort(); + await bridge.shutdown(); + }); + + it('suppresses current_mode_update while a bridge approval-mode roundtrip is in flight', async () => { + let releaseMode: (() => void) | undefined; + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent({ + extMethodImpl: (method) => { + if (method.includes('approval_mode')) { + return new Promise>((res) => { + releaseMode = () => + res({ previous: 'default', current: 'yolo' }); + }); + } + return {}; + }, + }); + capturedConn = new AgentSideConnection( + () => fakeAgent as Agent, + agentStream, + ); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + const modeChange = bridge + .setSessionApprovalMode(session.sessionId, ApprovalMode.YOLO, { + persist: false, + }) + .catch(() => {}); + await new Promise((r) => setTimeout(r, 10)); + + void capturedConn!.extNotification('qwen/notify/session/mode-update', { + v: 1, + sessionId: session.sessionId, + currentModeId: 'auto', + }); + await new Promise((r) => setTimeout(r, 10)); + + releaseMode!(); + await modeChange; + + const seen: string[] = []; + for await (const e of iter) { + seen.push(e.type); + if (e.type === 'approval_mode_changed') break; + } + // Only the bridge's own approval_mode_changed (yolo) — the suppressed + // 'auto' notification did NOT produce a second event. + expect(seen.filter((t) => t === 'approval_mode_changed')).toHaveLength(1); + abort.abort(); + await bridge.shutdown(); + }); + + it('drops malformed mode-update params without throwing', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + // Missing currentModeId + void capturedConn!.extNotification('qwen/notify/session/mode-update', { + v: 1, + sessionId: session.sessionId, + }); + // Non-string currentModeId + void capturedConn!.extNotification('qwen/notify/session/mode-update', { + v: 1, + sessionId: session.sessionId, + currentModeId: 42, + }); + await new Promise((r) => setTimeout(r, 50)); + + // No events should have been produced (no approval_mode_changed). + // Send a known good one to break the iterator. + void capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: session.sessionId, + currentModelId: 'qwen-max', + }); + + const seen: string[] = []; + for await (const e of iter) { + seen.push(e.type); + if (e.type === 'model_switched') break; + } + expect(seen.filter((t) => t === 'approval_mode_changed')).toEqual([]); + abort.abort(); + await bridge.shutdown(); + }); + + it('drops a current_mode_update with an unknown mode id (enum guard)', async () => { + // The agent can reach this receive path without `Session.setMode`'s + // enum validation, so a bogus mode id must be dropped here before it + // fans out to SSE clients / the SDK reducer's state.approvalMode. + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + // Well-formed string, but not a known approval mode. + void capturedConn!.extNotification('qwen/notify/session/mode-update', { + v: 1, + sessionId: session.sessionId, + currentModeId: 'totally-bogus', + }); + await new Promise((r) => setTimeout(r, 50)); + + // A known good model-update breaks the iterator; the bogus mode must + // not have produced an approval_mode_changed (or a legacy dual-emit). + void capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: session.sessionId, + currentModelId: 'qwen-max', + }); + + const seen: string[] = []; + for await (const e of iter) { + seen.push(e.type); + if (e.type === 'model_switched') break; + } + expect(seen.filter((t) => t === 'approval_mode_changed')).toEqual([]); + expect(seen.filter((t) => t === 'session_update')).toEqual([]); + abort.abort(); + await bridge.shutdown(); + }); + + it('dual-emits a legacy session_update on the setMode path (no legacyFrameSent)', async () => { + // The ACP `session/set_mode` path has no `sendUpdate`, so the demux + // owns the IDE-companion compat frame: one approval_mode_changed plus + // one legacy session_update{current_mode_update}. + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + void capturedConn!.extNotification('qwen/notify/session/mode-update', { + v: 1, + sessionId: session.sessionId, + currentModeId: 'auto-edit', + }); + + const collected: Array<{ type: string; data: unknown }> = []; + for await (const e of iter) { + collected.push({ type: e.type, data: e.data }); + if (e.type === 'session_update') break; + } + expect(collected.map((c) => c.type)).toEqual([ + 'approval_mode_changed', + 'session_update', + ]); + // Canonical ACP-nested shape so the companion's standard + // data.update.sessionUpdate switch recognises it. + const update = ( + collected[1]?.data as { + update?: { sessionUpdate?: string; currentModeId?: string }; + } + ).update; + expect(update?.sessionUpdate).toBe('current_mode_update'); + expect(update?.currentModeId).toBe('auto-edit'); + abort.abort(); + await bridge.shutdown(); + }); + + it('suppresses the legacy dual-emit when legacyFrameSent is true (exit_plan_mode path)', async () => { + // `Session.sendCurrentModeUpdateNotification` already published the + // legacy session_update via `sendUpdate` before this extNotification, + // so the demux must promote to approval_mode_changed only — emitting + // its own dual-emit would deliver the legacy frame to the companion + // twice for one mode change. + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + void capturedConn!.extNotification('qwen/notify/session/mode-update', { + v: 1, + sessionId: session.sessionId, + currentModeId: 'auto-edit', + legacyFrameSent: true, + }); + await new Promise((r) => setTimeout(r, 50)); + + // A known good model-update breaks the iterator; assert exactly one + // approval_mode_changed and NO legacy session_update from this path. + void capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: session.sessionId, + currentModelId: 'qwen-max', + }); + + const seen: string[] = []; + for await (const e of iter) { + seen.push(e.type); + if (e.type === 'model_switched') break; + } + expect(seen.filter((t) => t === 'approval_mode_changed')).toHaveLength(1); + expect(seen.filter((t) => t === 'session_update')).toEqual([]); + abort.abort(); + await bridge.shutdown(); + }); + }); + + describe('A5 — session snapshot on attach', () => { + it('yields session_snapshot after replay_complete when snapshot=true', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + // Promote a model change to populate the cache. + void capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: session.sessionId, + currentModelId: 'qwen-turbo', + }); + await new Promise((r) => setTimeout(r, 20)); + + // Subscribe with snapshot=true (triggers replay_complete + snapshot). + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + lastEventId: 0, + snapshot: true, + }); + + const collected: BridgeEvent[] = []; + for await (const e of iter) { + collected.push(e); + if (e.type === 'session_snapshot') break; + } + const rc = collected.find((e) => e.type === 'replay_complete'); + const snap = collected.find((e) => e.type === 'session_snapshot'); + expect(rc).toBeDefined(); + expect(snap).toBeDefined(); + expect(collected.indexOf(snap!)).toBeGreaterThan(collected.indexOf(rc!)); + expect( + (snap!.data as { currentModelId: string | null }).currentModelId, + ).toBe('qwen-turbo'); + abort.abort(); + await bridge.shutdown(); + }); + + it('carries currentApprovalMode in the snapshot when an approval-mode change was promoted', async () => { + // The other A5 tests only seed currentModelId, so the + // publishApprovalModeChanged → entry.currentApprovalMode → snapshot + // pipeline is otherwise untested at the bridge level: a typo writing + // the wrong field would leave currentApprovalMode null and slip past. + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + // Promote an in-session mode change to populate currentApprovalMode + // (flows through onModePromoted → publishApprovalModeChanged). + void capturedConn!.extNotification('qwen/notify/session/mode-update', { + v: 1, + sessionId: session.sessionId, + currentModeId: 'auto-edit', + }); + await new Promise((r) => setTimeout(r, 20)); + + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + lastEventId: 0, + snapshot: true, + }); + + const collected: BridgeEvent[] = []; + for await (const e of iter) { + collected.push(e); + if (e.type === 'session_snapshot') break; + } + const snap = collected.find((e) => e.type === 'session_snapshot'); + expect(snap).toBeDefined(); + expect( + (snap!.data as { currentApprovalMode: string | null }) + .currentApprovalMode, + ).toBe('auto-edit'); + abort.abort(); + await bridge.shutdown(); + }); + + it('does NOT yield session_snapshot when snapshot is not set', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + // Promote a model change so there IS cache state. + void capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: session.sessionId, + currentModelId: 'qwen-turbo', + }); + await new Promise((r) => setTimeout(r, 20)); + + // Subscribe WITHOUT snapshot. + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + lastEventId: 0, + }); + + // After replay_complete, send a known event to break the loop. + const collected: BridgeEvent[] = []; + // Publish something after a short delay so the iterator eventually yields. + setTimeout(() => { + void capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: session.sessionId, + currentModelId: 'qwen-max', + }); + }, 30); + + for await (const e of iter) { + collected.push(e); + // Stop after we see replay_complete + one more real event. + if ( + collected.some((c) => c.type === 'replay_complete') && + collected.some((c) => c.type === 'model_switched') + ) + break; + } + expect( + collected.find((e) => e.type === 'session_snapshot'), + ).toBeUndefined(); + abort.abort(); + await bridge.shutdown(); + }); + + it('yields session_snapshot up front on a fresh connection (no Last-Event-ID)', async () => { + // Regression for the A5 primary use case: a fresh attach has no + // `Last-Event-ID`, so the bus never emits `replay_complete` (the whole + // replay block is gated on `lastEventId !== undefined`). Keying the + // snapshot solely off `replay_complete` made it silently no-op exactly + // when a client most needs to seed state — on initial attach. + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + void capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: session.sessionId, + currentModelId: 'qwen-turbo', + }); + await new Promise((r) => setTimeout(r, 20)); + + // Fresh subscribe — snapshot=true, NO lastEventId. + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + snapshot: true, + }); + + const it2 = iter[Symbol.asyncIterator](); + const first = await it2.next(); + // The very first frame must be the snapshot (no replay precedes it). + expect(first.value?.type).toBe('session_snapshot'); + expect( + (first.value?.data as { currentModelId: string | null }).currentModelId, + ).toBe('qwen-turbo'); + abort.abort(); + await bridge.shutdown(); + }); + + it('seeds snapshot from newSession response without any intermediate notification (cold attach)', async () => { + // F7qEJ: seedSnapshotCaches fills the cache from the newSession + // response alone — no extNotification or setSessionModel needed. + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent({ + newSessionImpl: (p) => + Promise.resolve({ + sessionId: `sess:${p.cwd}`, + models: { + currentModelId: 'qwen-plus', + availableModels: [{ modelId: 'qwen-plus', name: 'Qwen Plus' }], + }, + modes: { + currentModeId: 'auto-edit', + availableModes: [ + { modeId: 'auto-edit', id: 'auto-edit', name: 'Auto Edit' }, + ], + }, + }), + }); + new AgentSideConnection(() => fakeAgent as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + // Subscribe with snapshot=true, no lastEventId — pure cold attach. + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + snapshot: true, + }); + const it2 = iter[Symbol.asyncIterator](); + const first = await it2.next(); + expect(first.value?.type).toBe('session_snapshot'); + const data = first.value?.data as { + currentModelId: string | null; + currentApprovalMode: string | null; + }; + expect(data.currentModelId).toBe('qwen-plus'); + expect(data.currentApprovalMode).toBe('auto-edit'); + abort.abort(); + await bridge.shutdown(); + }); + }); + + describe('§2.2 — post-roundtrip reconciliation', () => { + const makeReconcileFactory = + ( + sessionContextModelId: string | undefined, + opts: { throwOnStatus?: boolean } = {}, + ): ChannelFactory => + async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent({ + extMethodImpl: (method) => { + if (method === 'qwen/status/session/context') { + if (opts.throwOnStatus) { + throw new Error('status read failed'); + } + return Promise.resolve({ + state: { models: { currentModelId: sessionContextModelId } }, + }); + } + return Promise.resolve({}); + }, + }); + const augmented = new Proxy(fakeAgent, { + get(target, prop) { + if (prop === 'unstable_setSessionModel') { + return async () => ({}); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target as any)[prop]; + }, + }); + new AgentSideConnection(() => augmented as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + + it('publishes a corrective model_switched when the agent state drifted from cache', async () => { + // Switch to qwen-max, but the agent's real state is qwen-turbo (e.g. + // an agent-side override). Reconciliation must emit a corrective + // model_switched so peers converge on the agent's truth. + const bridge = makeBridge({ + channelFactory: makeReconcileFactory('qwen-turbo'), + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + await bridge.setSessionModel( + session.sessionId, + { sessionId: session.sessionId, modelId: 'qwen-max' }, + undefined, + ); + + const seen: Array<{ type: string; modelId?: string }> = []; + for await (const e of iter) { + seen.push({ + type: e.type, + modelId: (e.data as { modelId?: string })?.modelId, + }); + if (seen.filter((s) => s.type === 'model_switched').length === 2) break; + } + const switches = seen.filter((s) => s.type === 'model_switched'); + // First the requested change, then the corrective one from reconcile. + expect(switches[0]?.modelId).toBe('qwen-max'); + expect(switches[1]?.modelId).toBe('qwen-turbo'); + abort.abort(); + await bridge.shutdown(); + }); + + it('does NOT publish a corrective event when agent state matches cache', async () => { + // Stateful agent: `sessionContext` echoes the last model the bridge + // set, so reconciliation always finds cache == agent truth and never + // emits a corrective. Two distinct changes must therefore produce + // exactly two model_switched events, with no duplicates in between. + let lastModel: string | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent({ + extMethodImpl: (method) => { + if (method === 'qwen/status/session/context') { + return Promise.resolve({ + state: { models: { currentModelId: lastModel } }, + }); + } + return Promise.resolve({}); + }, + }); + const augmented = new Proxy(fakeAgent, { + get(target, prop) { + if (prop === 'unstable_setSessionModel') { + return async (p: { modelId: string }) => { + lastModel = p.modelId; + return {}; + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target as any)[prop]; + }, + }); + new AgentSideConnection(() => augmented as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + await bridge.setSessionModel( + session.sessionId, + { sessionId: session.sessionId, modelId: 'qwen-max' }, + undefined, + ); + // Second distinct change terminates the iterator; a spurious + // corrective would surface as a duplicate model_switched. + await bridge.setSessionModel( + session.sessionId, + { sessionId: session.sessionId, modelId: 'qwen-plus' }, + undefined, + ); + + const switches: string[] = []; + for await (const e of iter) { + if (e.type === 'model_switched') { + switches.push((e.data as { modelId: string }).modelId); + if (switches.includes('qwen-plus')) break; + } + } + expect(switches).toEqual(['qwen-max', 'qwen-plus']); + abort.abort(); + await bridge.shutdown(); + }); + + it('swallows a failed status read without crashing or masking the original change', async () => { + const bridge = makeBridge({ + channelFactory: makeReconcileFactory(undefined, { + throwOnStatus: true, + }), + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + await expect( + bridge.setSessionModel( + session.sessionId, + { sessionId: session.sessionId, modelId: 'qwen-max' }, + undefined, + ), + ).resolves.toBeDefined(); + + const it2 = iter[Symbol.asyncIterator](); + const next = await it2.next(); + // The original model_switched is delivered; reconcile failure stays in + // the operator log (no bus event the SDK cannot decode). + expect(next.value?.type).toBe('model_switched'); + expect((next.value?.data as { modelId: string }).modelId).toBe( + 'qwen-max', + ); + abort.abort(); + await bridge.shutdown(); + }); + + it('does NOT reconcile when the model roundtrip itself fails', async () => { + // The agent's unstable_setSessionModel rejects, so publishModelSwitched + // never runs and the cache is unchanged. Reconciliation must be skipped + // (no status read), and the only bus event is model_switch_failed — + // never a corrective model_switched paired with the failure. + let statusReads = 0; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent({ + extMethodImpl: (method) => { + if (method === 'qwen/status/session/context') { + statusReads += 1; + return Promise.resolve({ + state: { models: { currentModelId: 'qwen-turbo' } }, + }); + } + return Promise.resolve({}); + }, + }); + const augmented = new Proxy(fakeAgent, { + get(target, prop) { + if (prop === 'unstable_setSessionModel') { + return async () => { + throw new Error('agent refused model switch'); + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target as any)[prop]; + }, + }); + new AgentSideConnection(() => augmented as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + await expect( + bridge.setSessionModel( + session.sessionId, + { sessionId: session.sessionId, modelId: 'qwen-max' }, + undefined, + ), + ).rejects.toThrow(); + + const it2 = iter[Symbol.asyncIterator](); + const next = await it2.next(); + expect(next.value?.type).toBe('model_switch_failed'); + // Give any (incorrectly) scheduled reconcile a tick to fire. + await new Promise((r) => setTimeout(r, 10)); + expect(statusReads).toBe(0); + abort.abort(); + await bridge.shutdown(); + }); + + it('re-runs reconcile when a newer change publishes during the status read (generation rerun)', async () => { + // Anti-lost-reconcile: while reconcile for change A awaits its status + // RPC, a second change B publishes (bumping the generation). B's own + // reconcile bails on the in-flight guard, so without the `rerun` path + // B would never be reconciled. Gate the FIRST status read until B has + // published; assert the FIRST read is discarded (generation changed) + // and a SECOND read fires after the guard releases, whose corrective + // reflects the agent's truth read AFTER B — not a stale read for A. + let statusReads = 0; + let releaseFirstStatus: (() => void) | undefined; + const firstStatusGate = new Promise((res) => { + releaseFirstStatus = res; + }); + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent({ + extMethodImpl: (method) => { + if (method === 'qwen/status/session/context') { + statusReads += 1; + // Agent truth drifts from both A and B, so the post-rerun + // read produces an observable corrective. + const payload = { + state: { models: { currentModelId: 'qwen-turbo' } }, + }; + return statusReads === 1 + ? firstStatusGate.then(() => payload) + : Promise.resolve(payload); + } + return Promise.resolve({}); + }, + }); + const augmented = new Proxy(fakeAgent, { + get(target, prop) { + if (prop === 'unstable_setSessionModel') { + return async () => ({}); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target as any)[prop]; + }, + }); + new AgentSideConnection(() => augmented as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + // A: publishes gen=1; its reconcile starts and blocks on the gate. + await bridge.setSessionModel( + session.sessionId, + { sessionId: session.sessionId, modelId: 'qwen-max' }, + undefined, + ); + // B: publishes gen=2 while A's reconcile is still awaiting the gated + // status read; B's own reconcile bails on the in-flight guard. + await bridge.setSessionModel( + session.sessionId, + { sessionId: session.sessionId, modelId: 'qwen-plus' }, + undefined, + ); + // Now let A's status read resolve — it must detect the generation + // change, discard its (stale) read, and re-run. + releaseFirstStatus!(); + + const switches: string[] = []; + for await (const e of iter) { + if (e.type === 'model_switched') { + switches.push((e.data as { modelId: string }).modelId); + if (switches.includes('qwen-turbo')) break; + } + } + // The two requested changes, then ONE corrective from the rerun. + expect(switches).toEqual(['qwen-max', 'qwen-plus', 'qwen-turbo']); + // Two reads total: the gated (discarded) one + the rerun. + expect(statusReads).toBe(2); + abort.abort(); + await bridge.shutdown(); + }); + + it('publishes a corrective approval_mode_changed when the agent mode drifted from cache', async () => { + // approvalMode analog of the model drift test. The bridge sets YOLO, + // but the agent's real mode is `plan` (e.g. an agent-side exit_plan_mode + // restore). Reconciliation reads `state.modes.currentModeId` — a + // DIFFERENT status shape from the model branch — and must emit a + // corrective approval_mode_changed with next:'plan' so peers converge + // on the agent's truth. + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent({ + extMethodImpl: (method, params) => { + if (method === 'qwen/control/session/approval_mode') { + return Promise.resolve({ + previous: 'default', + current: (params as { mode: string }).mode, + }); + } + if (method === 'qwen/status/session/context') { + return Promise.resolve({ + state: { modes: { currentModeId: 'plan' } }, + }); + } + return Promise.resolve({}); + }, + }); + new AgentSideConnection(() => fakeAgent as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + await bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.YOLO, + { persist: false }, + undefined, + ); + + const nexts: string[] = []; + for await (const e of iter) { + if (e.type === 'approval_mode_changed') { + nexts.push((e.data as { next: string }).next); + if (nexts.length === 2) break; + } + } + // First the requested change, then the corrective one from reconcile. + expect(nexts[0]).toBe('yolo'); + expect(nexts[1]).toBe('plan'); + abort.abort(); + await bridge.shutdown(); + }); + + it('does NOT reconcile when the approval-mode roundtrip itself fails', async () => { + // approvalMode analog of the model roundtrip-fail test. The agent's + // approval_mode ext rejects, so publishApprovalModeChanged never runs + // and the cache is unchanged. Reconciliation must be skipped (no status + // read) and no corrective approval_mode_changed must reach the bus. + let statusReads = 0; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent({ + extMethodImpl: (method) => { + if (method === 'qwen/control/session/approval_mode') { + throw new Error('agent refused approval-mode switch'); + } + if (method === 'qwen/status/session/context') { + statusReads += 1; + return Promise.resolve({ + state: { modes: { currentModeId: 'plan' } }, + }); + } + return Promise.resolve({}); + }, + }); + new AgentSideConnection(() => fakeAgent as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const nexts: string[] = []; + const collecting = (async () => { + for await (const e of iter) { + if (e.type === 'approval_mode_changed') { + nexts.push((e.data as { next: string }).next); + } + } + })(); + + await expect( + bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.YOLO, + { persist: false }, + undefined, + ), + ).rejects.toThrow(); + + // Give any (incorrectly) scheduled reconcile a tick to fire. + await new Promise((r) => setTimeout(r, 10)); + expect(statusReads).toBe(0); + expect(nexts).toEqual([]); + abort.abort(); + await collecting; + await bridge.shutdown(); + }); + + it('drops unknown agent-returned approval mode without publishing a corrective event', async () => { + // F7qEL / F8E2h: when the agent returns a mode not in + // KNOWN_APPROVAL_MODES, the approvalMode reconcile branch should + // drop it (action=dropped reason=unknown_mode) instead of + // broadcasting an invalid approval_mode_changed. We trigger the + // approvalMode reconcile via setSessionApprovalMode (not via + // modelServiceId, which only reconciles the model branch). + let statusReads = 0; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent({ + extMethodImpl: (method, params) => { + if (method === 'qwen/control/session/approval_mode') { + return Promise.resolve({ + previous: 'default', + current: (params as { mode: string }).mode, + }); + } + if (method === 'qwen/status/session/context') { + statusReads += 1; + // Agent claims a mode that's NOT in KNOWN_APPROVAL_MODES. + return Promise.resolve({ + state: { modes: { currentModeId: 'super-yolo' } }, + }); + } + return Promise.resolve({}); + }, + }); + new AgentSideConnection(() => fakeAgent as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const modeEvents: string[] = []; + const collecting = (async () => { + for await (const e of iter) { + if (e.type === 'approval_mode_changed') { + modeEvents.push((e.data as { next: string }).next); + } + } + })(); + + // setSessionApprovalMode triggers reconcileAfterRoundtrip(entry, + // 'approvalMode'). The status read returns 'super-yolo' which + // isn't in KNOWN_APPROVAL_MODES — reconcile must DROP it. + await bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.YOLO, + { persist: false }, + undefined, + ); + + // Wait for reconcile to fire (async microtask chain). + await new Promise((r) => setTimeout(r, 50)); + // Positive assertion: reconcile DID execute (status was read). + // Without this, a future refactor that disables reconcile would + // make the modeEvents assertion pass vacuously. + expect(statusReads).toBe(1); + // Only the original mode change should appear — no corrective + // for the unknown 'super-yolo' value from the agent. + expect(modeEvents).toEqual(['yolo']); + abort.abort(); + await collecting; + await bridge.shutdown(); + }); + + it('syncs peer session cache on persisted approval-mode change (snapshot reflects new mode)', async () => { + // F7qEK: when session A persists a mode change, peer session B's + // cache should be updated so a subsequent snapshot on B reports + // the new workspace default — not the stale pre-change value. + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent({ + extMethodImpl: (method, params) => { + if (method === 'qwen/control/session/approval_mode') { + return Promise.resolve({ + previous: 'default', + current: (params as { mode: string }).mode, + }); + } + if (method === 'qwen/status/session/context') { + // Status RPC returns agent's authoritative mode. After the + // persist, the agent is on 'yolo' — return it so reconcile + // sees no drift and does not emit a corrective. + return Promise.resolve({ + state: { modes: { currentModeId: 'yolo' } }, + }); + } + return Promise.resolve({}); + }, + }); + new AgentSideConnection(() => fakeAgent as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ + channelFactory: factory, + sessionScope: 'thread', + persistApprovalMode: async () => {}, + }); + // Two sessions in the same workspace (thread scope → each attach + // creates a new session). + const sessionA = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const sessionB = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(sessionA.sessionId).not.toBe(sessionB.sessionId); + + // Persist a mode change on A. + await bridge.setSessionApprovalMode( + sessionA.sessionId, + ApprovalMode.YOLO, + { persist: true }, + undefined, + ); + + // Subscribe on B with snapshot — should reflect the persisted mode. + const abort = new AbortController(); + const iter = bridge.subscribeEvents(sessionB.sessionId, { + signal: abort.signal, + snapshot: true, + }); + const it2 = iter[Symbol.asyncIterator](); + const first = await it2.next(); + expect(first.value?.type).toBe('session_snapshot'); + expect( + (first.value?.data as { currentApprovalMode: string | null }) + .currentApprovalMode, + ).toBe('yolo'); + abort.abort(); + await bridge.shutdown(); + }); + }); +}); + +describe('channelIdleTimeoutMs', () => { + it('kills the channel immediately when timeout is 0 (default)', async () => { + const handle = makeChannel(); + const factory: ChannelFactory = async () => handle.channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + expect(bridge.sessionCount).toBe(1); + await bridge.closeSession(session.sessionId); + expect(bridge.sessionCount).toBe(0); + expect(handle.killed).toBe(true); + await bridge.shutdown(); + }); + + it('reuses warm channel during idle window when timeout > 0', async () => { + let factoryCalls = 0; + const factory: ChannelFactory = async () => { + factoryCalls++; + return makeChannel().channel; + }; + const bridge = makeBridge({ + channelFactory: factory, + channelIdleTimeoutMs: 60_000, + }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + expect(factoryCalls).toBe(1); + + await bridge.closeSession(session.sessionId); + + const session2 = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + expect(factoryCalls).toBe(1); + expect(bridge.sessionCount).toBe(1); + + await bridge.closeSession(session2.sessionId); + await bridge.shutdown(); + }); + + it('kills channel after idle timeout expires (fake timers)', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + let factoryCalls = 0; + const factory: ChannelFactory = async () => { + factoryCalls++; + return handle.channel; + }; + const bridge = makeBridge({ + channelFactory: factory, + channelIdleTimeoutMs: 5_000, + }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + expect(factoryCalls).toBe(1); + + await bridge.closeSession(session.sessionId); + expect(handle.killed).toBe(false); + + await vi.advanceTimersByTimeAsync(4_999); + expect(handle.killed).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(handle.killed).toBe(true); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('cancels idle timer when new session arrives', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + let factoryCalls = 0; + const factory: ChannelFactory = async () => { + factoryCalls++; + return handle.channel; + }; + const bridge = makeBridge({ + channelFactory: factory, + channelIdleTimeoutMs: 5_000, + }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + await bridge.closeSession(session.sessionId); + + await vi.advanceTimersByTimeAsync(3_000); + expect(handle.killed).toBe(false); + + const session2 = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + expect(factoryCalls).toBe(1); + + await vi.advanceTimersByTimeAsync(5_000); + expect(handle.killed).toBe(false); + + await bridge.closeSession(session2.sessionId); + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('preheat', () => { + it('spawns channel that is reused by first session', async () => { + let factoryCalls = 0; + const factory: ChannelFactory = async () => { + factoryCalls++; + return makeChannel().channel; + }; + const bridge = makeBridge({ channelFactory: factory }); + await bridge.preheat(); + expect(factoryCalls).toBe(1); + + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + expect(session.sessionId).toBeDefined(); + expect(factoryCalls).toBe(1); + expect(bridge.sessionCount).toBe(1); + + await bridge.closeSession(session.sessionId); + await bridge.shutdown(); + }); + + it('is a no-op after shutdown', async () => { + let factoryCalls = 0; + const factory: ChannelFactory = async () => { + factoryCalls++; + return makeChannel().channel; + }; + const bridge = makeBridge({ channelFactory: factory }); + await bridge.shutdown(); + await bridge.preheat(); + expect(factoryCalls).toBe(0); + }); + + it('arms idle timer on preheated channel when channelIdleTimeoutMs > 0', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + let factoryCalls = 0; + const factory: ChannelFactory = async () => { + factoryCalls++; + return handle.channel; + }; + const bridge = makeBridge({ + channelFactory: factory, + channelIdleTimeoutMs: 5_000, + }); + await bridge.preheat(); + expect(factoryCalls).toBe(1); + expect(handle.killed).toBe(false); + + // First session cancels the preheat idle timer and reuses channel + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + expect(factoryCalls).toBe(1); + + // Advance past preheat timer — channel should survive (timer cancelled) + await vi.advanceTimersByTimeAsync(6_000); + expect(handle.killed).toBe(false); + + // Close session — new idle timer starts + await bridge.closeSession(session.sessionId); + expect(handle.killed).toBe(false); + + await vi.advanceTimersByTimeAsync(5_000); + expect(handle.killed).toBe(true); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Session idle reaper +// --------------------------------------------------------------------------- +describe('session idle reaper', () => { + it('reaps an orphaned session whose client crashed (no detach sent)', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionReapIntervalMs: 1_000, + sessionIdleTimeoutMs: 5_000, + }); + await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + expect(bridge.sessionCount).toBe(1); + + // Simulate client crash: client never sends detach, but SSE + // dropped and no heartbeat. clientIds still > 0 — only the + // reaper can catch this. + await vi.advanceTimersByTimeAsync(6_000); + expect(bridge.sessionCount).toBe(0); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('does NOT reap a session with an active prompt and client', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel({ + promptImpl: () => new Promise(() => {}), + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionReapIntervalMs: 1_000, + sessionIdleTimeoutMs: 2_000, + }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + + const promptPromise = bridge + .sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'hi' }], + }) + .catch(() => {}); + await vi.waitFor(() => { + expect(handle.agent.promptCalls).toHaveLength(1); + }); + + await vi.advanceTimersByTimeAsync(5_000); + expect(bridge.sessionCount).toBe(1); + + await bridge.shutdown(); + await promptPromise; + } finally { + vi.useRealTimers(); + } + }); + + it('does NOT reap a session with a live SSE subscriber', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionReapIntervalMs: 1_000, + sessionIdleTimeoutMs: 2_000, + }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + // Subscribe BEFORE detach so the subscriber keeps the session alive + const abort = new AbortController(); + bridge.subscribeEvents(session.sessionId, { signal: abort.signal }); + // Detach — close-on-last-detach checks subscriberCount > 0 → skips + await bridge.detachClient(session.sessionId, session.clientId); + expect(bridge.sessionCount).toBe(1); + + // Advance past idle timeout — subscriber still protects from reaper + await vi.advanceTimersByTimeAsync(5_000); + expect(bridge.sessionCount).toBe(1); + + // Drop the subscriber — reaper catches it on next tick + abort.abort(); + await vi.advanceTimersByTimeAsync(2_000); + expect(bridge.sessionCount).toBe(0); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('does NOT reap a session with an active prompt (no SSE, no heartbeat)', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel({ + promptImpl: () => new Promise(() => {}), + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionReapIntervalMs: 1_000, + sessionIdleTimeoutMs: 2_000, + }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + const promptPromise = bridge + .sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'hi' }], + }) + .catch(() => {}); + await vi.waitFor(() => { + expect(handle.agent.promptCalls).toHaveLength(1); + }); + + // No subscriber, client registered but prompt active → reaper skips + await vi.advanceTimersByTimeAsync(5_000); + expect(bridge.sessionCount).toBe(1); + + await bridge.shutdown(); + await promptPromise; + } finally { + vi.useRealTimers(); + } + }); + + it('is disabled when sessionReapIntervalMs is 0', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionReapIntervalMs: 0, + sessionIdleTimeoutMs: 1_000, + }); + await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + + await vi.advanceTimersByTimeAsync(10_000); + expect(bridge.sessionCount).toBe(1); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('is disabled when sessionIdleTimeoutMs is 0', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionReapIntervalMs: 1_000, + sessionIdleTimeoutMs: 0, + }); + await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + + await vi.advanceTimersByTimeAsync(10_000); + expect(bridge.sessionCount).toBe(1); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('publishes session_closed with reason idle_timeout via closeSession opts', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + + const events: BridgeEvent[] = []; + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const reading = (async () => { + for await (const ev of iter) { + events.push(ev); + if (ev.type === 'session_closed') { + abort.abort(); + break; + } + } + })(); + + await bridge.closeSession(session.sessionId, undefined, { + reason: 'idle_timeout', + }); + await reading; + const closedEv = events.find((e) => e.type === 'session_closed'); + expect(closedEv).toBeDefined(); + expect((closedEv!.data as { reason: string }).reason).toBe('idle_timeout'); + + await bridge.shutdown(); + }); + + it('closeSession defaults to reason client_close', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + + const events: BridgeEvent[] = []; + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const reading = (async () => { + for await (const ev of iter) { + events.push(ev); + if (ev.type === 'session_closed') { + abort.abort(); + break; + } + } + })(); + + await bridge.closeSession(session.sessionId); + await reading; + const closedEv = events.find((e) => e.type === 'session_closed'); + expect(closedEv).toBeDefined(); + expect((closedEv!.data as { reason: string }).reason).toBe('client_close'); + + await bridge.shutdown(); + }); + + it('reaps multiple orphaned sessions in one tick', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionReapIntervalMs: 1_000, + sessionIdleTimeoutMs: 3_000, + }); + await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + expect(bridge.sessionCount).toBe(3); + + // No detach — simulates client crash. Reaper catches all 3. + await vi.advanceTimersByTimeAsync(4_000); + expect(bridge.sessionCount).toBe(0); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('session with recent heartbeat survives reaper', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionReapIntervalMs: 1_000, + sessionIdleTimeoutMs: 5_000, + }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + + // No detach — simulates a crashed client that still sends heartbeats + // (e.g. a headless API client with a keepalive loop). + await vi.advanceTimersByTimeAsync(4_000); + bridge.recordHeartbeat(session.sessionId); + expect(bridge.sessionCount).toBe(1); + + await vi.advanceTimersByTimeAsync(4_000); + expect(bridge.sessionCount).toBe(1); + + await vi.advanceTimersByTimeAsync(2_000); + expect(bridge.sessionCount).toBe(0); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('reaper is stopped on shutdown (no post-shutdown errors)', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionReapIntervalMs: 1_000, + sessionIdleTimeoutMs: 2_000, + }); + await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + + await bridge.shutdown(); + + await vi.advanceTimersByTimeAsync(10_000); + } finally { + vi.useRealTimers(); + } + }); + + it('triggers channel idle timer after reaping the last session', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionReapIntervalMs: 1_000, + sessionIdleTimeoutMs: 3_000, + channelIdleTimeoutMs: 2_000, + }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + await bridge.detachClient(session.sessionId, session.clientId); + + // Close-on-last-detach fires immediately — session gone + expect(bridge.sessionCount).toBe(0); + // Channel should still be alive — channelIdleTimeoutMs grace + expect(handle.killed).toBe(false); + + // Channel idle timer fires after 2s + await vi.advanceTimersByTimeAsync(2_000); + expect(handle.killed).toBe(true); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Close on last client detach +// --------------------------------------------------------------------------- +describe('close on last client detach', () => { + it('closes the session when the last client detaches', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionReapIntervalMs: 0, + }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + expect(bridge.sessionCount).toBe(1); + + await bridge.detachClient(session.sessionId, session.clientId); + expect(bridge.sessionCount).toBe(0); + + await bridge.shutdown(); + }); + + it('does NOT close when other clients remain', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionReapIntervalMs: 0, + }); + const s1 = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'single', + }); + const s2 = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'single', + }); + expect(s2.attached).toBe(true); + expect(bridge.sessionCount).toBe(1); + + await bridge.detachClient(s1.sessionId, s1.clientId); + expect(bridge.sessionCount).toBe(1); + + await bridge.detachClient(s2.sessionId, s2.clientId); + expect(bridge.sessionCount).toBe(0); + + await bridge.shutdown(); + }); + + it('closes immediately on last detach (session removed from byId)', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionReapIntervalMs: 0, + }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + expect(bridge.sessionCount).toBe(1); + + // Last client detaches — session closed immediately, no reaper needed + await bridge.detachClient(session.sessionId, session.clientId); + expect(bridge.sessionCount).toBe(0); + + // Session is gone from bridge but getHeartbeatState returns undefined + expect(bridge.getHeartbeatState(session.sessionId)).toBeUndefined(); + + await bridge.shutdown(); + }); +}); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts new file mode 100644 index 0000000000..8a22d25dd4 --- /dev/null +++ b/packages/acp-bridge/src/bridge.ts @@ -0,0 +1,4684 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import * as path from 'node:path'; +import { + ClientSideConnection, + PROTOCOL_VERSION, +} from '@agentclientprotocol/sdk'; +import type { + CancelNotification, + PromptRequest, + SetSessionModelRequest, + SetSessionModelResponse, +} from '@agentclientprotocol/sdk'; +import type { ApprovalMode } from '@qwen-code/qwen-code-core'; +import { + DAEMON_TRACEPARENT_META_KEY, + DAEMON_TRACESTATE_META_KEY, + TrustGateError, + ShellExecutionService, + type ShellOutputEvent, +} from '@qwen-code/qwen-code-core'; +import type { ShellCommandResult } from './bridgeTypes.js'; +import type { AcpChannel } from './channel.js'; +import { + EventBus, + DEFAULT_RING_SIZE, + EVENT_SCHEMA_VERSION, + type BridgeEvent, +} from './eventBus.js'; +import { TurnBoundaryCompactionEngine } from './compactionEngine.js'; +import { + BridgeChannelClosedError, + BridgeTimeoutError, + createIdleWorkspaceExtensionsStatus, + createIdleWorkspaceHooksStatus, + SERVE_CONTROL_EXT_METHODS, + SERVE_STATUS_EXT_METHODS, + STATUS_SCHEMA_VERSION, + type ServeSessionStatsStatus, + type ServeSessionContextStatus, + type ServeSessionTasksStatus, +} from './status.js'; +import { + BranchWhilePromptActiveError, + SessionNotFoundError, + RestoreInProgressError, + InvalidSessionScopeError, + SessionLimitExceededError, + WorkspaceMismatchError, + InvalidClientIdError, + // Mediator's `vote()` validates `optionId in allowedOptionIds`, + // but the bridge ALSO throws `InvalidPermissionOptionError` + // pre-mediator when a wire client tries to inject the cancel + // sentinel via a `selected` outcome — without this guard, a + // wire-supplied `optionId === CANCEL_VOTE_SENTINEL` would + // short-circuit all policy dispatch. + InvalidPermissionOptionError, + InvalidSessionMetadataError, + isNotCurrentlyGeneratingCancelError, + SessionBusyError, + InvalidRewindTargetError, +} from './bridgeErrors.js'; +import { canonicalizeWorkspace } from './workspacePaths.js'; +import type { + BridgeSession, + BridgeRestoreSessionRequest, + BridgeSessionState, + BridgeRestoredSession, + BridgeSessionSummary, + BridgeClientRequestContext, + CloseSessionOpts, + AcpSessionBridge, +} from './bridgeTypes.js'; +import type { BridgeOptions, BridgeTelemetry } from './bridgeOptions.js'; +import { MCP_RESTART_SERVER_DEADLINE_MS } from './mcpTimeouts.js'; +import { defaultSpawnChannelFactory } from './spawnChannel.js'; +import { writeStderrLine } from './internal/stderrLine.js'; +import { BridgeClient, KNOWN_APPROVAL_MODES } from './bridgeClient.js'; +import { + CANCEL_VOTE_SENTINEL, + createNoOpPermissionAuditPublisher, + MultiClientPermissionMediator, + type PermissionAuditPublisher, +} from './permissionMediator.js'; +import { PermissionForbiddenError } from './bridgeErrors.js'; + +const NOOP_BRIDGE_TELEMETRY: BridgeTelemetry = { + captureContext: () => undefined, + runWithContext(_captured, fn) { + return fn(); + }, + withSpan(_operation, _attributes, fn) { + return fn(); + }, + event() {}, + injectPromptContext(request) { + const meta = (request as { _meta?: unknown })._meta; + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { + return request; + } + const record = meta as Record; + if ( + !(DAEMON_TRACEPARENT_META_KEY in record) && + !(DAEMON_TRACESTATE_META_KEY in record) + ) { + return request; + } + const nextMeta = { ...record }; + delete nextMeta[DAEMON_TRACEPARENT_META_KEY]; + delete nextMeta[DAEMON_TRACESTATE_META_KEY]; + return { ...request, _meta: nextMeta }; + }, +}; + +/** + * Stage 1 HTTP->ACP bridge factory + supporting helpers. + * + * Architecture: + * - **1 daemon = 1 workspace**: every bridge instance is bound to a + * single canonical workspace path at construction + * (`BridgeOptions.boundWorkspace`). All `spawnOrAttach` calls must + * target that workspace; cross-workspace requests throw + * `WorkspaceMismatchError`. Multi-workspace deployments use multiple + * daemon processes (one per workspace, supervised externally). + * - One `qwen --acp` child total; multiple sessions multiplex onto it + * via `connection.newSession()`. Sessions share the child's process / + * OAuth state / `FileReadCache` / hierarchy-memory parse. + * - HTTP request bodies are forwarded as ACP NDJSON over the child's stdin. + * - Child stdout NDJSON notifications publish onto each session's + * `EventBus`; HTTP SSE subscribers (`GET /session/:id/events`) drain + * it. Cross-client fan-out + `Last-Event-ID` reconnect supported. + * - Multi-client requests against the same session serialize through this + * bridge (FIFO; honors ACP's "one active prompt per session" invariant). + * Different sessions on the same channel can prompt concurrently — + * the ACP layer demultiplexes by sessionId. + * + * Stage 2 replaces the spawn step with an in-process call into core's + * ACP-equivalent API. The `AcpSessionBridge` interface stays the same so HTTP + * route handlers don't need to change. + */ + +interface ChannelInfo { + channel: AcpChannel; + connection: ClientSideConnection; + /** Shared BridgeClient — its methods route ACP params by sessionId. */ + client: BridgeClient; + // Under "1 daemon = 1 workspace" the module-scope `boundWorkspace` + // is the single source of truth and every channel inherits it. + // Per-channel storage would suggest variance the model doesn't + // allow; keeping it out makes the single-workspace invariant visible + // at the type level. + /** + * Live session ids multiplexed on this channel. Updated when + * `doSpawn` registers a new session and when `killSession` / + * `channel.exited` removes one. When the set drops to empty under + * `killSession`, the channel is marked `isDying = true` and its + * `channel.kill()` is awaited; `channelInfo` itself is left + * pointing at the dying channel until `channel.exited` fires (see + * BkUyD invariant on `isDying` below). + */ + sessionIds: Set; + /** + * Restore calls currently executing on this channel but not yet registered + * in `sessionIds`. Used to avoid killing the shared channel when one pending + * restore fails while another is still healthy. + */ + pendingRestoreIds: Set; + /** + * Cached channel-close race for workspace-scoped status requests. Workspace + * status can be polled frequently by dashboards, so keep one promise per + * channel instead of attaching a new `.then()` to `channel.exited` per poll. + */ + statusClosedReject?: Promise; + /** + * MUST be set to `true` synchronously by any teardown path BEFORE + * awaiting `channel.kill()`. `ensureChannel` treats a dying channel + * as absent and spawns a fresh one — without this flag a concurrent + * `spawnOrAttach` arriving during the SIGTERM grace window (up to + * 10s) would attach to a transport about to close, landing the + * caller with a sessionId that 404s on every follow-up request. + * + * **Set-sites (5)** — any new teardown path MUST call into one of + * these or replicate the pattern: + * + * 1. `ensureChannel`: `initialize`-failure catch. + * 2. `ensureChannel`: late-shutdown re-check (shuttingDown flipped + * during handshake). + * 3. `doSpawn`: newSession-failure on an empty channel + * (sessionIds.size === 0). + * 4. `killSession`: last session leaving (sessionIds.size === 0 + * after the delete). + * 5. `shutdown`: bulk-mark every entry in `aliveChannels`. + * + * **BkUyD invariant (why we don't clear `channelInfo` here)**: + * `killAllSync` must still find the channel during the SIGTERM + * grace window to fire SIGKILL on `process.exit(1)`. `aliveChannels` + * holds the dying entry until `channel.exited` fires (OS-level + * reap); `isDying` is the "available-for-new-spawns" half of the + * two-bit (alive, dying) state. + */ + isDying: boolean; + handshakeComplete: boolean; +} + +interface SessionEntry { + sessionId: string; + workspaceCwd: string; + createdAt: string; + displayName?: string; + channel: AcpChannel; + connection: ClientSideConnection; + /** Per-session event bus drives `GET /session/:id/events`. */ + events: EventBus; + /** + * Tail of the per-session prompt queue. Each new prompt chains off the + * resolved (or rejected) state of this promise so prompts run one at a + * time in arrival order. Always resolves — failures are swallowed at the + * tail so a prior failure doesn't block subsequent prompts; the original + * caller still observes the rejection on its own returned promise. + */ + promptQueue: Promise; + /** + * Per-session model-change FIFO. Prevents two concurrent + * `applyModelServiceId` calls (e.g. simultaneous attach-with-different- + * model requests) from racing into `unstable_setSessionModel` and + * leaving the agent in non-deterministic state. Always resolves — + * failures swallowed at the tail like `promptQueue`. + */ + modelChangeQueue: Promise; + /** + * True while the bridge is driving a model roundtrip + * (`setSessionModel` / `applyModelServiceId`) for this session. The + * `current_model_update` extNotification demux in `BridgeClient` reads this + * to SUPPRESS promotion of the agent's notification during a bridge-driven + * change — the bridge publishes the authoritative `model_switched` itself, + * so promoting the notification too would double-publish. In-session + * `/model` (no bridge roundtrip) sees this false and IS promoted. + */ + modelRoundtripInFlight?: boolean; + /** A2: true while the bridge drives an approval-mode roundtrip. */ + approvalModeRoundtripInFlight?: boolean; + /** §2.3: cached model id, updated by every `publishModelSwitched` call. */ + currentModelId?: string; + /** §2.3: cached approval mode, updated by every `publishApprovalModeChanged` call. */ + currentApprovalMode?: string; + /** §2.3: monotonic counter bumped on every `model_switched` publish. */ + modelPublishGeneration: number; + /** §2.3: monotonic counter bumped on every `approval_mode_changed` publish. */ + approvalModePublishGeneration: number; + /** §2.2: true while a model reconciliation read is in flight. */ + modelReconciliationInFlight?: boolean; + /** §2.2: true while an approval-mode reconciliation read is in flight. */ + approvalModeReconciliationInFlight?: boolean; + /** + * Per-session approval-mode FIFO. Mirrors `modelChangeQueue`: + * serializes concurrent `setSessionApprovalMode` calls so two + * `POST /session/:id/approval-mode` can't race their ACP roundtrip + * + persist and publish an `approval_mode_changed` event whose + * `next` mode disagrees with the mode the ACP child actually settled + * on. Always resolves — failures swallowed at the tail like + * `modelChangeQueue`. + */ + approvalModeQueue: Promise; + /** + * Cached "transport closed" promise. The first `sendPrompt` on a + * session lazy-builds this from `channel.exited.then(throw)`; every + * subsequent prompt's race uses the SAME promise so the listener + * count on `channel.exited` stays at one regardless of how many + * prompts run on the session over its lifetime. + */ + transportClosedReject?: Promise; + /** + * Permission requestIds belonging to this session, kept so cancelSession + * + shutdown can resolve them as `cancelled` per ACP requirement + * (cancelled prompt MUST resolve outstanding requestPermission with + * outcome.cancelled). + */ + pendingPermissionIds: Set; + /** + * Daemon-issued client ids currently known for this live session. HTTP + * clients may echo one through `X-Qwen-Client-Id`; the bridge only treats + * it as trusted originator metadata if it appears in this set. + */ + clientIds: Map; + /** + * Originator for the prompt currently running on this session. ACP enforces + * one active prompt per session, and this bridge FIFO-serializes prompts, so + * inline session updates / permission requests can safely inherit this id. + */ + activePromptOriginatorClientId?: string; + /** True while a prompt is executing on the FIFO, regardless of whether + * an originator clientId is known. Used by the session reaper to avoid + * killing sessions mid-prompt. */ + promptActive: boolean; + /** + * Per-prompt "already broadcast `prompt_cancelled`" latch. The explicit + * `cancelSession` route and the `sendPrompt` abort path (originator SSE + * drop) can both fire for the same active prompt — e.g. a client POSTs + * /cancel then immediately closes its socket. Without dedup, peers + * receive two `prompt_cancelled` frames for one turn. Reset to `false` + * when the **next prompt starts** (the latch is per-prompt); set `true` + * on the first broadcast. + */ + cancelBroadcast?: boolean; + /** + * Count of times `spawnOrAttach` has returned `attached: true` for + * this entry — i.e. a second-or-subsequent client claimed this + * session under `sessionScope: 'single'`. Used by the disconnect- + * reaper in `server.ts`: if the spawn-owner client disconnected + * during the spawn handshake but another client has already + * attached, the reaper must NOT tear the session down. The + * increment + the killSession-skip-check both happen in the + * synchronous portion of their respective async functions, so the + * counter is observed atomically across the awaiting boundary. + */ + attachCount: number; + /** + * BkwQP: tombstone for the spawn-owner-disconnect path. When the + * spawn owner's HTTP response can't be written and they call + * `killSession({ requireZeroAttaches: true })` but the bail + * triggers (because some other client already bumped + * `attachCount`), set this flag — it remembers the spawn owner + * wanted the session reaped. A later `detachClient()` that brings + * `attachCount` back to 0 then completes the deferred reap. Stays + * `false` for sessions the spawn owner never tried to kill, so + * `detachClient` of a transient attach doesn't reap a still-valid + * session. + */ + spawnOwnerWantedKill: boolean; + /** + * ACP state captured at `session/load` / `session/resume` time so + * late attachers (existing-byId early-return + coalesced restore + * waiters) get the same payload the original restore caller did. + * `undefined` for sessions created via `doSpawn` — those have never + * had an ACP load/resume response, so attaches return `state: {}`. + */ + restoreState?: BridgeSessionState; + /** + * Most recent heartbeat across any client on this session (Date.now() + * epoch ms). Set on every `recordHeartbeat` call regardless of whether + * the caller identified themselves; consumed by diagnostics and + * revocation policy. Undefined until the first heartbeat lands. + */ + sessionLastSeenAt?: number; + /** + * Per-`clientId` last heartbeat (Date.now() epoch ms). Only populated + * when the heartbeat carried a trusted `X-Qwen-Client-Id`. Entries are + * dropped together with the parent session — revocation policy will + * own per-client eviction. + */ + clientLastSeenAt: Map; +} + +function isServeDebugLoggingEnabled(): boolean { + const value = process.env['QWEN_SERVE_DEBUG']; + if (!value) return false; + return !['0', 'false', 'off', 'no'].includes(value.trim().toLowerCase()); +} + +function writeServeDebugLine(message: string): void { + if (!isServeDebugLoggingEnabled()) return; + writeStderrLine(`qwen serve debug: ${message}`); +} + +const MAX_DISPLAY_NAME_LENGTH = 256; + +/** + * Upper bound on how many prompt content blocks the bridge echoes per + * prompt. A programmatically-generated prompt with thousands of small + * blocks would otherwise trigger thousands of synchronous `publish()` + * fan-outs (each up to the per-bus subscriber cap) and flood the + * replay ring, evicting real history for every SSE subscriber. 256 is + * far above any human-authored prompt's block count. + */ +const MAX_ECHO_CONTENT_BLOCKS = 256; + +function extractPermissionResponseMetadata( + response: unknown, +): Readonly> | undefined { + if (response === null || typeof response !== 'object') return undefined; + // Keep this extension deliberately narrow. Today the only non-ACP field + // expected by the agent is AskUserQuestion's `answers` payload. + const answers = (response as { readonly answers?: unknown }).answers; + if ( + answers !== null && + typeof answers === 'object' && + !Array.isArray(answers) + ) { + const entries = Object.entries(answers as Record); + if (entries.every(([, v]) => typeof v === 'string')) { + return { answers }; + } + } + return undefined; +} + +/** + * Echo a user prompt to the session bus so multi-client SSE subscribers + * see the input alongside the agent response. Iterates content blocks + * and emits one `user_message_chunk` per block, mirroring the shape the + * agent itself emits in the cron path (`Session.ts` cron handler) and + * the history-replay path (`HistoryReplayer`). The regular interactive + * `Session#executePrompt` was the historical outlier — it forwarded + * the prompt straight to the LLM without going through the session bus. + * + * Originator dedup: SDK consumers using `normalizeDaemonEvent` with + * `suppressOwnUserEcho: true` skip the echo for the originator (the + * envelope-level `originatorClientId` matches their own clientId). + * + * Anonymous-prompt caveat: a stable `X-Qwen-Client-Id` is a PRECONDITION + * for that dedup. A prompt with no clientId (curl smoke / pre-registration + * script) produces an envelope without `originatorClientId`, so + * `suppressOwnUserEcho` has nothing to match and the originating connection + * sees its own input echoed back. This is an accepted edge for + * headless/anonymous callers; interactive multi-client UIs always carry a + * clientId and are unaffected. + * + * Source marker: `_meta.source: 'bridge-echo'` lets downstream tooling + * distinguish bridge-synthesized echoes from agent-emitted content if + * needed (e.g., for replay-deduplication when the agent later catches + * up and emits the same chunk through `HistoryReplayer`). + */ +function echoPromptToSessionBus( + entry: SessionEntry, + req: PromptRequest, + originatorClientId: string | undefined, +): void { + // `PromptRequest.prompt` is a non-optional `ContentBlock[]` per the + // ACP type contract — read it directly so a future SDK bump that + // makes it optional surfaces as a TypeScript error rather than being + // silently swallowed by an `unknown` cast. + // `PromptRequest.prompt` is typed as a non-optional `ContentBlock[]`, so + // TS guarantees the shape. The runtime `Array.isArray` guard (D6) is pure + // defense-in-depth for a malformed HTTP body that slips past the type + // contract — cheaper than a thrown `TypeError` mid-echo. + const prompt = req.prompt; + if (!Array.isArray(prompt) || prompt.length === 0) return; + const serverTimestamp = Date.now(); + const blockCount = Math.min(prompt.length, MAX_ECHO_CONTENT_BLOCKS); + for (let i = 0; i < blockCount; i += 1) { + const part = prompt[i]; + if (!part || typeof part !== 'object' || Array.isArray(part)) continue; + // Every `ContentBlock` variant (text, image, audio, resource) is + // published to the bus verbatim. The SDK's `normalizeDaemonEvent` + // accepts any `content` shape; rich rendering of non-text blocks is + // the consumer's responsibility. + try { + entry.events.publish({ + type: 'session_update', + data: { + sessionId: req.sessionId, + update: { + sessionUpdate: 'user_message_chunk', + content: part, + // `_meta` lives inside the `update` object rather than at + // envelope level. `_meta` is a standard JSON-RPC/MCP extension + // field permitted alongside spec fields, the SDK normalizer + // reads it from `update._meta`/`data._meta`, and every other + // agent-emitted session_update carries `_meta` the same way. + _meta: { serverTimestamp, source: 'bridge-echo' }, + }, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } catch { + // bus may be closed (session being torn down); ignore — the + // prompt forward still proceeds. + } + } +} + +/** + * Publish a `prompt_cancelled` event to the session bus so peer SSE + * subscribers observe the cancel as a first-class event instead of + * inferring it from the absence of further `agent_message_chunk` + * frames. + * + * Semantic: this signals **cancel REQUESTED**, not **cancel + * confirmed** — it's published before the ACP `cancel` notification is + * forwarded/awaited (so peers learn promptly even if the agent is slow + * to wind down or the channel is dead). If a consumer needs hard + * confirmation it should observe the subsequent terminal + * `tool_call_update` / `agent_message_chunk` quiescence. + * + * `originatorClientId` identifies the cancelling client. Used by both + * the explicit `cancelSession` route and the `sendPrompt` abort path + * (originator SSE disconnect) so neither cancel route is a silent gap. + */ +function broadcastPromptCancelled( + entry: SessionEntry, + sessionId: string, + originatorClientId: string | undefined, + reason?: 'forward_failed', +): void { + try { + entry.events.publish({ + type: 'prompt_cancelled', + data: { sessionId, ...(reason ? { reason } : {}) }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } catch { + /* bus closed */ + } +} + +/** + * Dedup wrapper around {@link broadcastPromptCancelled}. Broadcasts at + * most once per active prompt by latching `entry.cancelBroadcast`, so the + * `cancelSession` route and the `sendPrompt` abort path can't both emit a + * `prompt_cancelled` for a single turn (POST /cancel then socket close). + * The latch is reset when the next prompt starts. + */ +function broadcastPromptCancelledOnce( + entry: SessionEntry, + sessionId: string, + originatorClientId: string | undefined, + reason?: 'forward_failed', +): void { + if (entry.cancelBroadcast) { + writeStderrLine( + `broadcastPromptCancelledOnce: suppressed duplicate cancel for session ${sessionId} (latch already set)`, + ); + return; + } + entry.cancelBroadcast = true; + broadcastPromptCancelled(entry, sessionId, originatorClientId, reason); +} + +function broadcastTurnComplete( + entry: SessionEntry, + sessionId: string, + promptResult: { stopReason?: string; [k: string]: unknown }, + promptId: string | undefined, + originatorClientId: string | undefined, +): void { + entry.events.publish({ + type: 'turn_complete', + data: { + sessionId, + stopReason: promptResult.stopReason ?? 'end_turn', + ...(promptId ? { promptId } : {}), + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); +} + +/** + * Extract a human-readable message from an unknown error value. + * Handles Error instances, JSON-RPC error objects (`{ code, message, + * data: { details } }`, `{ data: { message } }`, or string `data`), and plain + * objects with a `message` property. + * JSON-RPC internal errors carry the generic `"Internal error"` as + * `message`; the actual detail often lives in `data.details` or + * provider-specific `data.message`. + */ +export function extractErrorMessage(err: unknown): string { + if (err instanceof Error) { + const data = (err as Error & { data?: unknown }).data; + const detail = extractJsonRpcErrorDetail(data); + return detail ?? err.message; + } + if (typeof err === 'object' && err !== null) { + const obj = err as Record; + const detail = extractJsonRpcErrorDetail(obj['data']); + if (detail) return detail; + const msg = obj['message']; + if (typeof msg === 'string') return msg; + } + return String(err); +} + +function extractJsonRpcErrorDetail(data: unknown): string | undefined { + if (typeof data === 'string' && data.length > 0) return data; + if (typeof data === 'object' && data !== null) { + const details = (data as Record)['details']; + if (typeof details === 'string' && details.length > 0) return details; + const message = (data as Record)['message']; + if (typeof message === 'string' && message.length > 0) return message; + } + return undefined; +} + +export function extractErrorCode(err: unknown): string | undefined { + if (typeof err !== 'object' || err === null || !('code' in err)) + return undefined; + const raw = (err as Record)['code']; + if (typeof raw === 'string') return raw; + if (typeof raw === 'number') return String(raw); + return undefined; +} + +function broadcastTurnError( + entry: SessionEntry, + sessionId: string, + err: unknown, + promptId: string | undefined, + originatorClientId: string | undefined, +): void { + const message = extractErrorMessage(err); + const code = extractErrorCode(err); + entry.events.publish({ + type: 'turn_error', + data: { + sessionId, + message, + ...(code ? { code } : {}), + ...(promptId ? { promptId } : {}), + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); +} + +function hasControlCharacter(value: string): boolean { + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code <= 0x1f || code === 0x7f) { + return true; + } + } + return false; +} + +const DEFAULT_INIT_TIMEOUT_MS = 10_000; +const PERSIST_TIMEOUT_MS = 5_000; +const MCP_RESTART_TIMEOUT_MS = 300_000; +const MCP_OAUTH_TIMEOUT_MS = 600_000; +/** + * Backstop timeout for `qwen/control/session/recap`. The underlying + * side-query is single-attempt with `maxOutputTokens: 300`, so a + * healthy call finishes in 1–5 seconds; we cap at 60s to absorb model- + * provider hiccups without inheriting the 10s `initTimeoutMs` default + * (which would false-fire on any GPT-style slow start). The race is a + * safety net against a wedged ACP channel — there is no HTTP-side + * disconnect cancellation in v1 (see server.ts route comment). + */ +const SESSION_RECAP_TIMEOUT_MS = 60_000; +const SESSION_BTW_TIMEOUT_MS = 60_000; +const SHELL_COMMAND_TIMEOUT_MS = 120_000; +const MAX_SHELL_OUTPUT_FOR_HISTORY = 10_000; +const DEFAULT_MAX_SESSIONS = 20; +/** + * Soft upper bound on `BridgeOptions.eventRingSize` to catch operator + * typos before they OOM the daemon. At ~500 B per `BridgeEvent` an + * 1 000 000-frame ring already pins ~500 MB per session — well past + * any realistic workload. Not a security boundary (the flag is + * operator-controlled), just typo defense. + */ +const MAX_EVENT_RING_SIZE = 1_000_000; +// Bd1yh: per-permission-request wall clock. Without this, an agent +// calling `requestPermission` while no SSE subscriber is connected +// would hang the per-session FIFO promptQueue forever (the prompt +// can't complete, every subsequent prompt is blocked behind it). +// 5 minutes is generous for "human reads UI, decides, clicks +// approve" while still bounded enough to recover from a wedged +// state. Configurable via `BridgeOptions.permissionResponseTimeoutMs`. +const DEFAULT_PERMISSION_TIMEOUT_MS = 5 * 60 * 1000; +// Bd1z5: per-session cap on pending permissions in flight. A chatty +// agent making rapid `requestPermission` calls would otherwise grow +// `pendingPermissions` unboundedly — each entry is a UUID + closure +// + bus event. 64 mirrors `DEFAULT_MAX_SUBSCRIBERS` (one pending +// per subscriber feels like a reasonable headroom). Excess requests +// resolve as cancelled and emit a stderr warning so operators see +// the limit being hit. Configurable via +// `BridgeOptions.maxPendingPermissionsPerSession`. +const DEFAULT_MAX_PENDING_PER_SESSION = 64; +const DEFAULT_SESSION_REAP_INTERVAL_MS = 60_000; +const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 30 * 60_000; + +export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { + const defaultSessionScope = opts.sessionScope ?? 'single'; + // `undefined` → default 20 (intentionally tight to avoid resource cliffs). + // `0` → explicitly unlimited (operator opt-out). + // `Infinity` → unlimited (programmatic opt-out — accepted as a + // long-standing alias since the cap check is `>= max`). + // `NaN` / negative → throw. A typo / parse error in CLI/config + // silently disabling the daemon's only resource + // guard is fail-OPEN behavior — we'd rather fail + // boot than serve unbounded. + let maxSessions: number; + if (opts.maxSessions === undefined) { + maxSessions = DEFAULT_MAX_SESSIONS; + } else if (Number.isNaN(opts.maxSessions)) { + throw new TypeError( + `Invalid maxSessions: NaN. Must be a number >= 0 ` + + `(0 / Infinity = unlimited).`, + ); + } else if (opts.maxSessions < 0) { + throw new TypeError( + `Invalid maxSessions: ${opts.maxSessions}. Must be >= 0 ` + + `(0 / Infinity = unlimited).`, + ); + } else if (opts.maxSessions === 0 || opts.maxSessions === Infinity) { + maxSessions = Infinity; + } else { + maxSessions = opts.maxSessions; + } + if (defaultSessionScope !== 'single' && defaultSessionScope !== 'thread') { + throw new TypeError( + `Invalid sessionScope: ${JSON.stringify(defaultSessionScope)}. ` + + `Expected 'single' or 'thread'.`, + ); + } + // `eventRingSize` follows the same fail-CLOSED posture as + // `maxSessions`: silently disabling SSE backpressure on a config + // typo is worse than failing to start. Unlike `maxSessions` there + // is NO unlimited sentinel — an unbounded ring would grow forever. + // Soft upper bound MAX_EVENT_RING_SIZE catches operator typos + // (`--event-ring-size 80000000` instead of `8000000`); at 1M + // frames × ~500 B/frame the per-session ceiling is already + // ~500 MB, well past any legitimate use. + const eventRingSize = opts.eventRingSize ?? DEFAULT_RING_SIZE; + // `Number.isInteger` already rejects NaN / Infinity / non-finite + // — no separate `Number.isFinite` guard needed. + if ( + !Number.isInteger(eventRingSize) || + eventRingSize < 1 || + eventRingSize > MAX_EVENT_RING_SIZE + ) { + throw new TypeError( + `Invalid eventRingSize: ${opts.eventRingSize}. ` + + `Must be a positive integer in [1, ${MAX_EVENT_RING_SIZE}].`, + ); + } + const channelFactory = opts.channelFactory ?? defaultSpawnChannelFactory; + // Close over a per-handle env-override snapshot. Calls to + // `channelFactory` at spawn time receive this as the 2nd arg, so + // the default factory can merge into the child env without + // consulting any global state that another concurrent + // `runQwenServe()` handle might have mutated. Frozen to make + // accidental mutation throw rather than silently corrupt later + // spawns. + const childEnvOverrides: Readonly> = + opts.childEnvOverrides + ? Object.freeze({ ...opts.childEnvOverrides }) + : Object.freeze({}); + const initTimeoutMs = opts.initializeTimeoutMs ?? DEFAULT_INIT_TIMEOUT_MS; + if (initTimeoutMs <= 0) { + throw new TypeError( + `Invalid initializeTimeoutMs: ${initTimeoutMs}. Must be > 0.`, + ); + } + // Bd1yh + Bd1z5: per-permission deadline + per-session pending cap. + // 0 / Infinity / non-finite (NaN, -1) all disable — same sentinel + // convention as maxSessions / maxConnections. + const permissionTimeoutRaw = + opts.permissionResponseTimeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS; + const permissionTimeoutMs = + permissionTimeoutRaw > 0 && Number.isFinite(permissionTimeoutRaw) + ? permissionTimeoutRaw + : 0; // 0 = disabled + const maxPendingRaw = + opts.maxPendingPermissionsPerSession ?? DEFAULT_MAX_PENDING_PER_SESSION; + const maxPendingPerSession = + maxPendingRaw > 0 && Number.isFinite(maxPendingRaw) + ? maxPendingRaw + : Infinity; + // The bound path is the canonical form `spawnOrAttach` compares + // incoming `workspaceCwd` against. The caller MUST pass an already- + // canonical value (via `canonicalizeWorkspace`). `runQwenServe` + // does this at boot and threads the same value into both + // `createHttpAcpBridge` and `createServeApp`; direct embeds / tests + // must call `canonicalizeWorkspace` first. No redundant + // `realpathSync.native` here — on case-insensitive / symlinked + // filesystems two independent calls could disagree if the FS mutates + // between them. The `path.isAbsolute` guard is a structural input + // check, not a syscall. + if (!path.isAbsolute(opts.boundWorkspace)) { + throw new TypeError( + `Invalid boundWorkspace: "${opts.boundWorkspace}". Must be an ` + + `absolute path.`, + ); + } + const boundWorkspace = opts.boundWorkspace; + const persistApprovalMode = opts.persistApprovalMode; + const telemetry = opts.telemetry ?? NOOP_BRIDGE_TELEMETRY; + + // Single-workspace model: the bridge hosts AT MOST one + // ATTACH-AVAILABLE channel and one default attach-target entry. + // Multi-session multiplexing happens through `channelInfo.sessionIds`; + // the `defaultEntry` slot is the FIRST session created (the one a + // same-workspace attach under `single` scope reuses). Thread-scope + // sessions add to `byId` but don't displace `defaultEntry`. + let defaultEntry: SessionEntry | undefined; + // `channelInfo` is the SINGLE attach-available channel. Cleared + // ONLY by the `channel.exited` handler (see below) when the OS + // reaps the underlying child process. Teardown initiators + // (`killSession` last-session-leaving, `doSpawn`-newSession-failure + // on an empty channel, `ensureChannel` init-failure / + // late-shutdown, `shutdown`) set `isDying = true` but LEAVE + // `channelInfo` pointing at the dying channel until OS reap — that + // asymmetry IS the BkUyD invariant. It lets `killAllSync` reach a + // mid-SIGTERM-grace channel through `aliveChannels` while a + // concurrent `spawnOrAttach` can already start spawning a fresh + // replacement (which overwrites `channelInfo` when its + // handshake completes). Race-aware code paths (`ensureChannel`, + // `killAllSync`) gate on `isDying` rather than presence; see + // `ChannelInfo.isDying` for the per-set-site rationale. + let channelInfo: ChannelInfo | undefined; + let idleTimer: ReturnType | undefined; + + const sessionReapIntervalMs = resolvePositiveFiniteMs( + opts.sessionReapIntervalMs, + DEFAULT_SESSION_REAP_INTERVAL_MS, + ); + const sessionIdleTimeoutMs = resolvePositiveFiniteMs( + opts.sessionIdleTimeoutMs, + DEFAULT_SESSION_IDLE_TIMEOUT_MS, + ); + let sessionReaper: ReturnType | undefined; + + function resolvePositiveFiniteMs( + raw: number | undefined, + fallback: number, + ): number { + if (raw === undefined) return fallback; + // Clamp to 2^31-1: Node.js treats setInterval delays larger than + // this as 1ms, which would cause a tight CPU-burning loop. + return raw > 0 && Number.isFinite(raw) ? Math.min(raw, 2_147_483_647) : 0; + } + + function cancelIdleTimer(): void { + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + idleTimer = undefined; + } + } + + async function killChannelWithLog( + ci: ChannelInfo, + context?: string, + ): Promise { + ci.isDying = true; + await ci.channel.kill().catch((err) => { + writeStderrLine( + `qwen serve: channel kill failed${context ? ` (${context})` : ''}: ${String(err)}`, + ); + }); + } + + function resolvedChannelIdleTimeoutMs(): number { + const raw = opts.channelIdleTimeoutMs; + return raw !== undefined && Number.isFinite(raw) && raw > 0 + ? Math.min(raw, 2_147_483_647) + : 0; + } + + async function startIdleTimer( + ci: ChannelInfo, + context?: string, + ): Promise { + const timeoutMs = resolvedChannelIdleTimeoutMs(); + if (timeoutMs <= 0) { + await killChannelWithLog(ci, context); + return; + } + cancelIdleTimer(); + idleTimer = setTimeout(() => { + idleTimer = undefined; + if (ci.sessionIds.size === 0 && ci.pendingRestoreIds.size === 0) { + writeStderrLine( + `qwen serve: idle timeout (${timeoutMs}ms) expired, killing channel`, + ); + void killChannelWithLog(ci, 'idle timeout'); + } + }, timeoutMs); + idleTimer.unref(); + } + + function startSessionReaper(): void { + if (sessionReapIntervalMs <= 0 || sessionIdleTimeoutMs <= 0) { + writeStderrLine('qwen serve: session reaper disabled'); + return; + } + writeStderrLine( + `qwen serve: session reaper started ` + + `(interval ${sessionReapIntervalMs}ms, ` + + `idle threshold ${sessionIdleTimeoutMs}ms)`, + ); + sessionReaper = setInterval(() => { + if (shuttingDown) return; + const now = Date.now(); + for (const [id, entry] of byId) { + if (entry.promptActive) continue; + if (entry.events.subscriberCount > 0) continue; + // Note: clientIds.size is NOT checked here. Close-on-last-detach + // handles the normal path (client sends detach → immediate close). + // The reaper covers the crash path where detach was never sent — + // clientIds still > 0 but no SSE subscriber and no heartbeat for + // the configured TTL. + const lastActive = + entry.sessionLastSeenAt ?? Date.parse(entry.createdAt); + const idle = now - lastActive; + if (idle < sessionIdleTimeoutMs) continue; + writeStderrLine( + `qwen serve: reaping idle session ${JSON.stringify(id)} ` + + `(idle for ${Math.round(idle / 1000)}s, ` + + `threshold ${Math.round(sessionIdleTimeoutMs / 1000)}s)`, + ); + void closeSessionImpl(id, undefined, { reason: 'idle_timeout' }).catch( + (err) => { + writeStderrLine( + `qwen serve: session reaper failed to close ` + + `${JSON.stringify(id)}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, + ); + }, + ); + } + }, sessionReapIntervalMs); + sessionReaper.unref(); + } + + function stopSessionReaper(): void { + if (sessionReaper !== undefined) { + clearInterval(sessionReaper); + sessionReaper = undefined; + } + } + + // BkUyD: superset of `channelInfo` covering channels + // that are dying but not yet OS-reaped. `killSession` / + // `doSpawn`-newSession-failure / `shutdown` mark a channel as + // `isDying` and start its async kill; meanwhile a concurrent + // `spawnOrAttach` can spawn a FRESH channel and reassign + // `channelInfo`. Without this set, the dying channel becomes + // unreachable — a double-Ctrl+C arriving mid-grace would call + // `killAllSync()`, find only the fresh channel in `channelInfo`, + // force-kill it, and `process.exit(1)` would orphan the dying one + // whose SIGTERM hadn't yet completed. The set is the OS-level + // "still alive" source of truth: entries are added when a channel + // is created and removed when its `channel.exited` resolves. + // `killAllSync` iterates THIS set to fire SIGKILL on every alive + // child regardless of whether it's still the attach target. + const aliveChannels = new Set(); + // Coalesces a concurrent second `ensureChannel()` call onto the + // first one's spawn so we never create two children for the same + // daemon. Cleared in the `finally` of the creator. + let inFlightChannelSpawn: Promise | undefined; + const byId = new Map(); + // Pending + resolved permission state lives in + // `MultiClientPermissionMediator` (constructed below). The bridge + // keeps `entry.pendingPermissionIds: Set` on each + // SessionEntry as a fast cap-check index; the mediator is the + // single source of truth for the actual pending registry and the + // duplicate-vote LRU. + + // Validate the optional consensus quorum override defensively at + // construction. The settings layer is the primary enforcement + // point, but the bridge also rejects malformed values here so a + // buggy host wiring path can't NaN-poison the mediator. + const permissionConsensusQuorum = opts.permissionConsensusQuorum; + if ( + permissionConsensusQuorum !== undefined && + (!Number.isInteger(permissionConsensusQuorum) || + permissionConsensusQuorum < 1) + ) { + throw new Error( + `BridgeOptions.permissionConsensusQuorum must be a positive integer; ` + + `got ${String(permissionConsensusQuorum)}`, + ); + } + + // Build the mediator before the BridgeClient so the agent's + // `requestPermission` callback can hand the record straight in. + // Audit publisher fallback: when the host doesn't supply one + // (cli/serve/runQwenServe.ts wraps a real `PermissionAuditRing` + // backed publisher in production), we use the canonical no-op + // fallback so the mediator can still run for embedded callers / + // tests without an audit consumer. + const permissionAudit: PermissionAuditPublisher = + opts.permissionAudit ?? createNoOpPermissionAuditPublisher(); + const permissionMediator = new MultiClientPermissionMediator( + opts.permissionPolicy ?? 'first-responder', + { + emit: (sessionId, event) => { + const sessionEntry = byId.get(sessionId); + sessionEntry?.events.publish(event); + }, + audit: permissionAudit, + ...(permissionConsensusQuorum !== undefined + ? { consensusQuorum: permissionConsensusQuorum } + : {}), + now: () => Date.now(), + votersForSession: (sessionId) => { + const sessionEntry = byId.get(sessionId); + if (!sessionEntry) return new Set(); + return new Set(sessionEntry.clientIds.keys()); + }, + }, + ); + // Set by `shutdown()` so any in-flight `spawnOrAttach` that was + // dispatched on an existing connection AFTER the shutdown snapshot + // taken in `shutdown()` fails fast instead of creating a child the + // shutdown path has no more visibility into. Without this, the + // server.listen → bridge.shutdown ordering in `runQwenServe` leaves + // a window between (a) shutdown snapshotting `byId` for kills and + // (b) `server.close` rejecting new connections, during which a + // late-arriving `POST /session` slips a fresh child past cleanup. + let shuttingDown = false; + + // Tee writeServeDebugLine through the optional onDiagnosticLine callback. + // The module-level writeServeDebugLine is left intact for other entry points; + // inside createHttpAcpBridge we use this wrapper exclusively. + const teeServeDebugLine = (message: string): void => { + writeServeDebugLine(message); + if (opts.onDiagnosticLine && isServeDebugLoggingEnabled()) { + opts.onDiagnosticLine(`qwen serve debug: ${message}`, 'info'); + } + }; + + // Coalesces concurrent `spawnOrAttach` calls under single-scope and + // tracks in-progress thread-scope spawns for shutdown to await. + // Single-scope uses the workspaceKey as the dedup key (at most one + // entry; concurrent callers pass the `defaultEntry` check together + // and coalesce here). Thread-scope uses `workspaceKey#uuid` so + // simultaneous calls don't collide while still being awaitable from + // `shutdown()`. + const inFlightSpawns = new Map>(); + + interface InFlightRestore { + action: 'load' | 'resume'; + promise: Promise; + /** + * Synchronous reservation slot for callers that coalesce onto this + * restore. Coalescers do `count++` BEFORE awaiting `promise` so the + * spawn-owner's disconnect-reaper (`killSession({ requireZeroAttaches: + * true })`) sees a non-zero `attachCount` on the freshly registered + * entry and skips the kill. The IIFE folds this counter into + * `entry.attachCount` when it calls `createSessionEntry`. BQ9tV + * race-guard equivalent for coalesced restore waiters. + */ + coalesceState: { count: number }; + } + + // Coalesces concurrent explicit restore calls for the same session id. + // `session/load` replays history through SSE and `session/resume` restores + // context; running either twice for the same id at the same time can + // duplicate history frames or race two entries into `byId`. + const inFlightRestores = new Map(); + // `session/load` emits history replay as session_update notifications before + // the ACP request returns. Keep a temporary bus so those replay frames land in + // the ring, then promote the same bus into the registered SessionEntry. + const pendingRestoreEvents = new Map(); + + const createClientId = (): string => `client_${randomUUID()}`; + + const registerClient = ( + entry: SessionEntry, + requestedClientId?: string, + ): string => { + if (requestedClientId && entry.clientIds.has(requestedClientId)) { + entry.clientIds.set( + requestedClientId, + (entry.clientIds.get(requestedClientId) ?? 0) + 1, + ); + return requestedClientId; + } + const clientId = createClientId(); + entry.clientIds.set(clientId, 1); + return clientId; + }; + + const unregisterClient = (entry: SessionEntry, clientId?: string): void => { + if (clientId === undefined) return; + const count = entry.clientIds.get(clientId); + if (count === undefined) return; + if (count <= 1) { + entry.clientIds.delete(clientId); + // Drop the last-seen entry alongside the registration ref. + // Otherwise a long-lived daemon servicing a churn of disconnect/ + // reconnect clients (each picking a fresh `clientId`) would + // accumulate stale heartbeat timestamps for clients that no + // longer exist — the very leak revocation policy is meant to + // plug. + entry.clientLastSeenAt.delete(clientId); + } else { + entry.clientIds.set(clientId, count - 1); + } + }; + + const resolveTrustedClientId = ( + entry: SessionEntry, + clientId?: string, + ): string | undefined => { + if (clientId === undefined) return undefined; + if (!entry.clientIds.has(clientId)) { + throw new InvalidClientIdError(entry.sessionId, clientId); + } + return clientId; + }; + + // + /** + * Get-or-create the daemon's single `qwen --acp` channel. N sessions + * multiplex onto it via `connection.newSession()`. Concurrent callers + * coalesce through `inFlightChannelSpawn` so we never spawn two + * children. Wires up the one-and-only `channel.exited` cleanup on + * first creation so the late-arriving event tears down ALL + * multiplexed sessions. + */ + async function ensureChannel(): Promise { + // Skip a channel that's marked dying — its underlying transport is + // mid-SIGTERM-or-already-dead and `connection.newSession()` on it + // would either hang or land the caller with a sessionId that + // immediately 404s on every follow-up. + cancelIdleTimer(); + if (channelInfo && !channelInfo.isDying) return channelInfo; + if (inFlightChannelSpawn) return await inFlightChannelSpawn; + + const promise = (async () => { + const channel = await telemetry.withSpan( + 'channel.spawn', + { + 'qwen-code.daemon.bridge.operation': 'channel.spawn', + 'qwen-code.daemon.channel.reused': false, + }, + async () => await channelFactory(boundWorkspace, childEnvOverrides), + ); + const client = new BridgeClient( + // BfFut: ACP today carries a sessionId on every per-session + // notification / request, so the no-sessionId branch is + // technically unreachable. But the channel is multi-session + // (Stage 1.5 multiplex), so if ACP ever grows a no-sessionId + // call we'd silently drop it on a multi-session channel + // instead of throwing. Surface that ambiguity loudly. + (sessionId) => { + if (sessionId) return byId.get(sessionId); + if (channelInfo && channelInfo.sessionIds.size > 1) { + throw new Error( + 'BridgeClient: ACP call without sessionId on a ' + + 'multi-session channel cannot be routed — workspace=' + + boundWorkspace, + ); + } + return undefined; + }, + (sessionId) => + sessionId ? pendingRestoreEvents.get(sessionId) : undefined, + permissionMediator, + permissionTimeoutMs, + maxPendingPerSession, + // Forward the optional `BridgeFileSystem` injection so + // production `qwen serve` can wire the `WorkspaceFileSystem` + // adapter into BridgeClient's fs proxy methods. Tests + Mode A + // consumers + channels / IDE companion omit it; BridgeClient + // falls back to its inline fs proxy. + opts.fileSystem, + // §2.3: centralised model_switched publish — keeps cache + generation + // update atomic. BridgeClient calls this instead of inlining publish. + (entry, modelId, originator) => + publishModelSwitched(entry as SessionEntry, modelId, originator), + // A2: centralised approval_mode_changed publish on in-session mode + // promotion. `previous` is read from the bridge state cache. + (entry, modeId, originator) => { + const se = entry as SessionEntry; + publishApprovalModeChanged( + se, + { + previous: se.currentApprovalMode ?? 'default', + next: modeId, + persisted: false, + }, + originator, + ); + }, + ); + const connection = new ClientSideConnection(() => client, channel.stream); + + // Add to `aliveChannels` + register the `channel.exited` handler + // BEFORE the `initialize` handshake: the agent child exists from + // the moment `channelFactory(boundWorkspace)` returns, so a + // `killAllSync()` during the handshake window (up to + // `initTimeoutMs`, default 10s) must find it to avoid orphaning + // on `process.exit(1)`. Init-failure / child-crash / late-shutdown + // all converge on the same cleanup path via the handler below. + // `channelInfo` (the attach target) is assigned only AFTER + // initialize succeeds so callers don't attach to a still- + // handshaking channel. + const info: ChannelInfo = { + channel, + connection, + client, + sessionIds: new Set(), + pendingRestoreIds: new Set(), + isDying: false, + handshakeComplete: false, + }; + aliveChannels.add(info); + // Belt-and-suspenders leak detection. The set is intentionally + // multi-entry to cover the `killSession`-then-`spawnOrAttach` + // overlap window (size 2 is legitimate: one dying + one fresh + // attach-target). Anything higher implies a `channel.exited` + // handler never fired for some prior channel — a real leak we'd + // otherwise notice only as gradually-growing RSS over hours. + // The warning surfaces it the moment it happens. Threshold is + // 2 because that's the design ceiling; bumping it requires + // updating both this guard and the comments around + // `aliveChannels` declaration. + if (aliveChannels.size > 2) { + writeStderrLine( + `qwen serve: WARNING aliveChannels.size=${aliveChannels.size} ` + + `(expected 1, max 2 during killSession-then-spawnOrAttach ` + + `overlap) — possible channel leak; check that prior channels' ` + + `channel.exited fired and the handler ran cleanup.`, + ); + } + + // One-time channel.exited cleanup. The child dying takes ALL + // multiplexed sessions with it — iterate `sessionIds` (snapshot + // first to be safe against concurrent killSession during + // iteration), publish `session_died` on each session's bus, + // remove from byId / defaultEntry / pending tables. + // + // Registered BEFORE the `initialize` await so init-failure / + // child-crash / late-shutdown all converge here. During + // handshake `sessionIds` is empty — the loop below no-ops, + // the stderr line still fires, and `aliveChannels.delete(info)` + // clears the entry through the normal exit path. + // + // BkUyD: drop from `aliveChannels` ONLY when the OS process is + // actually gone. Async kill paths mark `isDying = true` but + // leave the entry in `aliveChannels` until this handler fires, + // so `killAllSync` still has a reference to fire SIGKILL during + // the SIGTERM grace window — even if a concurrent `spawnOrAttach` + // has already reassigned `channelInfo` to a fresh channel. + void channel.exited.then((exitInfo) => { + if (channelInfo === info) cancelIdleTimer(); + aliveChannels.delete(info); + if (channelInfo === info) channelInfo = undefined; + const sessions = Array.from(info.sessionIds); + info.sessionIds.clear(); + // Operator breadcrumb for UNEXPECTED channel exits. Without + // this an agent crash (OOM / segfault) is invisible from the + // daemon log: each affected SSE subscriber sees a + // `session_died` frame and disconnects, the daemon's + // child-stderr forwarder emits whatever the child wrote before + // dying (often nothing on a SIGKILL / segfault), and operators + // can't tell from `qwen serve`'s own output that the agent + // process is gone. + // + // Suppressed during `shuttingDown` because the operator + // already saw "received SIGINT, draining..." from + // `runQwenServe`'s signal handler. The standalone + // killSession case (last session leaves, channel torn down + // but daemon stays up) still logs — there's no upstream + // context line in that flow, and the message confirms the + // cleanup actually ran. + const channelExitExpected = shuttingDown || info.isDying; + if (info.handshakeComplete) { + telemetry.metrics?.channelLifecycle('exit', channelExitExpected); + } + if (!shuttingDown) { + telemetry.event('channel.exited', { + 'qwen-code.daemon.channel.exit_code': exitInfo?.exitCode ?? -1, + 'qwen-code.daemon.channel.session_count': sessions.length, + ...(exitInfo?.signalCode + ? { 'qwen-code.daemon.channel.signal': exitInfo.signalCode } + : {}), + }); + writeStderrLine( + `qwen serve: channel exited (code=${exitInfo?.exitCode ?? 'none'}, signal=${exitInfo?.signalCode ?? 'none'}, ${sessions.length} session(s) torn down)`, + ); + } + for (const sid of sessions) { + const sessEntry = byId.get(sid); + if (!sessEntry) continue; + cancelPendingForSession(sid); + try { + sessEntry.events.publish({ + type: 'session_died', + data: { + sessionId: sid, + reason: 'channel_closed', + // BX9_P: thread exitCode/signalCode through. + exitCode: exitInfo?.exitCode ?? null, + signalCode: exitInfo?.signalCode ?? null, + }, + }); + } catch { + /* bus already closed */ + } + byId.delete(sid); + telemetry.metrics?.sessionLifecycle('die'); + // Tombstone the id so any late `extNotification` from the + // dying child can't leak into the early-event buffer for a + // future load/resume of the same persisted session id. + info.client.markSessionClosed(sid); + if (defaultEntry === sessEntry) defaultEntry = undefined; + sessEntry.events.close(); + } + }); + + // Initialize handshake. The channel is already in + // `aliveChannels` and the `channel.exited` handler above is + // registered, so failure paths (init throw, timeout, late + // shutdown) only need to mark dying + kill — the handler does + // the alive-set cleanup when the OS reaps the child. + try { + await telemetry.withSpan( + 'channel.initialize', + { + 'qwen-code.daemon.bridge.operation': 'channel.initialize', + }, + async () => + await withTimeout( + connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + clientInfo: { name: 'qwen-serve-bridge', version: '0' }, + }), + initTimeoutMs, + 'initialize', + ), + ); + } catch (err) { + // Mark the half-initialized channel as dying/unavailable, then + // kill it. Coalesced callers (`inFlightChannelSpawn` branch in + // `ensureChannel`) observe the same rejection on this promise + // and propagate it to their callers; the `inFlightSpawns` + // tracker is cleared in `spawnOrAttach`'s finally so a follow- + // up call retries cleanly. The `channel.exited` handler + // registered earlier removes `info` from `aliveChannels` once + // the OS reaps the child. `isDying` here is the cross-path + // invariant marker (matches `killSession` / `doSpawn`- + // newSession-failure / `shutdown`): "any channel in + // `aliveChannels` with `isDying === true` is mid-teardown." + info.isDying = true; + await channel.kill().catch(() => {}); + throw err; + } + + // Late-shutdown re-check: if shutdown flipped during the + // handshake, tear this channel down rather than leak past + // `process.exit(0)`. Same cleanup pattern as the init-failure + // path: mark dying + kill, let the exited handler reap. + if (shuttingDown) { + info.isDying = true; + await channel.kill().catch(() => {}); + throw new Error('AcpSessionBridge is shutting down'); + } + + // Handshake succeeded — now publish the channel as the + // attach-available slot. `channelInfo` is assigned LAST so + // `ensureChannel`'s fast-path (`if (channelInfo && !.isDying)`) + // never returns a still-handshaking channel to a concurrent + // caller. + channelInfo = info; + info.handshakeComplete = true; + telemetry.metrics?.channelLifecycle('spawn'); + return info; + })(); + + inFlightChannelSpawn = promise; + try { + return await promise; + } finally { + inFlightChannelSpawn = undefined; + } + } + + async function doSpawn( + modelServiceId: string | undefined, + effectiveScope: 'single' | 'thread', + requestedClientId?: string, + ): Promise { + // Get-or-create the daemon's single channel, then call + // `connection.newSession()` on it. Sessions share the child's + // process / OAuth / file-cache / hierarchy-memory parse. + // + // newSession on an established channel can fail (auth, config, + // etc.) without the channel dying. We DON'T kill the channel on + // newSession failure when OTHER sessions are still using it — + // they'd lose their work for a problem orthogonal to them. + // + // BkwQA: when the failed newSession was the channel's ONLY + // attempt (sessionIds.size === 0), the empty channel must NOT + // linger — it would stay set as `channelInfo` invisible to + // `sessionCount` / `maxSessions` (both backed by `byId`), and + // repeated failing creates would still find this channel via + // `ensureChannel`, never spawning a fresh one. Tear down the + // empty channel so the next attempt gets a clean spawn. + const ci = await ensureChannel(); + let newSessionResp: { + sessionId: string; + models?: { currentModelId?: unknown } | null; + modes?: { currentModeId?: unknown } | null; + }; + try { + newSessionResp = await telemetry.withSpan( + 'session.new', + { + 'qwen-code.daemon.bridge.operation': 'session.new', + 'qwen-code.daemon.session_scope': effectiveScope, + }, + async () => + await withTimeout( + ci.connection.newSession({ + cwd: boundWorkspace, + mcpServers: [], + }), + initTimeoutMs, + 'newSession', + ), + ); + } catch (err) { + // Only reap when this newSession was the channel's first/only + // attempt — a populated channel keeps running for its other + // live sessions. + if (ci.sessionIds.size === 0) { + // Mark dying SYNCHRONOUSLY so a concurrent `spawnOrAttach` + // calling `ensureChannel()` between this point and the + // `channel.exited` cleanup spawns a fresh channel instead of + // attaching to the one we're about to tear down. `channelInfo` + // stays set until OS reap so `killAllSync` mid-SIGTERM still + // finds a target (BkUyD invariant). + ci.isDying = true; + await ci.channel.kill().catch(() => { + /* best-effort — channel.exited handler still runs */ + }); + } + throw err; + } + + // Late-shutdown re-check (BUy4U): shutdown() may have flipped + // while we were in `connection.newSession` (~1s on cold start). + if (shuttingDown) { + // Don't kill the channel — see comment above. Just throw. + throw new Error('AcpSessionBridge is shutting down'); + } + + const entry = createSessionEntry( + ci, + newSessionResp.sessionId, + boundWorkspace, + ); + seedSnapshotCaches(entry, newSessionResp); + const clientId = registerClient(entry, requestedClientId); + // `defaultEntry` is the single-scope attach target — only sessions + // SPAWNED UNDER `'single'` may claim it. A thread-scope spawn must + // never become the attach target, otherwise a later omitted-scope + // (or daemon-default-`single`) caller would attach to what its + // sender promised was an isolated session. Subsequent same-scope + // spawns also don't overwrite (first wins). + if (effectiveScope === 'single' && !defaultEntry) defaultEntry = entry; + + // ACP `newSession` doesn't take a model id; honor the caller's + // `modelServiceId` via `unstable_setSessionModel`. See + // `applyModelServiceId` for rationale (race against + // transportClosedReject, publish model_switched on success, + // model_switch_failed on failure, don't tear down the session). + if (modelServiceId) { + await applyModelServiceId( + entry, + modelServiceId, + initTimeoutMs, + clientId, + ).catch(() => { + // Already published `model_switch_failed`; session stays + // operational on the agent's default model. + }); + } + + // Bd1zc: re-check that the entry is still live before returning. + // The model-switch call yields and races against + // `channel.exited` — if the child crashed during the model + // switch, the exited handler already removed the entry from + // byId. Without this check, the caller would get HTTP 200 with + // a sessionId that already 404s on every subsequent request. + if (!byId.has(entry.sessionId)) { + throw new Error( + `Session ${entry.sessionId} died during model-switch ` + + `initialization`, + ); + } + + return { + sessionId: entry.sessionId, + workspaceCwd: entry.workspaceCwd, + attached: false, + clientId, + createdAt: entry.createdAt, + }; + } + + /** + * Send `unstable_setSessionModel` and broadcast a `model_switched` + * event. Used at create-session time (via doSpawn) AND on attach when + * the caller passes a modelServiceId — the existing session may be + * running a different model. + * + * Serialized through `entry.modelChangeQueue` so two concurrent + * attach-with-different-model requests can't race into the agent. + * On failure, publishes a `model_switch_failed` event for cross-client + * observability and re-throws so the HTTP caller sees the error + * (session keeps running its previous model — that's the safer + * default than tearing down a shared session because one client + * asked for an unknown model). + */ + async function applyModelServiceId( + entry: SessionEntry, + modelId: string, + timeoutMs: number, + originatorClientId?: string, + ): Promise { + const conn = entry.connection as unknown as { + unstable_setSessionModel(p: { + sessionId: string; + modelId: string; + }): Promise; + }; + // Race against `transportClosedReject` so a child crash during + // model switch fails the call immediately instead of waiting the + // full `timeoutMs`. Matches what `sendPrompt` and `setSessionModel` + // already do — without this, a callback-attach with a broken model + // wedges the HTTP handler for 10s. + const transportClosed = getTransportClosedReject(entry); + const work = entry.modelChangeQueue.then(async () => { + // A1: mark a bridge-driven model roundtrip so the agent's + // `current_model_update` extNotification (this path also drives + // `Session.setModel`, which emits it) is suppressed by the demux — + // the authoritative `model_switched` is published below. + entry.modelRoundtripInFlight = true; + // Mirror setSessionModel: only reconcile after a change that landed. A + // rejected roundtrip leaves the cache unchanged (often still unset on + // the create/attach path), so reconciling would emit a corrective + // model_switched right beside the model_switch_failed below. + let succeeded = false; + try { + await Promise.race([ + withTimeout( + conn.unstable_setSessionModel({ + sessionId: entry.sessionId, + modelId, + }), + timeoutMs, + 'setSessionModel', + ), + transportClosed, + ]); + publishModelSwitched(entry, modelId, originatorClientId); + succeeded = true; + } catch (err) { + // Surface the failure to ALL attached clients, not just the + // caller — a shared session swallowing a denied model change + // silently would surprise the others. `publish()` never throws + // (see `publishModelSwitched`), so no wrapper. + entry.events.publish({ + type: 'model_switch_failed', + data: { + sessionId: entry.sessionId, + requestedModelId: modelId, + error: err instanceof Error ? err.message : String(err), + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + throw err; + } finally { + entry.modelRoundtripInFlight = false; + if (succeeded) { + void reconcileAfterRoundtrip(entry, 'model'); + } else { + writeStderrLine( + `[reconcile] session=${entry.sessionId} target=model action=skipped reason=roundtrip_failed`, + ); + } + } + }); + // Tail swallows failures so subsequent model changes still run; the + // original caller still observes the rejection on `work`. + entry.modelChangeQueue = work.then( + () => undefined, + () => undefined, + ); + return work; + } + + /** + * Resolve every pending request belonging to one session as cancelled. + * + * **Scope contract (per ACP spec / live-collab default):** + * Permissions are issued by the agent inline DURING an active + * prompt — `requestPermission` returns a Promise the agent awaits + * before continuing. Per the bridge's per-session FIFO + ACP's + * "one active prompt per session" guarantee, ALL outstanding + * permissions at any moment belong to the **currently active + * prompt**. So "cancel all pending permissions for this session" + * is equivalent to "cancel the active prompt's permissions" — and + * that's exactly what ACP requires when a prompt is cancelled + * ("cancelling a prompt MUST resolve outstanding requestPermission + * calls with outcome.cancelled"). + * + * **Multi-client live-collab caveat:** under `sessionScope: 'single'` + * Client B may have been about to vote on A's pending permission + * via SSE — when A disconnects mid-prompt, B's vote (if it arrives + * after the abort) gets `404`. This is the right behavior: A's + * prompt is being cancelled, so the permission belongs to a turn + * that no longer matters. From B's side they see + * `permission_resolved` with `outcome: cancelled` on the SSE + * stream, then the prompt's `cancelled` stop reason. Voting on a + * cancelled-prompt's permission was never going to drive the + * agent forward anyway. + */ + const cancelPendingForSession = (sessionId: string) => { + // Mediator first (it cancels each pending, + // emits `permission_resolved`, writes audit, settles the + // Promise), THEN clear the bridge's fast cap-check index. + permissionMediator.forgetSession(sessionId); + byId.get(sessionId)?.pendingPermissionIds.clear(); + }; + + /** + * Lazy-init the per-session `transportClosedReject` promise that + * `sendPrompt` / `setSessionModel` / `applyModelServiceId` race their + * ACP calls against. ONE listener is attached to `channel.exited` + * over the session's lifetime (the first caller "wins" and creates + * the promise; subsequent callers reuse it) — a per-call attach + * would grow Node's listener list linearly with prompt count on + * chatty sessions. The rejection message names the FIRST caller, + * which can be misleading if a later method observes the failure; + * the cost-benefit favors the single-listener invariant. + */ + const getTransportClosedReject = (entry: SessionEntry): Promise => { + if (!entry.transportClosedReject) { + entry.transportClosedReject = entry.channel.exited.then(() => { + throw new BridgeChannelClosedError( + `mid-request (session ${entry.sessionId})`, + ); + }); + } + return entry.transportClosedReject; + }; + + const resolveWorkspaceKey = (workspaceCwd: string): string => { + if (!path.isAbsolute(workspaceCwd)) { + throw new Error( + `workspaceCwd must be an absolute path; got "${workspaceCwd}"`, + ); + } + const workspaceKey = + workspaceCwd === boundWorkspace + ? boundWorkspace + : canonicalizeWorkspace(workspaceCwd); + if (workspaceKey !== boundWorkspace) { + throw new WorkspaceMismatchError(boundWorkspace, workspaceKey); + } + return workspaceKey; + }; + + const liveChannelInfo = (): ChannelInfo | undefined => { + if (!channelInfo || channelInfo.isDying) return undefined; + return channelInfo; + }; + + const channelInfoForEntry = ( + entry: SessionEntry, + ): ChannelInfo | undefined => { + if (channelInfo?.channel === entry.channel) return channelInfo; + for (const info of aliveChannels) { + if (info.channel === entry.channel) return info; + } + return undefined; + }; + + const getChannelClosedReject = (info: ChannelInfo): Promise => { + if (!info.statusClosedReject) { + info.statusClosedReject = info.channel.exited.then(() => { + throw new BridgeChannelClosedError('mid-request (workspace status)'); + }); + } + return info.statusClosedReject; + }; + + const requestWorkspaceStatus = async ( + method: string, + idle: () => T, + params: Record = {}, + ): Promise => { + const info = liveChannelInfo(); + if (!info) return idle(); + const response = await withTimeout( + Promise.race([ + info.connection.extMethod(method, { ...params, cwd: boundWorkspace }), + getChannelClosedReject(info), + ]), + initTimeoutMs, + method, + ); + return response as unknown as T; + }; + + const requestSessionStatus = async ( + sessionId: string, + method: string, + params: Record = {}, + ): Promise => { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const info = channelInfoForEntry(entry); + if (!info || info.isDying) throw new SessionNotFoundError(sessionId); + const response = await Promise.race([ + withTimeout( + entry.connection.extMethod(method, { ...params, sessionId }), + initTimeoutMs, + method, + ), + getTransportClosedReject(entry), + ]); + return response as unknown as T; + }; + + const notifyAgentSessionClose = async ( + entry: SessionEntry, + ci: ChannelInfo | undefined, + label: 'closeSession' | 'killSession', + ): Promise => { + if (!ci || ci.channel !== entry.channel) return; + try { + await Promise.race([ + withTimeout( + entry.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionClose, { + sessionId: entry.sessionId, + }), + initTimeoutMs, + SERVE_CONTROL_EXT_METHODS.sessionClose, + ), + getTransportClosedReject(entry), + ]); + } catch (err) { + writeStderrLine( + `qwen serve: ${label} ACP session close notification failed ` + + `for session ${JSON.stringify(entry.sessionId)}: ${String( + err instanceof Error ? err.message : err, + )}`, + ); + } + }; + + /** + * Fan-out an event to every live session bus. Mutation events + * (`tool_toggled`, `workspace_initialized`, `mcp_server_restart*`, + * persisted `approval_mode_changed` mirror) call this. + * + * Kept as a local closure rather than a member method because call + * sites within the bridge implementation run inside the factory + * scope where `this` is not yet the proxy. + * + * Optional `skipSessionId` — when set, that session is excluded + * from the broadcast. Used by `setSessionApprovalMode` to avoid + * delivering `approval_mode_changed` twice to the requesting + * session (which already received the session-scoped publish on + * its own bus). + */ + const broadcastWorkspaceEvent = ( + envelope: Omit, + skipSessionId?: string, + ): void => { + const sessions = Array.from(byId.values()); + let successCount = 0; + let failureCount = 0; + let skippedCount = 0; + for (const entry of sessions) { + if (skipSessionId !== undefined && entry.sessionId === skipSessionId) { + skippedCount += 1; + continue; + } + try { + const published = entry.events.publish(envelope); + if (published === undefined) { + failureCount += 1; + teeServeDebugLine( + `broadcastWorkspaceEvent: publish on session ${entry.sessionId} no-op (bus closed)`, + ); + } else { + successCount += 1; + } + } catch (err) { + failureCount += 1; + const detail = + `broadcastWorkspaceEvent: bus publish failed for session ` + + `${JSON.stringify(entry.sessionId)} (type=${envelope.type}): ` + + `${err instanceof Error ? err.message : String(err)}`; + if (shuttingDown) { + teeServeDebugLine(detail); + } else { + writeStderrLine(`qwen serve: ${detail}`); + } + } + } + // Only elevate when the broadcast had at least one eligible + // recipient (excluding the skipped requester) and ALL of them + // dropped the event. Single-session workspaces with the requester + // skipped naturally produce zero recipients — that's not an + // "all dropped" condition, just nobody to deliver to. + // + // Count the sessions we actually skipped instead of unconditionally + // subtracting 1 when `skipSessionId` is set. Counting actual skips + // makes the alarm condition self-consistent regardless of whether + // the `skipSessionId` matches any live session. + const eligible = sessions.length - skippedCount; + if (eligible > 0 && successCount === 0 && !shuttingDown) { + writeStderrLine( + `qwen serve: broadcastWorkspaceEvent type=${envelope.type} dropped on ALL ${failureCount} session bus(es); SSE subscribers will miss this event (GET fallback still authoritative)`, + ); + } + }; + + const createSessionEventBus = (): EventBus => + new EventBus(eventRingSize, undefined, new TurnBoundaryCompactionEngine()); + + // §2.3 publish helpers — centralise cache + generation + bus publish so + // every `model_switched` / `approval_mode_changed` site stays atomic. + + const publishModelSwitched = ( + entry: SessionEntry, + modelId: string, + originatorClientId: string | undefined, + ): void => { + entry.currentModelId = modelId; + entry.modelPublishGeneration++; + // `EventBus.publish` never throws (a closed bus is a return-undefined + // no-op); per its documented contract we don't wrap it — a try/catch + // here would be dead code for "bus closed" and would mislabel a real + // programming error (e.g. a `TypeError`) as a benign bus-closed swallow. + entry.events.publish({ + type: 'model_switched', + data: { sessionId: entry.sessionId, modelId }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + }; + + const publishApprovalModeChanged = ( + entry: SessionEntry, + payload: { previous: string; next: string; persisted: boolean }, + originatorClientId: string | undefined, + ): void => { + entry.currentApprovalMode = payload.next; + entry.approvalModePublishGeneration++; + // See `publishModelSwitched`: `publish()` never throws, so no wrapper. + entry.events.publish({ + type: 'approval_mode_changed', + data: { + sessionId: entry.sessionId, + previous: payload.previous, + next: payload.next, + persisted: payload.persisted, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + }; + + // §2.2 post-roundtrip reconciliation — after a bridge-driven model or + // approval-mode change settles, re-read the agent's actual state and + // emit a corrective event if it drifted from the cached value. + const reconcileAfterRoundtrip = async ( + entry: SessionEntry, + target: 'model' | 'approvalMode', + ): Promise => { + const flagKey = + target === 'model' + ? 'modelReconciliationInFlight' + : 'approvalModeReconciliationInFlight'; + const genOf = () => + target === 'model' + ? entry.modelPublishGeneration + : entry.approvalModePublishGeneration; + if (entry[flagKey]) return; + entry[flagKey] = true; + const genBefore = genOf(); + // Set when a newer change published while our status read was in + // flight; we re-run once after releasing the guard (see `finally`). + let rerun = false; + try { + const status = await requestSessionStatus( + entry.sessionId, + SERVE_STATUS_EXT_METHODS.sessionContext, + ); + if (genOf() !== genBefore) { + // A newer change published during our RPC; its own + // `reconcileAfterRoundtrip` bailed on the in-flight guard above, + // so without a re-run the latest change would never be + // reconciled. Skip this (now-stale) read and re-run once. The + // re-run is gated on this generation-change signal — NOT on a + // bare `genOf() !== genBefore` at `finally` time — because a + // corrective publish below bumps the generation itself and would + // otherwise self-trigger an unbounded reconcile loop. + rerun = true; + writeStderrLine( + `[reconcile] session=${entry.sessionId} target=${target} action=skipped reason=generation_changed genBefore=${genBefore} genAfter=${genOf()}`, + ); + return; + } + + if (target === 'model') { + const actual = ( + status?.state?.models as { currentModelId?: string } | undefined + )?.currentModelId; + if ( + typeof actual === 'string' && + actual && + actual !== entry.currentModelId + ) { + writeStderrLine( + `[reconcile] session=${entry.sessionId} target=model action=corrected cached=${entry.currentModelId ?? ''} actual=${actual}`, + ); + publishModelSwitched(entry, actual, undefined); + } + } else { + const actual = ( + status?.state?.modes as { currentModeId?: string } | undefined + )?.currentModeId; + // Same enum backstop as the demux path (`handleInSessionModeUpdate`): + // `actual` is an agent-supplied id typed `unknown`, and the SDK's + // `isApprovalModeChangedData` is a structural check (deliberately + // forward-compatible with a future 5th mode), NOT an enum gate. An + // unknown id here would fan out to every SSE client and land in the + // reducer's `state.approvalMode`, so drop it before publishing. + if (actual && !KNOWN_APPROVAL_MODES.has(actual)) { + writeStderrLine( + `[reconcile] session=${entry.sessionId} target=approvalMode action=dropped reason=unknown_mode mode=${actual}`, + ); + } else if (actual && actual !== entry.currentApprovalMode) { + writeStderrLine( + `[reconcile] session=${entry.sessionId} target=approvalMode action=corrected cached=${entry.currentApprovalMode ?? ''} actual=${actual}`, + ); + publishApprovalModeChanged( + entry, + { + previous: entry.currentApprovalMode ?? 'default', + next: actual, + persisted: false, + }, + undefined, + ); + } + } + } catch (err) { + // The status read failed — drift can be neither confirmed nor + // corrected. Keep the signal in the operator log rather than + // emitting a bus event no client can decode: `reconciliation_failed` + // is not a known SDK event type, so `asKnownDaemonEvent` drops it + // and the reducer never sees it. Long-lived SSE connections that + // never disconnect will hold their last-seen state until the next + // successful roundtrip triggers another reconcile; reconnecting + // clients get a fresh `session_snapshot` on attach. + writeStderrLine( + `[reconcile] session=${entry.sessionId} target=${target} action=failed error=${ + err instanceof Error ? err.message : String(err) + }`, + ); + } finally { + entry[flagKey] = false; + if (rerun) void reconcileAfterRoundtrip(entry, target); + } + }; + + const createSessionEntry = ( + ci: ChannelInfo, + sessionId: string, + workspaceCwd: string, + events = createSessionEventBus(), + ): SessionEntry => { + const entry: SessionEntry = { + sessionId, + workspaceCwd, + createdAt: new Date().toISOString(), + channel: ci.channel, + connection: ci.connection, + events, + promptQueue: Promise.resolve(), + modelChangeQueue: Promise.resolve(), + approvalModeQueue: Promise.resolve(), + modelPublishGeneration: 0, + approvalModePublishGeneration: 0, + pendingPermissionIds: new Set(), + clientIds: new Map(), + clientLastSeenAt: new Map(), + attachCount: 0, + spawnOwnerWantedKill: false, + promptActive: false, + }; + ci.sessionIds.add(entry.sessionId); + byId.set(entry.sessionId, entry); + telemetry.metrics?.sessionLifecycle('spawn'); + // Drain any guardrail events that fired during this session's + // `newSession` handler (before this entry registered) onto the + // freshly-created EventBus. Idempotent on unknown sessionIds. + ci.client.drainEarlyEvents(entry.sessionId, entry); + return entry; + }; + + // A5: seed the snapshot caches from the agent's session-create response + // (`newSession` / `loadSession` / `resumeSession` all return `models` + + // `modes`). Without this the caches stay unset until the first change, so a + // cold `?snapshot=1` attach to a session that never switched would return + // `{ currentModelId: null, currentApprovalMode: null }` and the SDK reducer's + // `!= null` guard would leave the client unseeded — defeating A5's primary + // (initial-attach) use case. The agent's `currentModelId` is already the + // canonical `model(authType)` form (acpAgent `formatAcpModelId`), matching + // what `reconcileAfterRoundtrip` reads back, so seeding it keeps the model + // comparison format-stable. Mode ids pass the same `KNOWN_APPROVAL_MODES` + // backstop the demux/reconcile paths use. + const seedSnapshotCaches = ( + entry: SessionEntry, + resp: { + models?: { currentModelId?: unknown } | null; + modes?: { currentModeId?: unknown } | null; + }, + ): void => { + const model = resp.models?.currentModelId; + if (typeof model === 'string' && model.length > 0) { + entry.currentModelId = model; + } else if (model != null) { + writeStderrLine( + `[seed] session=${entry.sessionId} target=model action=dropped value=${JSON.stringify(model)} reason=invalid_type`, + ); + } + const mode = resp.modes?.currentModeId; + if (typeof mode === 'string' && KNOWN_APPROVAL_MODES.has(mode)) { + entry.currentApprovalMode = mode; + } else if (mode != null) { + writeStderrLine( + `[seed] session=${entry.sessionId} target=approvalMode action=dropped value=${JSON.stringify(mode)} reason=${typeof mode !== 'string' ? 'invalid_type' : 'unknown_mode'}`, + ); + } + }; + + const isAcpSessionResourceNotFound = ( + err: unknown, + sessionId: string, + ): boolean => { + if (!err || typeof err !== 'object') return false; + const maybe = err as { + code?: unknown; + data?: unknown; + message?: unknown; + }; + if (maybe.code !== -32002) return false; + const expectedUri = `session:${sessionId}`; + if ( + maybe.data && + typeof maybe.data === 'object' && + (maybe.data as { uri?: unknown }).uri === expectedUri + ) { + return true; + } + // Fallback for ACP servers that omit `data.uri` and embed the + // URI in the human-readable message. Use exact equality on the + // canonical "Resource not found: " form rather than + // `includes(expectedUri)` — a substring match would cause a + // sessionId of `"a"` to falsely match a message containing + // `"session:abc"`. + return ( + typeof maybe.message === 'string' && + maybe.message === `Resource not found: ${expectedUri}` + ); + }; + + const replayFieldsFor = ( + entry: { events: EventBus }, + action: 'load' | 'resume', + ): Pick< + BridgeRestoredSession, + 'compactedReplay' | 'liveJournal' | 'lastEventId' + > => { + const snapshot = entry.events.snapshotReplay(); + if (!snapshot) return { lastEventId: entry.events.lastEventId }; + if (action === 'load') { + return { + compactedReplay: snapshot.compactedTurns, + liveJournal: snapshot.liveJournal, + lastEventId: snapshot.lastEventId, + }; + } + return { lastEventId: snapshot.lastEventId }; + }; + + async function restoreSession( + action: 'load' | 'resume', + req: BridgeRestoreSessionRequest, + ): Promise { + if (shuttingDown) { + throw new Error('AcpSessionBridge is shutting down'); + } + const workspaceKey = resolveWorkspaceKey(req.workspaceCwd); + + const existing = byId.get(req.sessionId); + if (existing) { + existing.attachCount++; + const clientId = registerClient(existing, req.clientId); + return { + sessionId: existing.sessionId, + workspaceCwd: existing.workspaceCwd, + attached: true, + clientId, + createdAt: existing.createdAt, + // Late attachers get the same ACP state the original restore + // caller saw; spawn-only sessions don't carry a state payload. + state: existing.restoreState ?? {}, + ...replayFieldsFor(existing, action), + }; + } + + const inFlight = inFlightRestores.get(req.sessionId); + if (inFlight) { + // Cross-action races BOTH ways must reject. A `resume` arriving + // while a `load` is in flight cannot quietly coalesce: load + // returns compacted replay + watermark while resume returns only + // a watermark — mixing the two on a shared EventBus would give + // the resume client unexpected replay data or the load client a + // missing snapshot. Same-action coalescing is unaffected. + if (action !== inFlight.action) { + throw new RestoreInProgressError( + req.sessionId, + inFlight.action, + action, + ); + } + // Reserve the attach SYNCHRONOUSLY before awaiting so the spawn + // owner's `requireZeroAttaches` disconnect-reaper observes our + // intent. The IIFE folds this counter into `entry.attachCount` + // at `createSessionEntry` time. + inFlight.coalesceState.count++; + let restored: BridgeRestoredSession; + try { + restored = await inFlight.promise; + } catch (err) { + // Roll back our reservation so a subsequent retry isn't + // permanently skewed if the in-flight restore failed. + inFlight.coalesceState.count--; + throw err; + } + const entry = byId.get(restored.sessionId); + if (!entry) { + // Restore owner's session got reaped before our await + // resumed (channel died mid-microtask, etc). Roll back the + // reservation too — there's no entry for it to live on. + inFlight.coalesceState.count--; + throw new SessionNotFoundError( + restored.sessionId, + 'the agent child likely crashed during session restore — retry to restore the session', + ); + } + // NOTE: do NOT bump entry.attachCount here — `createSessionEntry` + // already initialized it from coalesceState.count synchronously + // when the IIFE registered the entry. Spread `restored` so the + // ACP state propagates to coalesced waiters (BQ9tV-equivalent + // for restore waiter consistency). + return { + ...restored, + attached: true, + clientId: registerClient(entry, req.clientId), + createdAt: entry.createdAt, + }; + } + + if ( + byId.size + inFlightSpawns.size + inFlightRestores.size >= + maxSessions + ) { + throw new SessionLimitExceededError(maxSessions); + } + + const restoreEvents = createSessionEventBus(); + let registeredEntry: SessionEntry | undefined; + let ci: ChannelInfo | undefined; + // Live counter shared with coalesced waiters (see InFlightRestore + // doc comment). Mutated synchronously by the coalesce branch above + // and read once by the IIFE when seeding `entry.attachCount`. + const coalesceState = { count: 0 }; + const promise = (async (): Promise => { + pendingRestoreEvents.set(req.sessionId, restoreEvents); + ci = await ensureChannel(); + ci.pendingRestoreIds.add(req.sessionId); + // Mark this id as in-flight restore BEFORE the ACP + // `loadSession`/`unstable_resumeSession` call. Restore-time + // guardrail events arriving during that ACP call hit + // `bufferEarlyEvent` BEFORE the post-restore + // `createSessionEntry -> drainEarlyEvents` clears the tombstone, + // so without this allow-list the tombstone would silently drop + // them. Cleared in the matching `finally` below. + ci.client.markRestoreInFlight(req.sessionId); + // Restore is a low-frequency one-shot path, so we register a + // fresh `channel.exited` listener per call instead of going + // through `getTransportClosedReject` (which exists to keep + // sendPrompt's per-session listener count at 1 over the + // session's lifetime). The listener is bound to this restore's + // race only — once the race settles, no new awaits attach to + // it, so there's no listener leak across restores. + const transportClosed = ci.channel.exited.then(() => { + throw new BridgeChannelClosedError(`during session/${action}`); + }); + // Suppress the dangling rejection if `withTimeout` wins the + // race below: `transportClosed` then stays pending, and a + // later `channel.exited` settle fires the inner `throw` with + // no observer attached. Node 22 logs `unhandledRejection`; + // under `--unhandled-rejections=throw` (common in container + // deployments) the daemon process crashes. The `Promise.race` + // path's own consumer below catches the rejection in the + // try/catch, so the suppressed rejection here is the + // race-loser case only. + transportClosed.catch(() => {}); + let state: BridgeSessionState; + try { + if (action === 'load') { + state = await Promise.race([ + withTimeout( + ci.connection.loadSession({ + sessionId: req.sessionId, + cwd: workspaceKey, + // Restore path drops per-request `mcpServers` (matches + // `doSpawn`); daemon-wide MCP comes from settings on + // the agent side. The SDK's `RestoreSessionRequest` + // intentionally has no `mcpServers` field for the + // same reason. + mcpServers: [], + }), + initTimeoutMs, + 'loadSession', + ), + transportClosed, + ]); + } else { + state = await Promise.race([ + withTimeout( + ci.connection.unstable_resumeSession({ + sessionId: req.sessionId, + cwd: workspaceKey, + mcpServers: [], + }), + initTimeoutMs, + 'resumeSession', + ), + transportClosed, + ]); + } + } catch (err) { + restoreEvents.close(); + if (isAcpSessionResourceNotFound(err, req.sessionId)) { + throw new SessionNotFoundError(req.sessionId); + } + if ( + ci.sessionIds.size === 0 && + ci.pendingRestoreIds.size === 1 && + ci.pendingRestoreIds.has(req.sessionId) + ) { + ci.isDying = true; + await ci.channel.kill().catch(() => { + /* best-effort — channel.exited handler still runs */ + }); + } + throw err; + } + + if (shuttingDown) { + restoreEvents.close(); + throw new Error('AcpSessionBridge is shutting down'); + } + if (ci.isDying || !aliveChannels.has(ci)) { + restoreEvents.close(); + throw new Error( + `Session ${req.sessionId} restored on a closed agent channel`, + ); + } + const racedEntry = byId.get(req.sessionId); + if (racedEntry) { + restoreEvents.close(); + // Self + any coalescers we accumulated while the restore was + // in flight. Coalescers must not bump attachCount themselves + // (they read it off the registered entry on the next tick). + racedEntry.attachCount += 1 + coalesceState.count; + const clientId = registerClient(racedEntry, req.clientId); + return { + sessionId: racedEntry.sessionId, + workspaceCwd: racedEntry.workspaceCwd, + attached: true, + clientId, + createdAt: racedEntry.createdAt, + state: racedEntry.restoreState ?? {}, + ...replayFieldsFor(racedEntry, action), + }; + } + + const entry = createSessionEntry( + ci, + req.sessionId, + workspaceKey, + restoreEvents, + ); + entry.restoreState = state; + seedSnapshotCaches(entry, state); + const clientId = registerClient(entry, req.clientId); + // Fold synchronous coalesce reservations into the new entry's + // `attachCount`. By this point all coalescers that beat us must + // have hit the inFlightRestores branch and bumped + // `coalesceState.count`; later coalescers will hit the byId + // early-return path instead and increment `entry.attachCount` + // directly. + entry.attachCount = coalesceState.count; + registeredEntry = entry; + // Explicit `session/load` / `session/resume` is "give me THIS + // id"; it must NOT become the implicit attach target for + // subsequent omitted-id `POST /session` callers under `single` + // scope. Those callers asked for "any default", and silently + // joining a restored live history would surprise them. + // `defaultEntry` is reserved for sessions created through + // `doSpawn` under `'single'` scope. + return { + sessionId: entry.sessionId, + workspaceCwd: entry.workspaceCwd, + attached: false, + clientId, + createdAt: entry.createdAt, + state, + ...replayFieldsFor(entry, action), + }; + })().finally(() => { + ci?.pendingRestoreIds.delete(req.sessionId); + // Pair with `markRestoreInFlight`. Once the IIFE settles, either + // `createSessionEntry` ran (`drainEarlyEvents` already cleared + // the tombstone) or the restore failed (handled below). + ci?.client.clearRestoreInFlight(req.sessionId); + pendingRestoreEvents.delete(req.sessionId); + if (!registeredEntry) { + restoreEvents.close(); + // On restore failure, purge any guardrail events that the + // child buffered during this restore window AND re-tombstone + // the id. Without this, a subsequent successful restore for + // the same id within 60s would drain stale frames into the + // new session. `markSessionClosed` already does both: refresh + // tombstone + delete `earlyEvents[id]`. + ci?.client.markSessionClosed(req.sessionId); + } + }); + + inFlightRestores.set(req.sessionId, { action, promise, coalesceState }); + try { + return await promise; + } finally { + inFlightRestores.delete(req.sessionId); + } + } + + async function closeSessionImpl( + sessionId: string, + context?: BridgeClientRequestContext, + closeOpts?: CloseSessionOpts, + ): Promise { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + let originatorClientId: string | undefined; + if (context?.clientId !== undefined) { + originatorClientId = resolveTrustedClientId(entry, context.clientId); + } + const reason = closeOpts?.reason ?? 'client_close'; + writeStderrLine( + `qwen serve: closing session ${JSON.stringify(sessionId)}` + + ` (reason: ${reason})` + + (originatorClientId + ? ` by client ${JSON.stringify(originatorClientId)}` + : ''), + ); + telemetry.event('session.close', { + 'qwen-code.daemon.bridge.operation': 'session.close', + 'session.id': sessionId, + 'session.close.reason': reason, + }); + if (defaultEntry === entry) defaultEntry = undefined; + // HAZARD: Resolve the channel via `channelInfoForEntry(entry)` (search + // `aliveChannels` for the entry's actual channel) instead of the + // module-scoped `channelInfo` (the CURRENT attach target). The two + // diverge during the channel-overlap window — A dying, B freshly + // spawned as `channelInfo` — where capturing `channelInfo` would + // (1) skip the `sessionIds.delete()` since `B.channel !== + // entry.channel`, and (2) call `markSessionClosed` on B's client + // instead of A's. The regression test is single-channel smoke only + // and WILL NOT fail if this reverts to module-scoped channelInfo. + // Keep `channelInfoForEntry(entry)` until a deterministic overlap + // test lands. + const ci = channelInfoForEntry(entry); + if (!ci) { + writeStderrLine( + `qwen serve: closeSession channelInfoForEntry returned undefined ` + + `for session ${JSON.stringify(sessionId)} — channel cleanup skipped (entry's channel already torn down)`, + ); + } + if (ci && ci.channel === entry.channel) { + ci.sessionIds.delete(sessionId); + } + // Synchronous teardown block — intentionally diverges from killSession: + // tombstone + event publish + bus close all run BEFORE + // notifyAgentSessionClose, so concurrent callers see + // byId.get(sessionId) === undefined and throw SessionNotFoundError, + // and late agent frames arriving during the RPC are dropped by the + // closed bus. + permissionMediator.forgetSession(sessionId); + entry.pendingPermissionIds.clear(); + byId.delete(sessionId); + telemetry.metrics?.sessionLifecycle('close'); + // Tombstone the closed sessionId so any late `extNotification` + // from the (now-defunct) child can't seed the early-event buffer + // and leak into a future load/resume of the same persisted id. + ci?.client.markSessionClosed(sessionId); + try { + entry.events.publish({ + type: 'session_closed', + data: { + sessionId, + reason, + // `data.closedBy` is kept for back-compat with existing + // wire consumers; new code should read envelope-level + // `originatorClientId` (matches `session_metadata_updated`, + // `model_switched`, `approval_mode_changed`, etc.). + ...(originatorClientId ? { closedBy: originatorClientId } : {}), + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } catch { + /* bus already closed */ + } + // `session_closed` is terminal. Close the bus before ACP cancel so any + // late cancellation frames from the agent are intentionally dropped. + entry.events.close(); + await notifyAgentSessionClose(entry, ci, 'closeSession'); + try { + await telemetry.withSpan( + 'session.close.cancel_active_prompt', + { + 'qwen-code.daemon.bridge.operation': + 'session.close.cancel_active_prompt', + 'session.id': sessionId, + }, + async () => await entry.connection.cancel({ sessionId }), + ); + } catch { + /* no active prompt or session already torn down */ + } + if (ci && ci.sessionIds.size === 0 && ci.pendingRestoreIds.size === 0) { + await startIdleTimer(ci, `closeSession "${sessionId}"`); + } + } + + startSessionReaper(); + + return { + get sessionCount() { + return byId.size; + }, + + isChannelLive() { + return !!liveChannelInfo(); + }, + + get pendingPermissionCount() { + return permissionMediator.pendingCount; + }, + + get permissionPolicy() { + return permissionMediator.policy; + }, + + async loadSession(req) { + return restoreSession('load', req); + }, + + async resumeSession(req) { + return restoreSession('resume', req); + }, + + async spawnOrAttach(req) { + if (shuttingDown) { + // `runQwenServe.close()` calls `bridge.shutdown()` BEFORE + // `server.close()`. During that window, established HTTP + // connections can still hit `POST /session`. Refuse here so + // late-arrivers don't spawn children the shutdown path won't + // see — they'd otherwise leak past `process.exit(0)`. + throw new Error('AcpSessionBridge is shutting down'); + } + // Fast-path the common case: clients pre-flight `caps.workspaceCwd` + // and post back the exact same string, so the equality check + // saves a `realpathSync.native` syscall per spawnOrAttach. The + // omit-cwd path in `server.ts` also synthesizes `cwd = + // boundWorkspace` before calling here, so it hits this branch + // too. Falls through to the full canonicalize when the client + // sent a non-canonical alias (`/work/./bound`, mixed casing on + // case-insensitive FS, a symlinked aliased path, …) — that + // still needs the realpath to compare correctly. + const workspaceKey = resolveWorkspaceKey(req.workspaceCwd); + + // Resolve the effective scope for THIS call. A per-request + // `req.sessionScope` overrides the daemon-wide default; omitting + // it falls back to `defaultSessionScope`. The string-validation + // happens here (rather than at the route layer alone) so direct + // callers — tests, embeds, future entry points — can't bypass it. + if ( + req.sessionScope !== undefined && + req.sessionScope !== 'single' && + req.sessionScope !== 'thread' + ) { + throw new InvalidSessionScopeError(req.sessionScope); + } + const effectiveScope = req.sessionScope ?? defaultSessionScope; + + if (effectiveScope === 'single') { + const existing = defaultEntry; + if (existing) { + // BRSCi: bump attach counter BEFORE any await so the + // spawn-owner's disconnect reaper (server.ts: + // `requireZeroAttaches: true`) sees this attach even when + // we yield on the model-switch below. Increment is + // synchronous → atomic against the killSession + // sync-prefix check. + // + // BVryk + BWGSL: counter is NOT strictly monotonic any + // more — `detachClient()` decrements it to roll back an + // attach whose HTTP response couldn't be written + // The race-guard invariant we still + // hold is "attachCount reflects the number of attaching + // clients whose response was written or is about to be + // written"; decrementing is the symmetric cleanup for + // attaches that turned out to be fictitious. The + // ordering guarantee that matters for the killSession + // race is "bump runs before any await inside this + // microtask," which is what we get here. + existing.attachCount++; + const clientId = registerClient(existing, req.clientId); + // If the caller passed a modelServiceId on attach, the session + // may currently be running a DIFFERENT model. Honor the request + // by issuing setSessionModel — same call we'd use on + // /session/:id/model. Surfaces a `model_switched` event so + // every attached client sees the change. If the new model is + // rejected, propagate as a spawn-style error rather than + // silently returning an attach-with-stale-model. + if (req.modelServiceId) { + // Swallow: matches the create-session catch in `doSpawn` + // below — a model-switch rejection on an already-running + // session must NOT 500 the attach (the session is fully + // operational on its current model; tearing it down or + // returning an error without the sessionId would deny + // the caller any way to recover). The + // `model_switch_failed` SSE event is the visible signal. + await applyModelServiceId( + existing, + req.modelServiceId, + initTimeoutMs, + clientId, + ).catch(() => {}); + } + return { + sessionId: existing.sessionId, + workspaceCwd: existing.workspaceCwd, + attached: true, + clientId, + createdAt: existing.createdAt, + }; + } + // Coalesce: if another caller is already mid-spawn for this same + // workspace, await their result. The reporter's call appears as an + // attach (the spawn was someone else's, not theirs). If the + // reporter asked for a different modelServiceId than the spawn + // chose, apply it now. + const inFlight = inFlightSpawns.get(workspaceKey); + if (inFlight) { + const session = await inFlight; + // BRSCi: bump attach counter SYNCHRONOUSLY in the same + // microtask the in-flight spawn resolves to us, BEFORE + // any further await. The spawn-owner's route handler + // microtask (which calls `killSession({requireZeroAttaches})`) + // runs after our spawnOrAttach() resolves; the ordering + // guarantee is "every attach-bump runs before the + // matching killSession sync prefix" only if the bump is + // the first sync step after `await inFlight`. Doing the + // model-switch await first re-opens the race. + const attachedEntry = byId.get(session.sessionId); + if (attachedEntry) attachedEntry.attachCount++; + // BX9_U: even with the BRSCi bump-before-await ordering, + // there are still adversarial paths where the entry could + // be torn down between `await inFlight` resolving and our + // continuation running (e.g. channel.exited firing during + // a crash spawn, or a direct bridge.killSession call from + // outside the route handler). In those cases byId.get() + // returned undefined. Fail loud with a descriptive error + // so the caller can distinguish "immediate agent death" + // from a stale sessionId and retry into a fresh spawn. + if (!attachedEntry) { + throw new SessionNotFoundError( + session.sessionId, + 'the agent child likely crashed during initialization — retry to spawn a new session', + ); + } + const clientId = registerClient(attachedEntry, req.clientId); + if (req.modelServiceId) { + // Same swallow as above — we picked up an in-flight + // spawn, the session is real, model-switch failure + // shouldn't deny us the sessionId. + await applyModelServiceId( + attachedEntry, + req.modelServiceId, + initTimeoutMs, + clientId, + ).catch(() => {}); + } + return { ...session, attached: true, clientId }; + } + } + + // Cap check: count both registered sessions and in-flight spawns + // (a fresh-spawn races that's about to register hasn't hit + // `byId` yet but should still count toward the limit). Attaches + // returned above bypass this — only NEW children are gated. + if ( + byId.size + inFlightSpawns.size + inFlightRestores.size >= + maxSessions + ) { + throw new SessionLimitExceededError(maxSessions); + } + + const promise = doSpawn(req.modelServiceId, effectiveScope, req.clientId); + // Track in-flight spawns regardless of scope. Under `single` + // this also serves the coalescing path above (a parallel + // `spawnOrAttach` finds the entry and waits for the same + // promise). Under `thread` we don't need coalescing — every + // call gets its own session — but `shutdown()` snapshots + // `inFlightSpawns.values()` to know which spawns to await + // for graceful tear-down. Without this, a `thread`-scope + // shutdown returns before in-progress spawns finish their + // child cleanup, surfacing stderr noise after the daemon + // claimed graceful shutdown. Use a unique key per spawn so + // simultaneous thread-scope spawns don't collide on the + // workspace key. + const tracker = + effectiveScope === 'single' + ? workspaceKey + : `${workspaceKey}#${randomUUID()}`; + inFlightSpawns.set(tracker, promise); + try { + return await promise; + } finally { + // Always clear the in-flight slot whether the spawn resolved + // or rejected — leaving a rejected promise behind would + // poison every future coalescing-path call for this + // workspace (single-scope) or grow unbounded (thread-scope). + inFlightSpawns.delete(tracker); + } + }, + + async sendPrompt(sessionId, req, signal, context) { + opts.onDiagnosticLine?.( + `qwen serve: bridge sendPrompt for session=${sessionId}`, + 'info', + ); + const capturedContext = telemetry.captureContext(); + const queuedAt = Date.now(); + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const originatorClientId = resolveTrustedClientId( + entry, + context?.clientId, + ); + // Pre-aborted: skip the queue entirely. Without this the prompt + // chains onto promptQueue, waits its turn, and the FIFO worker + // checks `signal.aborted` only AFTER reaching the head — wasted + // queue churn on every retry-after-abort, plus a confusing trace + // where the prompt appears to "run" before erroring. + if (signal?.aborted) { + throw new DOMException('Prompt aborted', 'AbortError'); + } + // Force the body's sessionId to match the routing id — a client that + // sent a stale id in the body would otherwise be dispatched to the + // wrong agent process. + const result = entry.promptQueue.then(() => + telemetry.runWithContext(capturedContext, async () => { + const queueWaitMs = Date.now() - queuedAt; + telemetry.metrics?.promptQueueWait(queueWaitMs); + const dispatchStartMs = Date.now(); + try { + return await telemetry.withSpan( + 'prompt.dispatch', + { + 'qwen-code.daemon.bridge.operation': 'prompt.dispatch', + 'session.id': sessionId, + 'qwen-code.daemon.prompt.queue_wait_ms': queueWaitMs, + ...(context?.clientId + ? { 'qwen-code.client_id': context.clientId } + : {}), + }, + async () => { + const normalized: PromptRequest = telemetry.injectPromptContext( + { + ...req, + sessionId, + }, + ); + // If the caller aborted while we were queued behind earlier + // prompts, don't even start this one. + if (signal?.aborted) { + throw new DOMException('Prompt aborted', 'AbortError'); + } + entry.promptActive = true; + entry.sessionLastSeenAt = Date.now(); + if (originatorClientId === undefined) { + delete entry.activePromptOriginatorClientId; + } else { + entry.activePromptOriginatorClientId = originatorClientId; + } + try { + // Echo the user prompt to the session bus so other SSE-subscribed + // clients see the input alongside the agent response. + // + // The interactive prompt path was the only one not emitting + // `user_message_chunk` — `Session#executePrompt` (the agent + // side) forwards the prompt directly to the LLM; the cron path + // (Session.ts:1402) and `HistoryReplayer` (line 65) emit it + // explicitly. Without this echo, multi-client UIs only saw + // assistant text from peer prompts — no record of who said what. + // + // Originator dedup: SDK consumers' `normalizeDaemonEvent` with + // `suppressOwnUserEcho: true` filters the echo when + // `event.originatorClientId === opts.clientId`. So the + // originator's local UI doesn't double-render its own input. + // + // Multi-modal: one envelope per content block. Non-text blocks + // pass through verbatim (the agent's Core multimodal echo is a + // for now the common text path is the immediate fix. + entry.cancelBroadcast = false; + echoPromptToSessionBus(entry, normalized, originatorClientId); + } catch (echoErr) { + entry.promptActive = false; + delete entry.activePromptOriginatorClientId; + throw echoErr; + } + const promptPromise = entry.connection + .prompt(normalized) + .finally(() => { + entry.promptActive = false; + entry.sessionLastSeenAt = Date.now(); + delete entry.activePromptOriginatorClientId; + if ( + entry.clientIds.size === 0 && + entry.events.subscriberCount === 0 && + byId.has(sessionId) + ) { + void closeSessionImpl(sessionId, undefined, { + reason: 'last_client_detached', + }).catch((err) => { + writeStderrLine( + `qwen serve: deferred close-on-prompt-complete failed for ` + + `${JSON.stringify(sessionId)}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, + ); + }); + } + }); + + // Race against channel termination: if the underlying transport + // dies (child crashed, stream torn down) WHILE the prompt is in + // flight, the SDK's pending-request promise can hang because the + // wire never delivers a response. Make the prompt fail-fast in + // that case so the per-session FIFO doesn't poison the next + // queued prompt with an unbounded await. See + // `getTransportClosedReject` for the single-listener invariant. + // + // FIXME(stage-2): no absolute prompt deadline. A buggy agent + // that ignores `cancel()` while keeping the channel alive can + // hold this race open indefinitely — the abort path fires + // `cancel()` and resolves pending permissions, but the + // `promptPromise` itself only settles when the agent + // cooperates. Stage 2 should add a configurable per-prompt + // wall clock (e.g. `--prompt-deadline 30m`) into this race so + // a wedged agent can't slow-leak prompt promises. Tracked + // as a follow-up. + const racedPromise = Promise.race([ + promptPromise, + getTransportClosedReject(entry), + ]); + + // The user echo (`echoPromptToSessionBus`) was already published + // BEFORE the forward. If the forward itself fails (transport died, + // ACP child error) and it wasn't a user-initiated cancel that + // already broadcast, peers would be stuck with no terminal signal. + // Emit a compensating `prompt_cancelled{reason:'forward_failed'}` + // so the turn visibly ends. The `...Once` latch dedups against + // the abort path. Side-effect only — the caller's `racedPromise` + // reference still surfaces the rejection. + void racedPromise + .then( + () => {}, + (err) => { + writeStderrLine( + `sendPrompt: forward failed for session ${sessionId}: ${extractErrorMessage(err)}`, + ); + broadcastPromptCancelledOnce( + entry, + sessionId, + originatorClientId, + 'forward_failed', + ); + cancelPendingForSession(sessionId); + entry.connection.cancel({ sessionId }).catch(() => {}); + }, + ) + .catch(() => {}); + + if (!signal) return racedPromise; + const onAbort = () => { + broadcastPromptCancelledOnce( + entry, + sessionId, + originatorClientId, + ); + cancelPendingForSession(sessionId); + entry.connection.cancel({ sessionId }).catch(() => {}); + }; + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + racedPromise + .finally(() => signal.removeEventListener('abort', onAbort)) + .catch(() => {}); + } + return racedPromise; + }, + ); + } finally { + telemetry.metrics?.promptDuration(Date.now() - dispatchStartMs); + } + }), + ); + const promptId = context?.promptId; + result.then( + (promptResult) => { + broadcastTurnComplete( + entry, + sessionId, + promptResult, + promptId, + originatorClientId, + ); + }, + (err) => { + if (err instanceof DOMException && err.name === 'AbortError') return; + broadcastTurnError( + entry, + sessionId, + err, + promptId, + originatorClientId, + ); + }, + ); + // Tail swallows failures so subsequent prompts still run. The caller + // still sees rejections on its own `result` reference. + entry.promptQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + }, + + async cancelSession(sessionId, req, context) { + opts.onDiagnosticLine?.( + `qwen serve: bridge cancelSession for session=${sessionId}`, + 'info', + ); + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const cancelOriginatorClientId = resolveTrustedClientId( + entry, + context?.clientId, + ); + // Broadcast `prompt_cancelled` so other SSE-subscribed clients see + // the cancel as a first-class event rather than inferring it from + // the absence of further `agent_message_chunk` frames. Mirrors + // `session_closed` — same audit gap (cross-client sync audit, + // 2026-05-24). Published before the ACP cancel forward (see the + // "cancel requested, not confirmed" semantic in + // `broadcastPromptCancelled`). + // + // Unconditional by design: not gated on `activePromptOriginatorClientId` + // because that field is only set when the active prompt carried an + // originator — gating on it would drop the broadcast for anonymous + // active prompts. A cancel against a genuinely idle session is a + // harmless no-op that consumers treat idempotently. + // + // The pending-permission resolution below intentionally omits the + // originator stamp (those resolutions are system-initiated, not + // user-voted); this top-level `prompt_cancelled` carries the + // cancelling client so peer UIs can attribute it. + // + // `...Once` dedups against the `sendPrompt` abort path so a client + // that POSTs /cancel and then drops its socket doesn't emit two + // `prompt_cancelled` frames for the same turn. The latch resets at + // the next prompt start, so a later turn still broadcasts. + broadcastPromptCancelledOnce(entry, sessionId, cancelOriginatorClientId); + // ACP spec: cancelling a prompt MUST resolve outstanding + // requestPermission calls with outcome.cancelled. Do this *before* + // forwarding the notification so the agent's wind-down sees the + // resolutions. + cancelPendingForSession(sessionId); + // Cancel intentionally bypasses the prompt queue: it's a notification + // that the agent uses to wind down the *currently active* prompt, not + // something to wait behind queued work. + // + // CONTRACT (multi-prompt clients): cancel affects ONLY the active + // prompt. Any prompts the client previously POSTed and that are + // still queued behind the active one will continue to execute + // after the active prompt resolves with `stopReason: 'cancelled'`. + // This matches ACP's "cancel is a wind-down notification for the + // current turn" semantics — multi-prompt queueing is a daemon + // convenience, not in spec, so we don't extend cancel's reach + // there. Clients that want a hard stop should stop posting new + // prompts and call `cancelSession` after their last prompt + // resolves, or kill the session via the channel-exit path. + const notif: CancelNotification = req + ? { ...req, sessionId } + : { sessionId }; + telemetry.metrics?.cancelled(); + await telemetry.withSpan( + 'session.cancel', + { + 'qwen-code.daemon.bridge.operation': 'session.cancel', + 'session.id': sessionId, + }, + async () => { + try { + await entry.connection.cancel(notif); + } catch (err) { + if (isNotCurrentlyGeneratingCancelError(err)) return; + throw err; + } + }, + ); + }, + + subscribeEvents(sessionId, subOpts) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const raw = entry.events.subscribe(subOpts); + if (!subOpts?.snapshot) return raw; + + // A5: wrap the iterator to inject a synthetic `session_snapshot` + // frame so a freshly attached / reconnecting client can seed its + // side-channel reducer without an extra round-trip. Captures cached + // state synchronously at yield time. + // + // The bus only emits `replay_complete` on the `Last-Event-ID` + // resume path (`eventBus.subscribe` gates the whole replay block on + // `opts.lastEventId !== undefined`). A fresh connection has no + // `Last-Event-ID`, so it never sees `replay_complete` — keying the + // snapshot solely off that sentinel silently no-ops on the primary + // use case (initial attach). So inject up front when there is no + // resume cursor, and otherwise after `replay_complete` so the + // client applies replayed deltas before the snapshot seeds state. + const snapshotFrame = (): BridgeEvent => ({ + v: EVENT_SCHEMA_VERSION, + type: 'session_snapshot', + data: { + sessionId: entry.sessionId, + currentModelId: entry.currentModelId ?? null, + currentApprovalMode: entry.currentApprovalMode ?? null, + }, + }); + async function* withSnapshot(): AsyncIterable { + let injected = false; + if (subOpts?.lastEventId === undefined) { + yield snapshotFrame(); + injected = true; + } + for await (const event of raw) { + yield event; + if (!injected && event.type === 'replay_complete') { + yield snapshotFrame(); + injected = true; + } + } + } + return withSnapshot(); + }, + + getSessionLastEventId(sessionId) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + return entry.events.lastEventId; + }, + + respondToPermission(requestId, response, context) { + // Legacy workspace-level vote route. Look up the session via + // mediator's resolved+pending peek, forward to session-scoped + // handler if both ids agree. + const sessionId = permissionMediator.peekSessionFor(requestId); + // Also check `byId.has(sessionId)`. The mediator's resolved LRU + // survives session teardown by design; without this guard, + // `respondToSessionPermission` would throw `SessionNotFoundError` + // once `byId.delete(sessionId)` ran. + if (sessionId === undefined || !byId.has(sessionId)) { + // Short-circuit to false (404) BEFORE clientId validation when + // the requestId is unknown. Without this, a probe with a + // fabricated clientId could distinguish "session exists with + // these clients" (400) from "no such request" (404), creating + // a cross-session client-registration oracle. + writeStderrLine( + `qwen serve: legacy permission vote ${JSON.stringify(requestId)} ` + + `has no live session (peek returned ${JSON.stringify(sessionId)}); ` + + `returning 404.`, + ); + return false; + } + return this.respondToSessionPermission( + sessionId, + requestId, + response, + context, + ); + }, + + respondToSessionPermission(sessionId, requestId, response, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + // Cross-session reject: a vote whose requestId belongs to a + // DIFFERENT session must return false (404) WITHOUT validating + // `context.clientId` against this session's registry. + const actualSessionId = permissionMediator.peekSessionFor(requestId); + if (actualSessionId !== undefined && actualSessionId !== sessionId) { + teeServeDebugLine( + `rejected permission vote ${JSON.stringify(requestId)} ` + + `for session ${JSON.stringify(sessionId)}; request belongs to ` + + `session ${JSON.stringify(actualSessionId)}.`, + ); + return false; + } + // Error precedence: when `peekSessionFor` returns `undefined` + // (timed out / LRU-evicted / never registered), return `false` + // (404) BEFORE any clientId validation. Without this guard, + // execution falls through to `resolveTrustedClientId` which + // throws `InvalidClientIdError` (400), leaking session-exists + // information. Logged unconditionally so operators can correlate + // unexpected 404s without debug mode. + if (actualSessionId === undefined) { + writeStderrLine( + `qwen serve: rejected permission vote ${JSON.stringify(requestId)} ` + + `for session ${JSON.stringify(sessionId)}; mediator has no ` + + `pending or resolved record (unknown / timed out / LRU-evicted).`, + ); + return false; + } + // requestId matches THIS session — only now validate clientId. + // `resolveTrustedClientId` throws `InvalidClientIdError` + // (mapped to 400 by the route) when the supplied id isn't in + // `entry.clientIds`. + const trustedClientId = resolveTrustedClientId(entry, context?.clientId); + // Voter cancel sentinel: when the ACP body is + // `{outcome: 'cancelled'}`, the wire frame doesn't carry an + // `optionId`. Map it to the mediator-internal sentinel so + // the mediator can resolve the pending as cancelled + // regardless of the active policy. + // + // The mediator recognizes `CANCEL_VOTE_SENTINEL` BEFORE + // validating the option against `allowedOptionIds`, so a wire + // client sending `{outcome: 'selected', optionId: '__cancelled__'}` + // would short-circuit all policy dispatch. Enforce the + // precondition here — the collision-defense at request issue + // time already prevents agents from advertising the sentinel + // as an option, so this guard closes the only remaining vector. + if ( + response.outcome.outcome === 'selected' && + response.outcome.optionId === CANCEL_VOTE_SENTINEL + ) { + throw new InvalidPermissionOptionError(requestId, CANCEL_VOTE_SENTINEL); + } + const optionId = + response.outcome.outcome === 'selected' + ? response.outcome.optionId + : CANCEL_VOTE_SENTINEL; + const voterMetadata = extractPermissionResponseMetadata(response); + const outcome = permissionMediator.vote({ + requestId, + sessionId, + clientId: trustedClientId, + optionId, + receivedAtMs: Date.now(), + fromLoopback: context?.fromLoopback ?? false, + ...(voterMetadata ? { metadata: voterMetadata } : {}), + }); + switch (outcome.kind) { + case 'resolved': + return true; + case 'recorded': + // Consensus-policy intermediate vote. + return true; + case 'already_resolved': + // Mediator already emitted `permission_already_resolved`. + return false; + case 'unknown_request': + teeServeDebugLine( + `rejected permission vote ${JSON.stringify(requestId)} ` + + `for session ${JSON.stringify(sessionId)}; mediator has no ` + + `pending or resolved record.`, + ); + return false; + case 'forbidden': + throw new PermissionForbiddenError( + requestId, + sessionId, + outcome.reason, + ); + default: { + const _exhaustive: never = outcome; + throw new Error( + `unreachable PermissionVoteOutcome: ${JSON.stringify(_exhaustive)}`, + ); + } + } + }, + + async branchSession(sessionId, req, context) { + if (shuttingDown) throw new Error('AcpSessionBridge is shutting down'); + + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + + let originatorClientId: string | undefined; + if (context?.clientId !== undefined) { + originatorClientId = resolveTrustedClientId(entry, context.clientId); + } + + const branchResult = entry.promptQueue.then(async () => { + if (entry.promptActive) { + throw new BranchWhilePromptActiveError(sessionId); + } + + if ( + byId.size + inFlightSpawns.size + inFlightRestores.size >= + maxSessions + ) { + throw new SessionLimitExceededError(maxSessions); + } + + const ci = await ensureChannel(); + const result = (await withTimeout( + ci.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionBranch, { + sessionId, + cwd: boundWorkspace, + name: req.name, + }), + initTimeoutMs, + 'branchSession', + )) as { newSessionId: string; title: string }; + + if ( + !result || + typeof result.newSessionId !== 'string' || + typeof result.title !== 'string' + ) { + throw new Error( + `branchSession: agent returned invalid response: ${JSON.stringify(result)}`, + ); + } + + let restored; + try { + restored = await restoreSession('resume', { + sessionId: result.newSessionId, + workspaceCwd: boundWorkspace, + clientId: context?.clientId, + }); + } catch (restoreErr) { + writeStderrLine( + `qwen serve: branchSession resume failed for ${result.newSessionId}, attempting cleanup...`, + ); + try { + await ci.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionClose, + { sessionId: result.newSessionId, cwd: boundWorkspace }, + ); + } catch (cleanupErr) { + writeStderrLine( + `qwen serve: branchSession cleanup of ${result.newSessionId} failed: ${cleanupErr instanceof Error ? cleanupErr.message : cleanupErr}`, + ); + } + throw restoreErr; + } + + const newEntry = byId.get(result.newSessionId); + if (newEntry) newEntry.displayName = result.title; + + const eventData = { + sourceSessionId: sessionId, + newSessionId: result.newSessionId, + displayName: result.title, + }; + const branchEnvelope = { + type: 'session_branched' as const, + data: eventData, + ...(originatorClientId ? { originatorClientId } : {}), + }; + entry.events.publish(branchEnvelope); + broadcastWorkspaceEvent(branchEnvelope, sessionId); + + return { + ...restored, + title: result.title, + forkedFrom: { + sessionId, + title: entry.displayName ?? sessionId.slice(0, 8), + }, + }; + }); + entry.promptQueue = branchResult.then( + () => undefined, + () => undefined, + ); + return branchResult; + }, + + async closeSession(sessionId, context, closeOpts) { + return closeSessionImpl(sessionId, context, closeOpts); + }, + + updateSessionMetadata(sessionId, metadata, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + // Capture the trusted originator so the broadcast envelope can + // attribute the change to a specific client (parity with + // `model_switched`, `approval_mode_changed`, etc., which stamp + // envelope-level `originatorClientId`). Prior to this, the + // metadata broadcast had no originator stamp at all — UIs + // couldn't tell which client renamed the session. + const metadataOriginatorClientId = + context?.clientId !== undefined + ? resolveTrustedClientId(entry, context.clientId) + : undefined; + if (metadata.displayName !== undefined) { + if ( + typeof metadata.displayName !== 'string' || + metadata.displayName.length > MAX_DISPLAY_NAME_LENGTH + ) { + throw new InvalidSessionMetadataError( + 'displayName', + `must be a string of at most ${MAX_DISPLAY_NAME_LENGTH} characters`, + ); + } + if (hasControlCharacter(metadata.displayName)) { + throw new InvalidSessionMetadataError( + 'displayName', + 'must not contain control characters', + ); + } + const nextDisplayName = metadata.displayName || undefined; + if (entry.displayName !== nextDisplayName) { + entry.displayName = nextDisplayName; + writeStderrLine( + `qwen serve: updated session metadata ${JSON.stringify(sessionId)} ` + + `displayName=${entry.displayName === undefined ? 'cleared' : 'set'}` + + (context?.clientId + ? ` by client ${JSON.stringify(context.clientId)}` + : ''), + ); + try { + entry.events.publish({ + type: 'session_metadata_updated', + data: { sessionId, displayName: entry.displayName }, + ...(metadataOriginatorClientId + ? { originatorClientId: metadataOriginatorClientId } + : {}), + }); + } catch { + /* bus already closed */ + } + } + } + return { displayName: entry.displayName }; + }, + + listWorkspaceSessions(workspaceCwd) { + if (!path.isAbsolute(workspaceCwd)) return []; + const key = + workspaceCwd === boundWorkspace + ? boundWorkspace + : canonicalizeWorkspace(workspaceCwd); + if (key !== boundWorkspace) return []; + const out: BridgeSessionSummary[] = []; + for (const entry of byId.values()) { + if (entry.workspaceCwd === key) { + out.push({ + sessionId: entry.sessionId, + workspaceCwd: entry.workspaceCwd, + createdAt: entry.createdAt, + displayName: entry.displayName, + clientCount: entry.clientIds.size, + hasActivePrompt: entry.promptActive, + }); + } + } + return out; + }, + + recordHeartbeat(sessionId, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + // Validate the optional client id BEFORE bumping any timestamp so + // an unknown client doesn't get to advance the per-session + // watermark — that would let an attacker with a valid bearer + // token mask client absence by spamming heartbeats with random + // ids. `resolveTrustedClientId` throws `InvalidClientIdError`, + // which the route layer maps to `400 invalid_client_id`. + const clientId = resolveTrustedClientId(entry, context?.clientId); + const lastSeenAt = Date.now(); + entry.sessionLastSeenAt = lastSeenAt; + if (clientId !== undefined) { + entry.clientLastSeenAt.set(clientId, lastSeenAt); + } + return { + sessionId: entry.sessionId, + ...(clientId !== undefined ? { clientId } : {}), + lastSeenAt, + }; + }, + + getHeartbeatState(sessionId) { + const entry = byId.get(sessionId); + if (!entry) return undefined; + // Snapshot the client map so callers can't mutate the live one; + // `sessionLastSeenAt` is undefined for sessions that have never + // received a heartbeat (the typical state right after spawn). + return { + ...(entry.sessionLastSeenAt !== undefined + ? { sessionLastSeenAt: entry.sessionLastSeenAt } + : {}), + clientLastSeenAt: new Map(entry.clientLastSeenAt), + }; + }, + + publishWorkspaceEvent(event) { + // Workspace-level mutations (memory writes / agent CRUD) need a + // fan-out path that doesn't require a session id. Iterate every + // live session's bus best-effort — a closed bus (mid-shutdown, + // or evicted under load) is silently skipped. + // + // The route handler's contract is "read-after-write" and any SSE + // subscriber that misses the event can re-fetch via the route's + // GET sibling. + // + // Per-entry exceptions go to stderr in normal operation, but + // are downgraded to the debug channel when `shuttingDown` is + // true. `EventBus.publish` is documented never to throw, so + // anything landing here in normal ops is unexpected — silencing + // via QWEN_SERVE_DEBUG would let a regression succeed at the + // route layer while SSE subscribers stop seeing events. + // + // PR #4255 fold-in 9: track per-session success/fail. A + // closed-bus return (`undefined` from `EventBus.publish` — + // see eventBus.ts:195-207) counts as a failure (operator + // signal), distinct from a thrown exception (regression + // signal). When zero sessions are active OR every active bus + // dropped the event, we elevate to unconditional stderr so + // monitoring catches the all-buses-dropped scenario. + // Two near-duplicate fan-outs coexist in this file: + // - this `publishWorkspaceEvent` member (PR 16) — used by + // workspace-mutation routes that have a bridge proxy + // reference (memory / agents). + // - the local `broadcastWorkspaceEvent` closure declared above + // in this factory body (PR 17 mutation surface) — used by + // `setSessionApprovalMode` + // because its call site runs inside the factory closure + // where `this` isn't yet the proxy. The closure also takes + // an optional `skipSessionId` for the persisted approval-mode + // mirror; this member doesn't. + // The duplication is acknowledged debt — addressed in #4297 + // fold-in 11 (#3263954688). A future refactor can extract a + // shared `fanOutToSessions(envelope, sessions, opts?)` helper + // once the `skipSessionId` semantics stabilize. + const sessions = Array.from(byId.values()); + let successCount = 0; + let failureCount = 0; + for (const entry of sessions) { + try { + const published = entry.events.publish(event); + if (published === undefined) { + failureCount += 1; + teeServeDebugLine( + `publishWorkspaceEvent: publish on session ${entry.sessionId} no-op (bus closed)`, + ); + } else { + successCount += 1; + } + } catch (err) { + failureCount += 1; + const detail = + `publishWorkspaceEvent: bus publish failed for session ` + + `${JSON.stringify(entry.sessionId)} (type=${event.type}): ` + + `${err instanceof Error ? err.message : String(err)}`; + if (shuttingDown) { + teeServeDebugLine(detail); + } else { + writeStderrLine(`qwen serve: ${detail}`); + } + } + } + if (sessions.length > 0 && successCount === 0 && !shuttingDown) { + writeStderrLine( + `qwen serve: publishWorkspaceEvent type=${event.type} dropped on ALL ${failureCount} session bus(es); SSE subscribers will miss this event (GET fallback still authoritative)`, + ); + } + }, + + knownClientIds() { + // Snapshot the union of every live session's stamped client ids. + // Returned as a fresh Set so callers can mutate-safely (the live + // per-session maps stay private). Workspace-level mutation routes + // use this to validate `X-Qwen-Client-Id` without owning a + // session id. + const out = new Set(); + for (const entry of byId.values()) { + for (const id of entry.clientIds.keys()) out.add(id); + } + return out; + }, + + async queryWorkspaceStatus(method, idle) { + return requestWorkspaceStatus(method, idle); + }, + + async invokeWorkspaceCommand( + method: string, + params?: Record, + invokeOpts?: { timeoutMs?: number }, + ) { + const info = liveChannelInfo(); + if (!info) throw new SessionNotFoundError(`workspace-command:${method}`); + const timeout = invokeOpts?.timeoutMs ?? initTimeoutMs; + const response = await withTimeout( + Promise.race([ + info.connection.extMethod(method, params ?? {}), + getChannelClosedReject(info), + ]), + timeout, + method, + ); + return response as T; + }, + + async getWorkspaceMcpToolsStatus(serverName) { + return requestWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceMcpTools, + () => ({ + v: STATUS_SCHEMA_VERSION, + workspaceCwd: boundWorkspace, + serverName, + initialized: false, + acpChannelLive: false, + tools: [], + errors: [ + { + kind: 'mcp_tools', + status: 'not_started' as const, + hint: 'spawn a session to populate', + }, + ], + }), + { serverName }, + ); + }, + + async getWorkspaceToolsStatus() { + return requestWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceTools, + () => ({ + v: STATUS_SCHEMA_VERSION, + workspaceCwd: boundWorkspace, + initialized: true as const, + acpChannelLive: false, + tools: [], + errors: [ + { + kind: 'tools', + status: 'not_started' as const, + hint: 'spawn a session to populate', + }, + ], + }), + ); + }, + + async getSessionContextStatus(sessionId) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionContext, + ); + }, + + async getSessionContextUsageStatus(sessionId, opts) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionContextUsage, + { detail: opts?.detail === true }, + ); + }, + + async getSessionSupportedCommandsStatus(sessionId) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionSupportedCommands, + ); + }, + + async getSessionTasksStatus(sessionId) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionTasks, + ); + }, + + async cancelSessionTask(sessionId, taskId, taskKind) { + return requestSessionStatus<{ cancelled: boolean }>( + sessionId, + SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, + { taskId, taskKind }, + ); + }, + + async clearSessionGoal(sessionId) { + return requestSessionStatus<{ cleared: boolean; condition?: string }>( + sessionId, + SERVE_CONTROL_EXT_METHODS.sessionGoalClear, + ); + }, + + async getSessionStatsStatus(sessionId) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionStats, + ); + }, + + async getWorkspaceHooksStatus() { + return requestWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceHooks, + () => createIdleWorkspaceHooksStatus(boundWorkspace), + ); + }, + + async getSessionHooksStatus(sessionId) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionHooks, + ); + }, + + async getWorkspaceExtensionsStatus() { + return requestWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceExtensions, + () => createIdleWorkspaceExtensionsStatus(boundWorkspace), + ); + }, + + async setSessionModel(sessionId, req, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const originatorClientId = resolveTrustedClientId( + entry, + context?.clientId, + ); + const normalized: SetSessionModelRequest = { ...req, sessionId }; + // The ACP SDK marks setSessionModel as unstable (not in spec yet); the + // method on AgentSideConnection is `unstable_setSessionModel`. Cast + // through the shape we know rather than couple to the prefix in case + // it's renamed when the spec stabilizes. + const conn = entry.connection as unknown as { + unstable_setSessionModel( + p: SetSessionModelRequest, + ): Promise; + }; + // Serialize through `entry.modelChangeQueue` so a `POST /session/:id/model` + // can't race with `applyModelServiceId` (e.g. an attach-with-different- + // modelServiceId) and leave the agent connection in an indeterminate + // model. `applyModelServiceId` already chains on this queue; without + // mirroring that here, two concurrent model changes interleave and the + // last `model_switched` event published may not match the actual model + // the agent is on. + // + // Race the agent call against `transportClosedReject` and a + // `withTimeout` so a wedged child can't block the HTTP handler + // forever. Matches `sendPrompt` (transport race) and + // `applyModelServiceId` (timeout) — the absence of either was an + // attack surface for "POST /session/:id/model never returns". + // See `getTransportClosedReject` for the single-listener invariant. + // + // FIXME(stage-2): we reuse `initTimeoutMs` (default 10s) as the + // model-switch deadline because the two values happen to share + // a sensible order of magnitude today. They're conceptually + // distinct (cold-start handshake vs in-flight model swap) and + // a Stage 2 split into `modelSwitchTimeoutMs` would let + // operators tune them independently — also a good time to + // remove the no-abort behavior of `withTimeout` (it rejects + // the promise but leaves the underlying ACP call running, so a + // late-arriving `model_switched` can race a previously-fired + // `model_switch_failed`). Both depend on ACP exposing a cancel + // signal for `unstable_setSessionModel`. + const transportClosed = getTransportClosedReject(entry); + const work = entry.modelChangeQueue.then(async () => { + // A1: suppress the agent's current_model_update notification (this + // path drives Session.setModel, which emits it) while the bridge + // owns the change. Publish the authoritative model_switched INSIDE + // this callback — i.e. while the flag is still true — mirroring + // `applyModelServiceId`, so the agent notification can never slip + // through after the flag clears even if transport ordering changes. + entry.modelRoundtripInFlight = true; + // Only reconcile after a change that actually landed. If the + // roundtrip rejects (timeout / transport close) `publishModelSwitched` + // never ran and the cache is unchanged, so a reconcile would just emit + // a confusing corrective `model_switched` alongside the + // `model_switch_failed` the catch block already publishes. + let succeeded = false; + try { + const result = await Promise.race([ + withTimeout( + conn.unstable_setSessionModel(normalized), + initTimeoutMs, + 'setSessionModel', + ), + transportClosed, + ]); + // Cache the model id as received from the caller. The bridge + // layer does not have access to the CLI's `formatAcpModelId` + // (which requires `authType`), so it cannot canonicalize here. + // In practice callers always send canonical ids (from + // `buildAvailableModels`); any residual raw→canonical drift is + // corrected by the `reconcileAfterRoundtrip` below, which reads + // the agent's authoritative canonical id and re-publishes if it + // differs. + publishModelSwitched(entry, req.modelId, originatorClientId); + succeeded = true; + return result; + } finally { + entry.modelRoundtripInFlight = false; + if (succeeded) { + void reconcileAfterRoundtrip(entry, 'model'); + } else { + writeStderrLine( + `[reconcile] session=${entry.sessionId} target=model action=skipped reason=roundtrip_failed`, + ); + } + } + }); + // Tail-swallow on the queue so a model-change failure doesn't poison + // every subsequent change (matches `applyModelServiceId`'s pattern). + entry.modelChangeQueue = work.then( + () => undefined, + () => undefined, + ); + let response: SetSessionModelResponse; + try { + response = await work; + } catch (err) { + // Mirror `applyModelServiceId`'s observability contract: surface + // failed model changes on the SSE bus so subscribers can update + // their UI / retry. Without this the only signal is the HTTP + // 5xx, which doesn't reach passive viewers. `publish()` never + // throws (see `publishModelSwitched`), so no wrapper. + entry.events.publish({ + type: 'model_switch_failed', + data: { + sessionId: entry.sessionId, + requestedModelId: req.modelId, + error: err instanceof Error ? err.message : String(err), + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + throw err; + } + // model_switched is published inside the work callback above (while the + // suppress flag is still set), mirroring applyModelServiceId. + return response; + }, + + async setSessionLanguage(sessionId, params, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const info = channelInfoForEntry(entry); + if (!info || info.isDying) throw new SessionNotFoundError(sessionId); + const originatorClientId = resolveTrustedClientId( + entry, + context?.clientId, + ); + + const result = (await Promise.race([ + withTimeout( + entry.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionLanguage, + { + sessionId, + language: params.language, + syncOutputLanguage: params.syncOutputLanguage, + }, + ), + initTimeoutMs, + SERVE_CONTROL_EXT_METHODS.sessionLanguage, + ), + getTransportClosedReject(entry), + ])) as { + language: string; + outputLanguage: string | null; + refreshed: boolean; + }; + + try { + entry.events.publish({ + type: 'language_changed', + data: { + sessionId: entry.sessionId, + language: result.language, + outputLanguage: result.outputLanguage ?? null, + refreshed: result.refreshed ?? false, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } catch (err) { + writeServeDebugLine( + `language_changed event publish failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return { + language: result.language, + outputLanguage: result.outputLanguage ?? null, + refreshed: result.refreshed ?? false, + }; + }, + + async setSessionApprovalMode(sessionId, mode, opts, context) { + // Forwards through `qwen/control/session/approval_mode` so the + // change lands inside the ACP child's own `Config` (per-session + // `setApprovalMode`). The bridge layer adds two things on top: + // trusted `originatorClientId` resolution and an opt-in persist + // hook that writes `tools.approvalMode` to the workspace settings + // file. Persist is OFF by default — see the interface doc. + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const info = channelInfoForEntry(entry); + if (!info || info.isDying) throw new SessionNotFoundError(sessionId); + const originatorClientId = resolveTrustedClientId( + entry, + context?.clientId, + ); + // Validate the persist contract BEFORE the ACP roundtrip changes + // the in-process mode. A missing `persistApprovalMode` callback + // would otherwise produce a 500 after the ACP child already + // applied the mode change. + if (opts.persist && !persistApprovalMode) { + throw new Error( + 'setSessionApprovalMode called with `persist: true` but no ' + + '`persistApprovalMode` callback wired in BridgeOptions. ' + + 'runQwenServe wires the production callback; direct embeds ' + + 'and tests must opt in or omit `persist`.', + ); + } + // Serialize the WHOLE change — ACP roundtrip + persist + publish — through + // `entry.approvalModeQueue` (A3). Covering only the `extMethod` call (the + // earlier shape) left persist+publish OUTSIDE the queue: two concurrent + // `persist:true` calls could interleave their persist phases and publish + // out of order, so the bus's last `approval_mode_changed` disagreed with + // the mode the ACP child actually settled on. Keeping persist+publish in + // the queued work means the next change can't start its `extMethod` until + // this change's side effects are fully done. Mirrors `modelChangeQueue`. + const approvalWork = entry.approvalModeQueue.then(async () => { + // A2: suppress the agent's current_mode_update notification while + // the bridge owns the change. Mirrors `modelRoundtripInFlight`. + // The flag stays true through persist + publish so the notification + // cannot slip through during the persist phase (review finding #3). + entry.approvalModeRoundtripInFlight = true; + // See setSessionModel: only reconcile after a change that landed, so + // a rejected roundtrip can't pair a corrective event with the failure. + let succeeded = false; + try { + const response = (await Promise.race([ + withTimeout( + entry.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionApprovalMode, + { sessionId, mode }, + ), + initTimeoutMs, + SERVE_CONTROL_EXT_METHODS.sessionApprovalMode, + ), + getTransportClosedReject(entry), + ])) as { previous: ApprovalMode; current: ApprovalMode }; + + if ( + typeof response.current !== 'string' || + !KNOWN_APPROVAL_MODES.has(response.current) + ) { + // Throw so the HTTP caller sees a 500 instead of a misleading + // 200 OK with the requested mode echoed back. Without this, + // the HTTP client thinks the mode changed while the cache and + // SSE bus still show the old value. + throw new Error( + `Agent returned unknown approval mode: ${JSON.stringify(response.current)}`, + ); + } + + let persisted = false; + if (opts.persist) { + try { + await withTimeout( + persistApprovalMode?.(boundWorkspace, mode) ?? + Promise.resolve(), + PERSIST_TIMEOUT_MS, + 'persistApprovalMode', + ); + persisted = persistApprovalMode !== undefined; + } catch (err) { + writeStderrLine( + `setSessionApprovalMode: persist failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + publishApprovalModeChanged( + entry, + { + previous: response.previous, + next: response.current, + persisted, + }, + originatorClientId, + ); + // #4282 fold-in 4 (S2): a persisted change becomes the workspace + // default, so fan out a workspace-scoped mirror for peer sessions. + // #4297 fold-in 1: skip the requesting session (its own bus already + // got the publish above) to avoid double-counting in the reducer. + if (persisted) { + broadcastWorkspaceEvent( + { + type: 'approval_mode_changed', + data: { + sessionId: entry.sessionId, + previous: response.previous, + next: response.current, + persisted, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }, + entry.sessionId, + ); + // F3Qgp: a persisted change rewrites the workspace default, so the + // peers we just notified now hold a stale `currentApprovalMode` in + // their SessionEntry cache. Their GET status / session_snapshot + // would report the pre-change mode until their own next roundtrip. + // `byId` is the per-workspace session map (the bridge is bound per + // workspace), so mirror the new default into every peer's cache; + // skip the originator, whose cache `publishApprovalModeChanged` + // already updated. + for (const peer of byId.values()) { + if (peer.sessionId === entry.sessionId) { + continue; + } + peer.currentApprovalMode = response.current; + } + } + succeeded = true; + return { + sessionId: entry.sessionId, + mode: response.current, + previous: response.previous, + persisted, + }; + } finally { + entry.approvalModeRoundtripInFlight = false; + if (succeeded) { + void reconcileAfterRoundtrip(entry, 'approvalMode'); + } else { + writeStderrLine( + `[reconcile] session=${entry.sessionId} target=approvalMode action=skipped reason=roundtrip_failed`, + ); + } + } + }); + // Tail-swallow so a failed change doesn't poison subsequent ones. + entry.approvalModeQueue = approvalWork.then( + () => undefined, + () => undefined, + ); + try { + return await approvalWork; + } catch (err) { + // The ACP child rethrows `TrustGateError` as a JSON-RPC error whose + // `data.errorKind` is `'trust_gate'`; re-instantiate the typed class so + // the HTTP route maps it to 403 with the `auth_env_error` errorKind. + const data = (err as { data?: unknown })?.data; + if ( + data && + typeof data === 'object' && + 'errorKind' in data && + (data as { errorKind?: unknown }).errorKind === 'trust_gate' + ) { + const rawMessage = (err as { message?: unknown })?.message; + const message = + typeof rawMessage === 'string' + ? rawMessage + : 'Trust-gate rejection from ACP child'; + throw new TrustGateError(message); + } + throw err; + } + }, + + async generateSessionRecap(sessionId, _context) { + // Thin pass-through to `qwen/control/session/ + // recap` — the ACP child runs `generateSessionRecap` against the + // session's GeminiClient history and returns `{sessionId, recap}` + // where `recap` may be `null` for too-short histories or transient + // model failures. The core helper is documented to never throw, + // so the only paths that surface as bridge errors are: unknown + // sessionId (`SessionNotFoundError`), transport closed mid-flight + // (race against `getTransportClosedReject`), and the backstop + // `SESSION_RECAP_TIMEOUT_MS` race for a wedged ACP channel. + // + // `_context` carries the trusted client id for future event + // fan-out (e.g. a `session_recap_generated` push event), but + // recap is informational-only today — no SSE broadcast. + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const info = channelInfoForEntry(entry); + if (!info || info.isDying) throw new SessionNotFoundError(sessionId); + opts.onDiagnosticLine?.( + `qwen serve: bridge generateSessionRecap dispatching ext-method for session=${sessionId}`, + 'info', + ); + const response = (await Promise.race([ + withTimeout( + entry.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionRecap, { + sessionId, + }), + SESSION_RECAP_TIMEOUT_MS, + SERVE_CONTROL_EXT_METHODS.sessionRecap, + ), + getTransportClosedReject(entry), + ])) as { sessionId: string; recap: string | null }; + opts.onDiagnosticLine?.( + `qwen serve: bridge generateSessionRecap completed for session=${sessionId} recap=${response.recap ? `len=${response.recap.length}` : 'null'}`, + 'info', + ); + return { + sessionId: entry.sessionId, + recap: response.recap ?? null, + }; + }, + + async generateSessionBtw(sessionId, question, signal, _context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const info = channelInfoForEntry(entry); + if (!info || info.isDying) throw new SessionNotFoundError(sessionId); + if (signal?.aborted) return { sessionId, answer: null }; + const races: Array> = [ + withTimeout( + entry.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionBtw, { + sessionId, + question, + }), + SESSION_BTW_TIMEOUT_MS, + SERVE_CONTROL_EXT_METHODS.sessionBtw, + ), + getTransportClosedReject(entry), + ]; + let cleanupAbort: (() => void) | undefined; + if (signal) { + races.push( + new Promise((_, reject) => { + const handler = () => + reject(new DOMException('Aborted', 'AbortError')); + signal.addEventListener('abort', handler, { once: true }); + cleanupAbort = () => signal.removeEventListener('abort', handler); + }), + ); + } + let response: { sessionId: string; answer: string | null }; + try { + response = (await Promise.race(races)) as { + sessionId: string; + answer: string | null; + }; + } finally { + cleanupAbort?.(); + } + return { + sessionId: entry.sessionId, + answer: response.answer ?? null, + }; + }, + + async executeShellCommand( + sessionId, + command, + signal, + context, + ): Promise { + opts.onDiagnosticLine?.( + `qwen serve: bridge executeShellCommand for session=${sessionId}`, + 'info', + ); + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const originatorClientId = resolveTrustedClientId( + entry, + context?.clientId, + ); + + if (signal?.aborted) { + return { exitCode: null, output: '', aborted: true }; + } + + const cwd = entry.workspaceCwd; + + entry.events.publish({ + type: 'user_shell_command', + data: { sessionId, command, cwd }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + + const outputChunks: string[] = []; + const abort = new AbortController(); + const onSignalAbort = () => abort.abort(); + signal?.addEventListener('abort', onSignalAbort, { once: true }); + + try { + const handle = await ShellExecutionService.execute( + command, + cwd, + (event: ShellOutputEvent) => { + if (event.type === 'data') { + const chunk = + typeof event.chunk === 'string' + ? event.chunk + : event.chunk + .map((line: Array<{ text: string }>) => + line.map((t) => t.text).join(''), + ) + .join('\n'); + outputChunks.push(chunk); + entry.events.publish({ + type: 'session_update', + data: { + sessionId, + update: { + sessionUpdate: 'shell_output', + output: chunk, + _meta: { + serverTimestamp: Date.now(), + source: 'user-shell', + }, + }, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } + }, + abort.signal, + false, + { terminalWidth: 120, terminalHeight: 40 }, + { streamStdout: true }, + ); + + const timeoutId = setTimeout( + () => abort.abort(), + SHELL_COMMAND_TIMEOUT_MS, + ); + timeoutId.unref(); + + const result = await handle.result; + clearTimeout(timeoutId); + + const exitCode = result.exitCode; + const aborted = result.aborted; + const output = outputChunks.join('') || result.output; + + entry.events.publish({ + type: 'user_shell_result', + data: { + sessionId, + exitCode, + signal: result.signal, + aborted, + _meta: { serverTimestamp: Date.now() }, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + + const historyOutput = + output.length > MAX_SHELL_OUTPUT_FOR_HISTORY + ? output.substring(0, MAX_SHELL_OUTPUT_FOR_HISTORY) + + '\n... (truncated)' + : output; + + try { + await entry.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionShellHistory, + { sessionId, command, output: historyOutput, exitCode }, + ); + } catch (err) { + writeServeDebugLine( + `shell history injection failed for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return { exitCode, output, aborted }; + } catch (err) { + entry.events.publish({ + type: 'user_shell_result', + data: { + sessionId, + exitCode: null, + signal: null, + aborted: false, + error: err instanceof Error ? err.message : String(err), + _meta: { serverTimestamp: Date.now() }, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + throw err; + } finally { + signal?.removeEventListener('abort', onSignalAbort); + } + }, + + async getRewindSnapshots(sessionId) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionRewindSnapshots, + ); + }, + + async rewindSession(sessionId, req, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const info = channelInfoForEntry(entry); + if (!info || info.isDying) throw new SessionNotFoundError(sessionId); + const originatorClientId = resolveTrustedClientId( + entry, + context?.clientId, + ); + + let response: Record; + try { + response = (await Promise.race([ + withTimeout( + entry.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionRewind, + { sessionId, promptId: req.promptId, rewindFiles: true }, + ), + initTimeoutMs, + SERVE_CONTROL_EXT_METHODS.sessionRewind, + ), + getTransportClosedReject(entry), + ])) as Record; + } catch (err) { + const data = (err as { data?: unknown })?.data; + if (data && typeof data === 'object' && 'errorKind' in data) { + const kind = (data as { errorKind: string }).errorKind; + const msg = (err as { message?: string })?.message ?? 'Rewind failed'; + if (kind === 'session_busy') { + throw new SessionBusyError(sessionId, msg); + } + if (kind === 'invalid_rewind_target') { + throw new InvalidRewindTargetError(sessionId, msg); + } + } + throw err; + } + + const targetTurnIndex = (response['targetTurnIndex'] as number) ?? 0; + const filesChanged = (response['filesChanged'] as string[]) ?? []; + const filesFailed = (response['filesFailed'] as string[]) ?? []; + + try { + entry.events.publish({ + type: 'session_rewound', + data: { + sessionId, + promptId: req.promptId, + targetTurnIndex, + filesChanged, + filesFailed, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } catch { + /* bus closed */ + } + + return { + rewound: filesFailed.length === 0, + targetTurnIndex, + filesChanged, + filesFailed, + }; + }, + + async manageMcpServer(serverName, action, originatorClientId) { + const info = liveChannelInfo(); + if (!info) { + throw new SessionNotFoundError(`mcp:${serverName}`); + } + const timeout = + action === 'authenticate' + ? MCP_OAUTH_TIMEOUT_MS + : MCP_RESTART_TIMEOUT_MS; + const response = (await Promise.race([ + withTimeout( + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, + { serverName, action, originatorClientId }, + ), + timeout, + SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, + ), + getChannelClosedReject(info), + ])) as { + serverName: string; + action: 'enable' | 'disable' | 'authenticate' | 'clear-auth'; + ok: true; + changed?: boolean; + messages?: string[]; + authUrl?: string; + }; + broadcastWorkspaceEvent({ + type: 'mcp_server_changed', + data: { + serverName: response.serverName, + action: response.action, + originatorClientId, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + return response; + }, + + async generateWorkspaceAgent(description, _originatorClientId) { + const info = liveChannelInfo(); + if (!info) { + throw new SessionNotFoundError('agents:generate'); + } + return (await Promise.race([ + withTimeout( + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceAgentGenerate, + { description }, + ), + MCP_RESTART_TIMEOUT_MS, + SERVE_CONTROL_EXT_METHODS.workspaceAgentGenerate, + ), + getChannelClosedReject(info), + ])) as { + name: string; + description: string; + systemPrompt: string; + }; + }, + + async addRuntimeMcpServer(name, config, originatorClientId) { + // Round-trip the runtime-add ext-method through the + // live ACP child and broadcast an `mcp_server_added` event on + // success. Soft-refuse (`budget_warning_only`) returns the skip + // shape without emitting — the caller (HTTP route) decides how to + // surface the skip to the SDK consumer. + const info = liveChannelInfo(); + if (!info) { + throw Object.assign( + new Error(`No live ACP channel for runtime MCP add: ${name}`), + { data: { errorKind: 'acp_channel_unavailable' } }, + ); + } + type AddOk = { + name: string; + transport: 'stdio' | 'sse' | 'http' | 'tcp' | 'sdk'; + replaced: boolean; + shadowedSettings: boolean; + toolCount: number; + originatorClientId: string; + }; + type AddSkip = { + name: string; + skipped: true; + reason: 'budget_warning_only'; + }; + const response = (await Promise.race([ + withTimeout( + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd, + { name, config, originatorClientId }, + ), + MCP_RESTART_SERVER_DEADLINE_MS, + SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd, + ), + getChannelClosedReject(info), + ])) as AddOk | AddSkip; + // Emit event on success (non-skip) + const addSkipped = (response as { skipped?: boolean }).skipped === true; + if (!addSkipped) { + const ok = response as AddOk; + broadcastWorkspaceEvent({ + type: 'mcp_server_added', + data: { + name: ok.name, + transport: ok.transport, + replaced: ok.replaced, + shadowedSettings: ok.shadowedSettings, + toolCount: ok.toolCount, + originatorClientId: ok.originatorClientId, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } + return response; + }, + + async removeRuntimeMcpServer(name, originatorClientId) { + // Round-trip the runtime-remove ext-method through + // the live ACP child and broadcast `mcp_server_removed` on success. + // Idempotent skip (`not_present`) returns without emitting. + const info = liveChannelInfo(); + if (!info) { + throw Object.assign( + new Error(`No live ACP channel for runtime MCP remove: ${name}`), + { data: { errorKind: 'acp_channel_unavailable' } }, + ); + } + type RemoveOk = { + name: string; + removed: true; + wasShadowingSettings: boolean; + originatorClientId: string; + }; + type RemoveSkip = { name: string; skipped: true; reason: 'not_present' }; + const response = (await Promise.race([ + withTimeout( + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeRemove, + { name, originatorClientId }, + ), + MCP_RESTART_SERVER_DEADLINE_MS, + SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeRemove, + ), + getChannelClosedReject(info), + ])) as RemoveOk | RemoveSkip; + // Emit event on success (non-skip) + const removeSkipped = + (response as { skipped?: boolean }).skipped === true; + if (!removeSkipped) { + const ok = response as RemoveOk; + broadcastWorkspaceEvent({ + type: 'mcp_server_removed', + data: { + name: ok.name, + wasShadowingSettings: ok.wasShadowingSettings, + originatorClientId: ok.originatorClientId, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } + return response; + }, + + async killSession(sessionId, opts) { + const entry = byId.get(sessionId); + if (!entry) return; + // BQ9tV race guard: skip the reap if any other client already + // attached to this entry. The disconnect-reaper in server.ts + // sets `requireZeroAttaches: true` because it only wants to + // reap when the spawn-owner that disconnected truly was the + // sole client. Counter increment + this check both run + // synchronously, so no microtask boundary lets a race slip + // through. + // BkwQP: when bailing because of an attach, set the tombstone + // so a later `detachClient` (that brings attachCount back to + // 0) can complete the deferred reap. Without this, both + // spawn-owner-and-attach disconnecting leaves the session + // orphaned forever (spawn owner's reap bails here, attach's + // detach does nothing structural). + if (opts?.requireZeroAttaches && entry.attachCount > 0) { + entry.spawnOwnerWantedKill = true; + return; + } + // Mediator-driven cancel cascade. Must run BEFORE byId.delete so + // the mediator's emit callback can still reach entry.events via + // byId.get(sessionId) (same order as closeSession). + permissionMediator.forgetSession(sessionId); + entry.pendingPermissionIds.clear(); + // Remove from the state eagerly so concurrent `spawnOrAttach` + // can't reattach to a session we're tearing down. + if (defaultEntry === entry) defaultEntry = undefined; + byId.delete(sessionId); + telemetry.metrics?.sessionLifecycle('die'); + // Detach from the channel. The channel dies only when its LAST + // session leaves — other sessions on the same channel keep + // running. + // + // HAZARD: Same channel-overlap fix as in `closeSession` above. + // `channelInfoForEntry(entry)` returns the entry's actual + // channel rather than the module-scoped `channelInfo` (current + // attach target), preventing the "kill operates on the freshly- + // spawned channel B instead of the dying channel A" cascade + // during the overlap window. The regression test is single-channel + // smoke only and WILL NOT fail if this reverts to module-scoped + // channelInfo. Keep `channelInfoForEntry(entry)` until a + // deterministic overlap test lands. + const ci = channelInfoForEntry(entry); + if (!ci) { + // Same diagnostic as `closeSession` — when the entry's channel + // is already gone, the cleanup below short-circuits silently. + writeStderrLine( + `qwen serve: killSession channelInfoForEntry returned undefined ` + + `for session ${JSON.stringify(sessionId)} — channel cleanup skipped (entry's channel already torn down)`, + ); + } + if (ci && ci.channel === entry.channel) { + ci.sessionIds.delete(sessionId); + } + await notifyAgentSessionClose(entry, ci, 'killSession'); + // Tombstone the killed sessionId so any in-flight + // `extNotification` from the (about-to-be-killed) child can't + // seed the early-event buffer for a subsequent load/resume of + // the same persisted id. + ci?.client.markSessionClosed(sessionId); + // Publish `session_died` BEFORE closing the bus. After the eager + // `byId.delete` above, the channel.exited handler's + // `byId.get(...)` returns undefined so the automatic publish + // at crash time wouldn't fire. SSE subscribers need this + // terminal frame to know the session is gone. + try { + entry.events.publish({ + type: 'session_died', + data: { sessionId, reason: 'killed' }, + }); + } catch { + /* bus already closed */ + } + entry.events.close(); + // Only kill the channel when no other sessions remain AND no + // restore is in flight. + // `pendingRestoreIds` covers in-flight `session/load` and + // `session/resume` calls that haven't yet registered into + // `sessionIds`. Killing the channel out from under them would + // SIGTERM the restore mid-flight and 500 the caller for a + // failure orthogonal to their request. + if (ci && ci.sessionIds.size === 0 && ci.pendingRestoreIds.size === 0) { + await startIdleTimer(ci, `killSession "${sessionId}"`); + } + }, + + async detachClient(sessionId, clientId) { + // The `attachCount` race guard is monotonic — once any attach + // bumps it, the spawn-owner's disconnect-reaper becomes a + // permanent no-op even if the attaching client itself + // disconnected. This is the symmetric rollback the server's + // `!res.writable && session.attached` path calls into. + // + // BkwQP: detachClient decrements attachCount and unregisters the + // client. Two close paths: + // 1. spawnOwnerWantedKill tombstone → killSession (deferred reap + // from the spawn-handshake disconnect race). + // 2. clientIds.size === 0 → closeSessionImpl (last registered + // client left; session closed immediately, JSONL preserved). + // The idle reaper serves as a backstop for clients that crash + // without sending a detach request. + const entry = byId.get(sessionId); + if (!entry) return; + if (entry.attachCount > 0) entry.attachCount--; + unregisterClient(entry, clientId); + if ( + entry.spawnOwnerWantedKill && + entry.attachCount === 0 && + entry.events.subscriberCount === 0 + ) { + // Defer-completed reap. Re-use killSession's logic; pass + // `requireZeroAttaches: false` (default) because we've + // already validated all the conditions ourselves. + await this.killSession(sessionId).catch(() => { + /* best-effort; channel.exited will eventually reap anyway */ + }); + } else if ( + entry.clientIds.size === 0 && + entry.events.subscriberCount === 0 && + !entry.promptActive + ) { + // Last registered client left, no SSE subscribers remain, and + // no prompt is in flight. Close the session immediately so it + // doesn't linger in memory. The JSONL transcript on disk is + // preserved — session/load or session/resume can restore it + // later. When a prompt IS active, skip the close and let the + // idle reaper handle it after the prompt completes. + await closeSessionImpl(sessionId, undefined, { + reason: 'last_client_detached', + }).catch((err) => { + writeStderrLine( + `qwen serve: close-on-last-detach failed for ` + + `${JSON.stringify(sessionId)}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, + ); + }); + } + }, + + killAllSync() { + // Synchronous best-effort SIGKILL on EVERY alive channel + // (typically 1, but during a `killSession`-then-`spawnOrAttach` + // overlap there can be 2). Set `shuttingDown` so any racing + // async path fails fast. + // + // BkUyD: iterate `aliveChannels` (the OS-level "still alive" + // source of truth) — `channelInfo` only points at the CURRENT + // attach target, missing any dying channel whose + // `channel.exited` hasn't fired yet. + shuttingDown = true; + cancelIdleTimer(); + stopSessionReaper(); + const channels = Array.from(aliveChannels); + defaultEntry = undefined; + byId.clear(); + for (const info of channels) { + try { + info.channel.killSync(); + } catch { + /* best-effort — already-dead child / pid race */ + } + } + }, + + async shutdown() { + // Set BEFORE the snapshot so any racing `spawnOrAttach` triggered + // by an in-flight HTTP connection after `runQwenServe.close()` + // entered the bridge.shutdown() phase fails fast instead of + // spawning a child this teardown won't see. + shuttingDown = true; + cancelIdleTimer(); + stopSessionReaper(); + const entries = Array.from(byId.values()); + // Snapshot every alive channel (typically 1; up to 2 during a + // `killSession`-then-`spawnOrAttach` overlap) — entries are + // intentionally NOT removed from `aliveChannels` here; their + // `channel.exited` handlers clear them once the OS has reaped + // each child. That preserves the BkUyD invariant: a + // double-Ctrl+C arriving mid-SIGTERM-grace can still find every + // alive channel via `killAllSync`. Marking each `isDying` makes + // them invisible to any racing `ensureChannel` call — but + // `shuttingDown` already blocks new `spawnOrAttach` upstream, + // so this is mostly belt-and-suspenders (a direct internal + // `ensureChannel` past the gate would still see the dying + // state and not attach). + const channels = Array.from(aliveChannels); + for (const ci of channels) ci.isDying = true; + // Drain mediator pending state before clearing byId so awaiting + // `requestPermission` callers unwind. Each `forgetSession` + // settles all matching pending as session_closed; the bridge's + // per-entry index gets cleared alongside. + for (const e of entries) { + permissionMediator.forgetSession(e.sessionId); + e.pendingPermissionIds.clear(); + } + defaultEntry = undefined; + byId.clear(); + // Publish a terminal `session_died` BEFORE closing each bus so SSE + // subscribers can distinguish "daemon shut down" from a transient + // network error and don't sit indefinitely retrying. The + // channel.exited handler also publishes this on a child crash, + // but at shutdown time the entry has already been removed from + // `byId` (above), so the handler's `byId.get(...)` is undefined + // and the automatic publish wouldn't fire. + for (const e of entries) { + telemetry.metrics?.sessionLifecycle('die'); + try { + e.events.publish({ + type: 'session_died', + data: { sessionId: e.sessionId, reason: 'daemon_shutdown' }, + }); + } catch { + /* bus already closed */ + } + e.events.close(); + } + // Wait for in-flight channel + session spawns. The snapshot + // above only sees what's already registered; a doSpawn past + // `newSession()` but pre-`byId.set` is missed, as is an + // `ensureChannel` past `channelFactory()` but pre-`channelInfo + // = info`. The late-shutdown re-checks at doSpawn/ensureChannel + // catch both — but without these awaits, `bridge.shutdown()` + // would resolve before they finish, and the orphan stderr + // error from a half-built child would fire AFTER the daemon + // claimed graceful shutdown (log-confusing). + const inFlightSessionAwaits = Array.from(inFlightSpawns.values()).map( + (p): Promise => + p.then( + () => undefined, + () => undefined, + ), + ); + const inFlightRestoreAwaits = Array.from(inFlightRestores.values()).map( + (restore): Promise => + restore.promise.then( + () => undefined, + () => undefined, + ), + ); + const inFlightChannelAwait: Promise = inFlightChannelSpawn + ? inFlightChannelSpawn.then( + () => undefined, + () => undefined, + ) + : Promise.resolve(); + await Promise.all([ + ...channels.map((ci) => ci.channel.kill().catch(() => {})), + ...inFlightSessionAwaits, + ...inFlightRestoreAwaits, + inFlightChannelAwait, + ]); + }, + + async preheat() { + if (shuttingDown) return; + const ci = await ensureChannel(); + const idleMs = resolvedChannelIdleTimeoutMs(); + if ( + idleMs > 0 && + ci.sessionIds.size === 0 && + ci.pendingRestoreIds.size === 0 + ) { + await startIdleTimer(ci); + } + }, + }; +} + +/** + * Race `p` against a timeout. The timeout REJECTS the returned + * promise but does NOT abort the underlying operation — `p` keeps + * running to completion (or its own failure) and its eventual + * resolution is silently dropped. + * + * Stage 1 limitation: for `unstable_setSessionModel` the agent may + * complete the model switch AFTER we surfaced the timeout to the + * HTTP caller, leading to drift between caller's perceived model + * and agent's actual model. Subscribers also see contradictory + * SSE events (`model_switch_failed` from the timeout, then a late + * `model_switched` if the agent succeeds). Acceptable for Stage 1 + * because: + * 1. ACP's `unstable_setSessionModel` doesn't accept a cancel + * signal yet (the SDK's `prompt` does, hence `sendPrompt`'s + * explicit `cancel` notification on abort). + * 2. Model switches complete in milliseconds in practice; a + * timeout firing means the agent is genuinely wedged, not + * just slow, and would have been DOA anyway. + * Stage 2 will add abort plumbing once ACP exposes a cancel hook + * for `unstable_setSessionModel`. Tracked in the model-change + * concurrency notes in `applyModelServiceId`. BSA0C suggested a + * `modelSwitchTimedOut` flag + `model_switch_late_success` + * synthetic frame for full observability of the divergent state; + * recorded as a Stage 2 follow-up so the timeout/late-success + * handshake is implemented once across both ACP-side cancel and + * the bridge-side state flag (rather than just papering over the + * symptom). + */ +async function withTimeout( + p: Promise, + ms: number, + label: string, +): Promise { + let timer: NodeJS.Timeout | undefined; + const timeoutP = new Promise((_, reject) => { + timer = setTimeout(() => reject(new BridgeTimeoutError(label, ms)), ms); + }); + try { + return await Promise.race([p, timeoutP]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** @deprecated Use `createAcpSessionBridge` instead. */ +export const createHttpAcpBridge = createAcpSessionBridge; diff --git a/packages/acp-bridge/src/bridgeClient.test.ts b/packages/acp-bridge/src/bridgeClient.test.ts new file mode 100644 index 0000000000..3f77dda4c3 --- /dev/null +++ b/packages/acp-bridge/src/bridgeClient.test.ts @@ -0,0 +1,446 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Unit tests for the `BridgeFileSystem` injection seam introduced in + * #4175 PR F1 step 5. The wider 174-test `httpAcpBridge.test.ts` suite + * exercises BridgeClient end-to-end via the lifted factory, but none + * of those tests wire `fileSystem` — they all exercise the inline + * `fs.writeFile` / `fs.readFile` proxy. These tests close that gap + * (wenshao #4319 Critical fold-in): they directly assert that + * + * 1. when `fileSystem` is provided, both `writeTextFile` and + * `readTextFile` delegate every call to it (and the inline + * proxy is fully bypassed — no `fs.writeFile` syscall); + * 2. when `fileSystem` is omitted, the inline proxy runs and + * reads / writes real disk (sanity check that the fallback + * path the 8-arg constructor's positional slot opt-outs to + * still works). + * + * Regression guard: the constructor takes 8 positional args; the + * 6th (`fileSystem`) is optional. A subtle re-ordering (or + * dropping the arg from `bridge.ts`'s factory + * `new BridgeClient(..., opts.fileSystem)` call) would silently + * bypass the adapter in production. Test #1 + #2 catch that + * because the mock fileSystem would never be called. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { promises as fsp } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { + ReadTextFileRequest, + ReadTextFileResponse, + WriteTextFileRequest, + WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import { BridgeClient } from './bridgeClient.js'; +import type { BridgeFileSystem } from './bridgeFileSystem.js'; +import { CancelSentinelCollisionError } from './bridgeErrors.js'; +import { CANCEL_VOTE_SENTINEL } from './permissionMediator.js'; + +/** + * Minimal-stub constructor for a `BridgeClient` whose only purpose is + * to exercise `writeTextFile` / `readTextFile`. The 5 callback args + * before `fileSystem` are filled with thrower-defaults so any test + * that accidentally hits the permission path (instead of the fs path) + * fails loudly instead of silently. F3 Commit 3 replaced the pre-F3 + * `registerPending` + `rollbackPending` callbacks with a single + * `MultiClientPermissionMediator` reference; the test stub provides + * a thrower-Mediator that fails any unexpected `request()` / + * `vote()` / `forgetSession()` call. + */ +function makeClient(fileSystem?: BridgeFileSystem): BridgeClient { + const noPermissionFlow = () => { + throw new Error('test: permission flow should not run in fs-path tests'); + }; + // Wenshao review #4335 / 3272581569 — `BridgeClient.mediator` is + // narrowed to `Pick`, so the + // thrower stub only needs to provide `request`. Eliminates the + // 5 unused-method placeholders the pre-narrowing version + // required (policy/vote/forgetSession/peekSessionFor/pendingCount). + const throwerMediator = { request: noPermissionFlow } as never; + return new BridgeClient( + noPermissionFlow as never, // resolveEntry + noPermissionFlow as never, // resolvePendingRestoreEvents + throwerMediator, // mediator (F3 Commit 3) + 0, // permissionTimeoutMs (disabled) + Infinity, // maxPendingPerSession (disabled) + fileSystem, + ); +} + +describe('BridgeClient — BridgeFileSystem injection seam (F1 step 5)', () => { + describe('writeTextFile', () => { + it('delegates to the injected fileSystem.writeText, bypassing the inline fs proxy', async () => { + const writeText = vi + .fn<(p: WriteTextFileRequest) => Promise>() + .mockResolvedValue({}); + const readText = + vi.fn<(p: ReadTextFileRequest) => Promise>(); + const fakeFs: BridgeFileSystem = { writeText, readText }; + + const client = makeClient(fakeFs); + const params: WriteTextFileRequest = { + path: '/this/path/never/touches/disk', + content: 'injected-content', + sessionId: 'sess:test', + }; + + const response = await client.writeTextFile(params); + + expect(response).toEqual({}); + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith(params); + expect(readText).not.toHaveBeenCalled(); + }); + + it('does NOT touch real fs when delegating — the mock is invoked without any disk touch', async () => { + const writeText = vi + .fn<(p: WriteTextFileRequest) => Promise>() + .mockResolvedValue({}); + const fakeFs: BridgeFileSystem = { + writeText, + readText: vi.fn(), + }; + const client = makeClient(fakeFs); + + // A path no real disk would ever resolve to. Delegation skips + // realpath / writeFile entirely, so the call succeeds purely + // on the mock's resolve. Cross-platform-safe (avoiding `/proc/` + // because macOS / Windows would treat that path differently + // than Linux — the inline proxy's dangling-symlink fallback + // would write through there on macOS). + await client.writeTextFile({ + path: '/this/dir/never/exists/file.txt', + content: '', + sessionId: 'sess:test', + }); + + expect(writeText).toHaveBeenCalled(); + }); + }); + + describe('readTextFile', () => { + it('delegates to the injected fileSystem.readText, bypassing the inline fs proxy', async () => { + const writeText = + vi.fn<(p: WriteTextFileRequest) => Promise>(); + const readText = vi + .fn<(p: ReadTextFileRequest) => Promise>() + .mockResolvedValue({ content: 'injected-content' }); + const fakeFs: BridgeFileSystem = { writeText, readText }; + + const client = makeClient(fakeFs); + const params: ReadTextFileRequest = { + path: '/this/path/never/touches/disk', + sessionId: 'sess:test', + }; + + const response = await client.readTextFile(params); + + expect(response).toEqual({ content: 'injected-content' }); + expect(readText).toHaveBeenCalledTimes(1); + expect(readText).toHaveBeenCalledWith(params); + expect(writeText).not.toHaveBeenCalled(); + }); + + it('propagates fileSystem.readText errors to the caller', async () => { + const readText = vi.fn(async (): Promise => { + throw new Error('adapter-rejected'); + }); + const client = makeClient({ writeText: vi.fn(), readText }); + + await expect( + client.readTextFile({ path: '/x', sessionId: 'sess:test' }), + ).rejects.toThrow('adapter-rejected'); + }); + }); + + describe('FsError preservation over ACP wire (#4175 F4 prereq, Codex #4360 round 2)', () => { + // The fix scope: when `BridgeFileSystem.writeText` / + // `BridgeFileSystem.readText` throw a structured `FsError`, the + // BridgeClient must rethrow as ACP `RequestError` with `data. + // errorKind` / `data.hint` / `data.status` preserved. Pre-fix + // the ACP SDK serialized only `error.message` so SDK consumers + // lost the discriminator and had to regex-match the message. + // + // FsError lives in `cli/src/serve/fs/errors.ts` — acp-bridge can't + // import it (cross-package dep inversion), so we synthesize the + // shape directly here. The duck typing in + // `preserveFsErrorOverAcp` keys on `err.name === 'FsError'` + + // `typeof err.kind === 'string'`. + + function makeFsError( + kind: string, + message: string, + extras: { hint?: string; status?: number } = {}, + ): Error { + const err = new Error(message); + err.name = 'FsError'; + (err as unknown as { kind: string }).kind = kind; + if (extras.hint !== undefined) { + (err as unknown as { hint: string }).hint = extras.hint; + } + if (extras.status !== undefined) { + (err as unknown as { status: number }).status = extras.status; + } + return err; + } + + it('writeTextFile rethrows FsError as ACP RequestError with errorKind in data', async () => { + const writeText = vi.fn(async (): Promise => { + throw makeFsError( + 'untrusted_workspace', + 'workspace is not trusted; write operations are forbidden', + { + status: 403, + hint: 'enable trust via createWorkspaceFileSystemFactory', + }, + ); + }); + const client = makeClient({ writeText, readText: vi.fn() }); + + const err = (await client + .writeTextFile({ + path: '/x', + content: 'y', + sessionId: 'sess:test', + }) + .catch((e) => e)) as Error & { code?: number; data?: unknown }; + + // Reshaped as JSON-RPC RequestError (-32603 = internal error) + // with structured data field. + expect(err.name).toBe('RequestError'); + expect(err.code).toBe(-32603); + expect(err.message).toContain('not trusted'); + expect(err.data).toMatchObject({ + errorKind: 'untrusted_workspace', + status: 403, + hint: expect.any(String), + }); + }); + + it('readTextFile rethrows FsError preserving symlink_escape kind', async () => { + const readText = vi.fn(async (): Promise => { + throw makeFsError( + 'symlink_escape', + 'symlink resolves outside workspace', + { status: 400 }, + ); + }); + const client = makeClient({ writeText: vi.fn(), readText }); + + const err = (await client + .readTextFile({ path: '/x', sessionId: 'sess:test' }) + .catch((e) => e)) as Error & { code?: number; data?: unknown }; + + expect(err.name).toBe('RequestError'); + expect(err.code).toBe(-32603); + expect(err.data).toMatchObject({ + errorKind: 'symlink_escape', + status: 400, + }); + // No `hint` field on this FsError → not stamped (spread guard). + expect((err.data as { hint?: unknown }).hint).toBeUndefined(); + }); + + it('passes non-FsError errors through unchanged (no RequestError wrap)', async () => { + // Plain Error → bridgeClient must NOT wrap it. Only structured + // FsError gets the reshape. ACP's default serialization is + // adequate for unstructured errors. + const writeText = vi.fn(async (): Promise => { + throw new Error('boring generic failure'); + }); + const client = makeClient({ writeText, readText: vi.fn() }); + + const err = (await client + .writeTextFile({ + path: '/x', + content: 'y', + sessionId: 'sess:test', + }) + .catch((e) => e)) as Error & { code?: number; data?: unknown }; + + // Original Error preserved — no JSON-RPC code stamped. + expect(err.name).toBe('Error'); + expect(err.message).toBe('boring generic failure'); + expect(err.code).toBeUndefined(); + expect(err.data).toBeUndefined(); + }); + + it('readTextFile passes non-FsError errors through unchanged (wenshao #4360 review)', async () => { + // Symmetric guard for the read-side `preserveFsErrorOverAcp` + // call. The write- and read-side catch blocks are independent + // try/catch wrappers in `bridgeClient.ts`; if a future refactor + // diverges them (e.g. adds Error-wrapping to one but not the + // other), this test catches the read-side regression. + const readText = vi.fn(async (): Promise => { + throw new Error('generic read failure'); + }); + const client = makeClient({ writeText: vi.fn(), readText }); + + const err = (await client + .readTextFile({ path: '/x', sessionId: 'sess:test' }) + .catch((e) => e)) as Error & { code?: number; data?: unknown }; + + expect(err.name).toBe('Error'); + expect(err.message).toBe('generic read failure'); + expect(err.code).toBeUndefined(); + expect(err.data).toBeUndefined(); + }); + + it('preserves hint field when present on the FsError', async () => { + const writeText = vi.fn(async (): Promise => { + throw makeFsError( + 'file_too_large', + 'file of 6 MiB exceeds write cap of 5 MiB', + { hint: 'split large writes into bounded chunks', status: 413 }, + ); + }); + const client = makeClient({ writeText, readText: vi.fn() }); + + const err = (await client + .writeTextFile({ + path: '/x', + content: 'y', + sessionId: 'sess:test', + }) + .catch((e) => e)) as Error & { code?: number; data?: unknown }; + + expect((err.data as { hint?: string }).hint).toBe( + 'split large writes into bounded chunks', + ); + expect((err.data as { errorKind?: string }).errorKind).toBe( + 'file_too_large', + ); + }); + + it('does not wrap an error that LOOKS like FsError but has wrong name', async () => { + // Defensive: an unrelated error class with a `kind` field but + // a different `name` should fall through to the unstructured + // path. Prevents accidental wrapping of e.g. permission errors + // that happen to carry a `kind` discriminator. + const writeText = vi.fn(async (): Promise => { + const err = new Error('looks-similar'); + err.name = 'PermissionForbiddenError'; + (err as unknown as { kind: string }).kind = + 'designated_originator_mismatch'; + throw err; + }); + const client = makeClient({ writeText, readText: vi.fn() }); + + const err = (await client + .writeTextFile({ + path: '/x', + content: 'y', + sessionId: 'sess:test', + }) + .catch((e) => e)) as Error & { code?: number }; + + expect(err.name).toBe('PermissionForbiddenError'); + expect(err.code).toBeUndefined(); + }); + }); + + describe('inline fallback when fileSystem is omitted (regression guard)', () => { + let tmpDir: string; + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridgeclient-test-')); + }); + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it('writeTextFile actually writes to disk through the inline proxy', async () => { + const client = makeClient(/* no fileSystem */); + const target = path.join(tmpDir, 'inline.txt'); + + await client.writeTextFile({ + path: target, + content: 'inline-content', + sessionId: 'sess:test', + }); + + const onDisk = await fsp.readFile(target, 'utf8'); + expect(onDisk).toBe('inline-content'); + }); + + it('readTextFile actually reads from disk through the inline proxy', async () => { + const client = makeClient(/* no fileSystem */); + const target = path.join(tmpDir, 'src.txt'); + await fsp.writeFile(target, 'on-disk-content', 'utf8'); + + const response = await client.readTextFile({ + path: target, + sessionId: 'sess:test', + }); + + expect(response.content).toBe('on-disk-content'); + }); + }); +}); + +/** + * Wenshao review #4335 / 3271978365 — `requestPermission`'s pre-publish + * `CancelSentinelCollisionError` guard prevents an orphan SSE + * `permission_request` event from being emitted when an agent's + * `allowedOptionIds` legitimately contains '__cancelled__'. The + * mediator-level test (`permissionMediator.test.ts:330`) covers the + * issue-time collision detection inside `mediator.request`, but + * BridgeClient layers a separate pre-publish check whose distinct + * purpose — preventing orphan SSE frames — needs its own test. + */ +describe('BridgeClient — requestPermission pre-publish collision guard', () => { + it('throws CancelSentinelCollisionError BEFORE publishing on the events bus', async () => { + // Arrange: a fake session entry whose `events.publish` is a spy. + // If the collision check ran AFTER publish, this would record a + // call and the assertion below would fail. + const publish = vi.fn().mockReturnValue(true); + const fakeEntry = { + sessionId: 'sess:test', + pendingPermissionIds: new Set(), + events: { publish }, + activePromptOriginatorClientId: undefined, + }; + + const noPermissionFlow = () => { + throw new Error('test: not reachable on collision-throw path'); + }; + // Wenshao review #4335 / 3272581569 — narrowed mediator type + // means the stub only needs `request`. + const throwerMediator = { request: noPermissionFlow } as never; + const client = new BridgeClient( + ((sid: string) => (sid === 'sess:test' ? fakeEntry : undefined)) as never, + noPermissionFlow as never, + throwerMediator, + 0, + Infinity, + ); + + // Act + Assert: a sentinel-colliding option causes the bridge + // client to throw before reaching publish. + await expect( + client.requestPermission({ + sessionId: 'sess:test', + toolCall: { toolCallId: 'tc-1', title: 'rm -rf /' }, + options: [ + { optionId: 'allow', name: 'Allow', kind: 'allow_once' }, + { + optionId: CANCEL_VOTE_SENTINEL, + name: 'Adversarial label', + kind: 'allow_once', + }, + ], + }), + ).rejects.toThrow(CancelSentinelCollisionError); + + // The crucial post-condition: no SSE frame went out. + expect(publish).not.toHaveBeenCalled(); + // And the cap-index was never touched (only added AFTER publish). + expect(fakeEntry.pendingPermissionIds.size).toBe(0); + }); +}); diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts new file mode 100644 index 0000000000..c240a508a4 --- /dev/null +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -0,0 +1,1119 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import type { + Client, + ReadTextFileRequest, + ReadTextFileResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, + WriteTextFileRequest, + WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import { RequestError } from '@agentclientprotocol/sdk'; +import type { BridgeEvent, EventBus } from './eventBus.js'; +import type { BridgeFileSystem } from './bridgeFileSystem.js'; +import { CANCEL_VOTE_SENTINEL } from './permissionMediator.js'; +// Narrowed from the concrete `MultiClientPermissionMediator` to the +// sub-interface this class actually uses (`request` only). Structural +// typing lets the bridge factory pass the full mediator instance +// without a cast; test stubs only need to fake the `request` method. +import type { PermissionMediator } from './permission.js'; +import type { + PermissionRequestRecord, + PermissionResolution, +} from './permission.js'; +import { CancelSentinelCollisionError } from './bridgeErrors.js'; +import { writeStderrLine } from './internal/stderrLine.js'; + +/** + * Duck-type check for `FsError` from `cli/src/serve/fs/errors.ts`. + * FsError lives in `cli`, but this class lives in `acp-bridge` — a + * direct import would invert the dependency. Uses `.name`-based duck + * typing (same pattern as `mapDomainErrorToErrorKind` in status.ts). + * + * Without this: when the `BridgeFileSystem` adapter throws an + * `FsError`, the ACP SDK's default RPC error path serializes only + * `error.message` — the structured `kind` / `status` / `hint` are + * lost. With this: the bridge catches FsError and rethrows as ACP + * `RequestError(-32603, message, {errorKind, hint, status})` so the + * agent's RPC client can branch on `data.errorKind`. + */ +interface FsErrorShape { + name: 'FsError'; + message: string; + kind: string; + status?: number; + hint?: string; +} + +function isFsErrorShape(err: unknown): err is FsErrorShape { + return ( + err instanceof Error && + err.name === 'FsError' && + typeof (err as { kind?: unknown }).kind === 'string' + ); +} + +/** + * Rethrow an FsError as a structured ACP `RequestError` so the + * agent's RPC client sees `data.errorKind` / `data.hint` / + * `data.status` rather than just the human-readable message. + * Non-FsError errors are rethrown unchanged — the default ACP + * serialization is fine for unstructured errors. + */ +function preserveFsErrorOverAcp(err: unknown): never { + if (isFsErrorShape(err)) { + throw new RequestError(-32603, err.message, { + errorKind: err.kind, + ...(err.hint !== undefined ? { hint: err.hint } : {}), + ...(err.status !== undefined ? { status: err.status } : {}), + }); + } + throw err; +} + +/** + * Translate the mediator's internal `PermissionResolution` to the + * ACP-shaped `RequestPermissionResponse` the agent expects. + * Voter-cancel, timeout, and session-closed all project to the same + * `{outcome: 'cancelled'}` shape — the ACP wire frame doesn't + * distinguish them. The audit log carries `decisionReason.type` + * for forensic discrimination. + */ +function resolutionToAcpResponse( + resolution: PermissionResolution, +): RequestPermissionResponse & Record { + if (resolution.kind === 'option') { + return { + outcome: { outcome: 'selected', optionId: resolution.optionId }, + ...(resolution.metadata ?? {}), + }; + } + return { outcome: { outcome: 'cancelled' } }; +} + +/** + * Bounded buffering for ACP `extNotification` frames that arrive on + * `BridgeClient` before the matching session has been registered in + * `byId`. The bridge populates `byId` only AFTER `connection.newSession` + * returns, but the child's MCP discovery runs INSIDE `newSession` and + * may fire budget events synchronously before the response makes it + * back. Without buffering, those frames are silently dropped. + * + * The triple bound (max sessions x max events per session x TTL) + * caps worst-case heap retention even if a malicious / buggy child + * spammed `extNotification` for sessionIds that never register: + * 64 x 32 x ~200B = 400 KB total. TTL is generous (60s) so brief + * scheduling pauses don't cause real warnings to be evicted. + */ +const MAX_EARLY_EVENT_SESSIONS = 64; +const MAX_EARLY_EVENTS_PER_SESSION = 32; +const MAX_SUGGESTION_LENGTH = 500; +const EARLY_EVENT_TTL_MS = 60_000; + +// Known approval-mode ids accepted on the in-session `current_mode_update` +// demux path. Mirrors the `modeMap` keys in `Session.setMode` (CLI); an id +// outside this set is dropped before it fans out to SSE clients / the SDK +// reducer. Keep the two in lockstep. Exported so the bridge's reconcile and +// snapshot-seed paths apply the same enum backstop to agent-supplied mode ids. +export const KNOWN_APPROVAL_MODES: ReadonlySet = new Set([ + 'plan', + 'default', + 'auto-edit', + 'auto', + 'yolo', +]); + +/** + * Human-readable label for a `fs.Stats` object's kind, used in the + * `readTextFile` "not a regular file" rejection message (BX8YO). + * Sockets, pipes, char-devices etc. all report `size: 0` but stream + * unbounded data; the operator wants to know which one they hit so + * the path-mistake is obvious. + */ +function describeStatKind(stats: import('node:fs').Stats): string { + if (stats.isDirectory()) return 'directory'; + if (stats.isSymbolicLink()) return 'symlink'; + if (stats.isCharacterDevice()) return 'character device'; + if (stats.isBlockDevice()) return 'block device'; + if (stats.isFIFO()) return 'named pipe (FIFO)'; + if (stats.isSocket()) return 'socket'; + return 'non-regular file'; +} + +/** + * Extract the line range `[startLine, endLine)` (0-based) from a string + * without allocating a per-line array. Equivalent to + * `content.split('\n').slice(startLine, endLine).join('\n')` but + * O(file size) string scan rather than O(file size) string + O(line + * count) array. Matters for the partial-read path of `readTextFile` + * where the limit is small and the file is large. + */ +function sliceLineRange( + content: string, + startLine: number, + endLine: number | undefined, +): string { + // Find the byte offset where line `startLine` begins. + let offset = 0; + for (let i = 0; i < startLine; i++) { + const nl = content.indexOf('\n', offset); + if (nl === -1) return ''; + offset = nl + 1; + } + if (endLine === undefined) return content.slice(offset); + // Walk `endLine - startLine` newlines forward to find the end byte. + let end = offset; + const want = endLine - startLine; + for (let i = 0; i < want; i++) { + const nl = content.indexOf('\n', end); + if (nl === -1) return content.slice(offset); + end = nl + 1; + } + // Trim the trailing `\n` so the slice mirrors `lines.slice(...).join('\n')`. + return content.slice(offset, end > offset ? end - 1 : end); +} + +/** + * Minimal session-entry shape `BridgeClient` reads via its + * `resolveEntry` callback. Defined here (rather than importing the + * factory's richer `SessionEntry`) to keep the bridge package free of + * daemon-host session-bookkeeping types: the factory's `SessionEntry` + * structurally satisfies this interface, so no explicit conversion + * is required. + * + * Only four fields cross the boundary: `sessionId`, `events`, + * `pendingPermissionIds`, `activePromptOriginatorClientId`. New fields + * BridgeClient grows must be added here too (and the factory's + * `SessionEntry` is required to provide them — TS enforces the + * structural match at the callback signature). + */ +export interface BridgeClientSessionEntry { + sessionId: string; + events: EventBus; + pendingPermissionIds: Set; + activePromptOriginatorClientId?: string; + /** + * True while the bridge drives a model roundtrip; the + * `current_model_update` extNotification demux reads it to suppress + * promotion during a bridge-driven change. Set on the full `SessionEntry` + * in `bridge.ts`; surfaced here for the demux. + */ + modelRoundtripInFlight?: boolean; + /** A2: mirrors `modelRoundtripInFlight` for approval-mode roundtrips. */ + approvalModeRoundtripInFlight?: boolean; +} + +/** + * Bridge `Client` implementation — the daemon's response surface for things + * the agent asks the client (file reads/writes, permission prompts). + * + * Stage 1 behavior: + * - `requestPermission` publishes a `permission_request` event onto the + * session bus and awaits the first HTTP `POST /permission/:requestId` + * vote (first-responder wins). When the session is cancelled or the + * daemon shuts down, the pending promise resolves with + * `{ outcome: { outcome: 'cancelled' } }` per ACP spec. + * - `sessionUpdate` notifications publish onto the session's EventBus; SSE + * subscribers (`GET /session/:id/events`) drain it. + * - File reads/writes proxy to local fs (daemon and agent share the host). + * + * Stage 1 trust model: the spawned `qwen --acp` child runs as the same user + * as the daemon, so the file-proxy methods do NOT enforce a workspace-cwd + * sandbox. The agent could already read or write the same files via its + * built-in tools (e.g. shell). Restricting the bridge here would be + * theatre. Stage 4+ remote-sandbox deployments swap this `Client` for a + * sandbox-aware variant. + */ +export class BridgeClient implements Client { + constructor( + /** + * Look up the `SessionEntry` for an ACP call. Stage 1.5 multi- + * session on one channel means `BridgeClient` is shared across + * many sessions, so we can't bind the entry in a closure — we + * dispatch by the `sessionId` ACP includes in every per-session + * notification / request. `undefined` sessionId is the fallback + * for ACP calls that don't carry one (none expected on the + * client surface as of this writing) and resolves to whatever + * the channel's most-recent entry is — kept defensive to avoid + * silent drops if ACP grows a no-sessionId call. + */ + private readonly resolveEntry: ( + sessionId?: string, + ) => BridgeClientSessionEntry | undefined, + private readonly resolvePendingRestoreEvents: ( + sessionId?: string, + ) => EventBus | undefined, + /** The multi-client permission coordinator. Owns ALL pending + + * resolved permission state; this client just plumbs + * `requestPermission` into `mediator.request` and forwards + * the resolution to the agent. Strategy dispatch and audit/emit + * fan-out live inside the mediator. + */ + private readonly mediator: Pick, + /** + * Bd1yh: wall-clock ms before `requestPermission` resolves as + * cancelled if no client vote arrives. 0 = disabled. Prevents + * the per-session FIFO `promptQueue` from poisoning forever + * when no SSE subscriber is connected. Forwarded directly to + * `mediator.request`; the mediator owns the timer. + */ + private readonly permissionTimeoutMs: number, + /** + * Bd1z5: per-session cap on in-flight permissions. New requests + * past this cap resolve as cancelled with a stderr warning. + * Infinity = disabled. The bridge keeps `entry.pendingPermissionIds` + * as a fast cap-check index; the mediator is still the source of + * truth for the pending registry. + */ + private readonly maxPendingPerSession: number, + /** + * Optional fs injection seam. When provided, `writeTextFile` / + * `readTextFile` delegate to this implementation instead of running + * the inline `fs.realpath` / `fs.writeFile` / `fs.readFile` proxy + * below. Production `qwen serve` wires a serve-side adapter + * wrapping `WorkspaceFileSystem` here so writes get the TOCTOU + + * symlink + trust-gate + audit machinery the inline proxy lacks. + * Omitted by tests + Mode A in-process consumers + channels / IDE + * companion — preserves the inline proxy behavior. + */ + private readonly fileSystem?: BridgeFileSystem, + /** + * §2.3 callback: centralised `model_switched` publish through the + * bridge factory's cache-updating helper. The BridgeClient calls + * this instead of inlining `entry.events.publish(...)` so the + * cache update + generation bump stays atomic in one place. + */ + private readonly onModelPromoted?: ( + entry: BridgeClientSessionEntry, + modelId: string, + originatorClientId: string | undefined, + ) => void, + /** + * §2.3 / A2 callback: centralised `approval_mode_changed` publish. + * Called by the A2 `current_mode_update` demux when the agent + * switches approval mode in-session (exit_plan_mode, ProceedAlways, + * /mode). `previous` is read from the bridge state cache. + */ + private readonly onModePromoted?: ( + entry: BridgeClientSessionEntry, + modeId: string, + originatorClientId: string | undefined, + ) => void, + ) {} + + async requestPermission( + params: RequestPermissionRequest, + ): Promise { + const entry = this.resolveEntry(params.sessionId); + if (!entry) return { outcome: { outcome: 'cancelled' } }; + + // Bd1z5: per-session cap. Reject before issuing so we never + // grow `pendingPermissionIds` past the limit. + if (entry.pendingPermissionIds.size >= this.maxPendingPerSession) { + writeStderrLine( + `qwen serve: session ${entry.sessionId} exceeded ` + + `maxPendingPermissionsPerSession (${this.maxPendingPerSession}) — ` + + `resolving new permission as cancelled.`, + ); + return { outcome: { outcome: 'cancelled' } }; + } + + // BkwQI: snapshot the option-id set the agent is offering for + // this prompt. The mediator validates the voter's `optionId` + // against this set so a malicious client can't forge an option + // (e.g. `ProceedAlways*`) the agent intentionally hid. + const allowedOptionIds = new Set( + params.options.map((o: { optionId?: unknown }) => + String(o.optionId ?? ''), + ), + ); + allowedOptionIds.delete(''); + + // Pre-flight the cancel-vote sentinel collision BEFORE publishing + // the `permission_request` SSE event. The mediator also checks + // defensively at issue time, but if we publish first and the + // mediator throws, SSE subscribers see an orphan event with no + // resolution. + const requestId = randomUUID(); + if (allowedOptionIds.has(CANCEL_VOTE_SENTINEL)) { + throw new CancelSentinelCollisionError(requestId, CANCEL_VOTE_SENTINEL); + } + + // Publish AFTER the collision check so a violating agent never + // leaves an orphan `permission_request` on the SSE bus. If the + // bus is closed (shutdown race), bail before touching the + // mediator. The mediator's N1 invariant (synchronous register + // inside the Promise executor) protects against the + // forgetSession-races-with-issue case ONLY when register runs; + // refusing to enter the mediator on a publish-failure is the + // symmetric defense for the publish-failure case. + const published = entry.events.publish({ + type: 'permission_request', + data: { + requestId, + sessionId: entry.sessionId, + toolCall: params.toolCall, + options: params.options, + }, + ...(entry.activePromptOriginatorClientId + ? { originatorClientId: entry.activePromptOriginatorClientId } + : {}), + }); + if (!published) return { outcome: { outcome: 'cancelled' } }; + + // Cap-index add happens AFTER publish-success so a publish-fail + // path doesn't need to roll back. The mediator's + // `forgetSession` is the only thing that drains this index (via + // the bridge's `cancelPendingForSession`). + entry.pendingPermissionIds.add(requestId); + try { + const record: PermissionRequestRecord = { + requestId, + sessionId: entry.sessionId, + originatorClientId: entry.activePromptOriginatorClientId, + allowedOptionIds, + issuedAtMs: Date.now(), + }; + const resolution = await this.mediator.request( + record, + this.permissionTimeoutMs, + ); + return resolutionToAcpResponse(resolution); + } finally { + entry.pendingPermissionIds.delete(requestId); + } + } + + async sessionUpdate(params: SessionNotification): Promise { + const entry = this.resolveEntry(params.sessionId); + const events = + entry?.events ?? this.resolvePendingRestoreEvents(params.sessionId); + if (!events) return; + events.publish({ + type: 'session_update', + data: params, + ...(entry?.activePromptOriginatorClientId + ? { originatorClientId: entry.activePromptOriginatorClientId } + : {}), + }); + } + + /** + * Bounded early-event buffer. Frames are keyed by sessionId; each + * entry tracks its `expiresAt` for lazy TTL-based eviction in + * `bufferEarlyEvent`. Drained by `drainEarlyEvents` whenever the + * bridge registers a session with a matching id. See + * MAX_EARLY_EVENT_* constants for capacity bounds. + */ + private readonly earlyEvents = new Map< + string, + { + frames: Array>; + expiresAt: number; + } + >(); + + /** + * Tombstone for closed/killed session ids. Prevents late + * `extNotification` from a dying child from leaking into the + * early-event buffer and being replayed onto a future session + * that reuses the same id via `session/load` or `session/resume`. + * + * Tombstone semantics: + * - Marked when the bridge removes a sessionId from `byId` (kill + * path, channel.exited handler, closeSession). + * - Concurrently purges any in-flight `earlyEvents[id]`. + * - `bufferEarlyEvent` rejects tombstoned ids. + * - `drainEarlyEvents` clears the tombstone — a fresh + * `createSessionEntry` for the same id is a legitimate + * "load/resume of a persisted session id" case. + * - TTL = `EARLY_EVENT_TTL_MS` (60s) — same as the early-event + * buffer, so by the time a tombstone expires there can be no + * stale frame for that id anywhere in the system. + */ + private readonly tombstonedSessionIds = new Map(); + + /** + * Allow-list of sessionIds currently being restored via + * `session/load` / `session/resume`. Bypasses the tombstone check + * in `bufferEarlyEvent` so restore-time guardrail events for a + * previously-closed id flow through to the future + * `createSessionEntry -> drainEarlyEvents` call. + * + * Without this, the tombstone set before a future `load` can clear + * it via `drainEarlyEvents` would silently drop legitimate + * restore-time events (e.g. MCP discovery budget events firing + * during the ACP call window). + * + * Bridge factory enters the set before awaiting the ACP restore + * call and exits on settle (success or failure). + */ + private readonly inFlightRestoreIds = new Set(); + + /** + * Handle child->bridge ACP `extNotification` calls. Five methods are + * recognized — `qwen/notify/session/model-update`, + * `qwen/notify/session/mode-update`, + * `qwen/notify/session/prompt-suggestion` (followup assist), + * `qwen/notify/session/terminal-sequence`, and + * `qwen/notify/session/mcp-budget-event` — each translated into a + * session-scoped SSE frame. Unknown methods are dropped silently + * for forward-compat. + */ + async extNotification( + method: string, + params: Record, + ): Promise { + if (method === 'qwen/notify/session/model-update') { + this.handleInSessionModelUpdate(params); + return; + } + if (method === 'qwen/notify/session/mode-update') { + this.handleInSessionModeUpdate(params); + return; + } + if (method === 'qwen/notify/session/prompt-suggestion') { + const sessionId = params['sessionId']; + const suggestion = params['suggestion']; + const promptId = params['promptId']; + if ( + typeof sessionId !== 'string' || + typeof suggestion !== 'string' || + suggestion.length === 0 || + suggestion.length > MAX_SUGGESTION_LENGTH || + typeof promptId !== 'string' + ) { + writeStderrLine( + `[demux] session=${typeof sessionId === 'string' ? sessionId : ''} type=prompt_suggestion action=dropped reason=malformed`, + ); + return; + } + const entry = this.resolveEntry(sessionId); + if (!entry) return; + entry.events.publish({ + type: 'followup_suggestion', + data: { sessionId, suggestion, promptId }, + }); + return; + } + if (method === 'qwen/notify/session/terminal-sequence') { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string') return; + const { v: _v, sessionId: _sid, ...rest } = params; + void _v; + void _sid; + this.publishExtNotification(sessionId, 'terminal_sequence', rest); + return; + } + if (method !== 'qwen/notify/session/mcp-budget-event') return; + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string') return; + const kind = params['kind']; + let type: string; + if (kind === 'budget_warning') { + type = 'mcp_budget_warning'; + } else if (kind === 'refused_batch') { + type = 'mcp_child_refused_batch'; + } else { + return; + } + // Strip the routing fields (`v`, `sessionId`, `kind`) from the + // outbound `data` payload — the SSE frame already carries `v` at + // the envelope level (`EVENT_SCHEMA_VERSION`) and the session id + // is implicit from the endpoint, so duplicating them in `data` + // would be noise. `kind` is encoded as the frame `type`. + const { v: _v, sessionId: _sid, kind: _kind, ...rest } = params; + void _v; + void _sid; + void _kind; + this.publishExtNotification(sessionId, type, rest); + } + + private publishExtNotification( + sessionId: string, + type: string, + data: Record, + ): void { + const entry = this.resolveEntry(sessionId); + const frame: Omit = { + type, + data, + ...(entry?.activePromptOriginatorClientId + ? { originatorClientId: entry.activePromptOriginatorClientId } + : {}), + }; + if (entry) { + entry.events.publish(frame); + return; + } + // No entry yet — buffer for `drainEarlyEvents`. The bridge calls + // `drainEarlyEvents` immediately after `byId.set(sessionId, entry)` + // in `createSessionEntry`; if the session never registers (spawn + // failure), the entry is GC'd by TTL after EARLY_EVENT_TTL_MS. + this.bufferEarlyEvent(sessionId, frame); + } + + /** + * Promote an in-session `current_model_update` extNotification to a + * `model_switched` bus event. Suppressed while the bridge is driving + * its own model roundtrip (`entry.modelRoundtripInFlight`) — there the + * bridge publishes the authoritative `model_switched`, so promoting + * here too would double-publish. A structured log records the decision + * so the `dropped` case is observable. + */ + private handleInSessionModelUpdate(params: Record): void { + const sessionId = params['sessionId']; + const currentModelId = params['currentModelId']; + if (typeof sessionId !== 'string' || typeof currentModelId !== 'string') { + return; + } + const entry = this.resolveEntry(sessionId); + if (!entry) { + // No live session — a model switch only happens on an established + // session, so unlike the MCP-budget path there is nothing to buffer. + writeStderrLine( + `[demux] session=${sessionId} type=current_model_update action=dropped reason=no_entry`, + ); + return; + } + if (entry.modelRoundtripInFlight) { + // Bridge owns this change and will publish model_switched itself. + writeStderrLine( + `[demux] session=${sessionId} type=current_model_update action=suppressed reason=bridge_roundtrip_in_flight`, + ); + return; + } + if (this.onModelPromoted) { + this.onModelPromoted( + entry, + currentModelId, + entry.activePromptOriginatorClientId, + ); + } else { + // `EventBus.publish` never throws (closed bus → undefined no-op); per + // its documented contract we don't wrap it. + entry.events.publish({ + type: 'model_switched', + data: { sessionId, modelId: currentModelId }, + ...(entry.activePromptOriginatorClientId + ? { originatorClientId: entry.activePromptOriginatorClientId } + : {}), + }); + } + writeStderrLine( + `[demux] session=${sessionId} type=current_model_update action=promoted model=${currentModelId}`, + ); + } + + /** + * A2: promote an in-session `current_mode_update` extNotification to + * `approval_mode_changed`. Uses the same suppression pattern as + * `handleInSessionModelUpdate` — suppressed while the bridge is driving + * its own approval-mode roundtrip (`entry.approvalModeRoundtripInFlight`) + * — but diverges with two additions the model handler lacks: enum + * validation against `KNOWN_APPROVAL_MODES`, and a legacy + * `session_update{current_mode_update}` dual-emit for IDE companion + * compat (transition — see §6 of the design doc), itself deduped via the + * `legacyFrameSent` flag. + */ + private handleInSessionModeUpdate(params: Record): void { + const sessionId = params['sessionId']; + const currentModeId = params['currentModeId']; + if (typeof sessionId !== 'string' || typeof currentModeId !== 'string') { + return; + } + // Validate against the known approval-mode enum before it fans out. + // `Session.setMode` guards the symmetric send path with the same set + // ("an unknown id would call setApprovalMode(undefined), leaving the + // permission system undefined"); this is the receive path the agent + // can reach without that validation, so an unknown id here would + // propagate through `approval_mode_changed` to every SSE client and + // land in the SDK reducer's `state.approvalMode`. Keep in lockstep + // with `Session.setMode`'s `modeMap` keys (includes `auto`). + if (!KNOWN_APPROVAL_MODES.has(currentModeId)) { + writeStderrLine( + `[demux] session=${sessionId} type=current_mode_update action=dropped reason=unknown_mode mode=${currentModeId}`, + ); + return; + } + const entry = this.resolveEntry(sessionId); + if (!entry) { + writeStderrLine( + `[demux] session=${sessionId} type=current_mode_update action=dropped reason=no_entry`, + ); + return; + } + if (entry.approvalModeRoundtripInFlight) { + writeStderrLine( + `[demux] session=${sessionId} type=current_mode_update action=suppressed reason=bridge_roundtrip_in_flight`, + ); + return; + } + if (this.onModePromoted) { + this.onModePromoted( + entry, + currentModeId, + entry.activePromptOriginatorClientId, + ); + } else { + // Fallback path (no `onModePromoted` injected — tests / non-bridge + // consumers; production always wires the bridge callback). Mirror + // the main path's full payload: the SDK's + // `isApprovalModeChangedData` requires `previous` (non-empty + // string) and `persisted` (boolean), so a `{ sessionId, next }` + // shape fails validation and `asKnownDaemonEvent` drops the event. + // `previous` is unavailable on this path (the cache lives on the + // bridge's `SessionEntry`, not the demux interface), so seed it + // with the protocol default. + // + // `EventBus.publish` never throws (a closed bus is a return-undefined + // no-op and subscriber-enqueue failures are caught internally), so + // per its documented contract we don't wrap it in try/catch. + entry.events.publish({ + type: 'approval_mode_changed', + data: { + sessionId, + previous: 'default', + next: currentModeId, + persisted: false, + }, + ...(entry.activePromptOriginatorClientId + ? { originatorClientId: entry.activePromptOriginatorClientId } + : {}), + }); + } + // TODO(dual-emit-removal): also emit the legacy generic + // `session_update{current_mode_update}` for one release cycle so the + // VS Code IDE companion's existing `case 'current_mode_update'` + // handler keeps working. Remove this block (and its tracking issue) + // once the companion ships an `approval_mode_changed` handler. + // + // Skip it when the producer already sent the legacy frame itself: the + // `exit_plan_mode` path (`Session.sendCurrentModeUpdateNotification`) + // calls `sendUpdate` before this extNotification, which + // `BridgeClient.sessionUpdate` already fanned onto the bus as the same + // `session_update{current_mode_update}` frame. Dual-emitting here would + // deliver it twice. The `setMode` path omits the flag (it has no + // `sendUpdate`), so its dual-emit still fires. + // + // Use the canonical ACP-nested shape (`data.update.sessionUpdate`), + // matching what `BridgeClient.sessionUpdate` publishes for a real + // `current_mode_update` notification. A flat + // `{ sessionId, sessionUpdate, currentModeId }` would (a) not be + // recognised by the companion's standard `data.update.sessionUpdate` + // switch, and (b) collide structurally with the real `session_update` + // the agent already emits on the `exit_plan_mode` path — leaving two + // incompatible shapes on the bus for one change. + if (params['legacyFrameSent'] === true) { + writeStderrLine( + `[demux] session=${sessionId} type=current_mode_update action=promoted mode=${currentModeId} legacy_frame=skipped`, + ); + return; + } + // `EventBus.publish` never throws (closed bus → undefined no-op); per its + // documented contract we don't wrap it in try/catch. + entry.events.publish({ + type: 'session_update', + data: { + sessionId, + update: { + sessionUpdate: 'current_mode_update', + currentModeId, + }, + }, + ...(entry.activePromptOriginatorClientId + ? { originatorClientId: entry.activePromptOriginatorClientId } + : {}), + }); + writeStderrLine( + `[demux] session=${sessionId} type=current_mode_update action=promoted mode=${currentModeId}`, + ); + } + + /** + * Enqueue `frame` for `sessionId`. Lazy TTL sweep runs first so + * caller doesn't pay for stale entries before deciding whether + * the session-cap is reached. New sessionIds past + * `MAX_EARLY_EVENT_SESSIONS` are dropped (defense against a + * malicious / buggy child fanning out fake sessionIds); same- + * sessionId frames past `MAX_EARLY_EVENTS_PER_SESSION` are + * dropped to bound per-session memory. + */ + private bufferEarlyEvent( + sessionId: string, + frame: Omit, + ): void { + const now = Date.now(); + // Drop frames for ids the bridge has already marked closed/killed. + // Sweep + check before any other work so a malicious / buggy child + // can't keep appending post-mortem frames against an old id. Live + // ids that re-register (load/resume) clear their tombstone in + // `drainEarlyEvents`. + // + // Skip the tombstone check for ids currently being restored so a + // `close -> load same id` sequence within 60s doesn't lose + // restore-time guardrail events. + this.sweepExpiredTombstones(now); + if ( + this.tombstonedSessionIds.has(sessionId) && + !this.inFlightRestoreIds.has(sessionId) + ) { + writeStderrLine( + `qwen serve: dropping mcp guardrail extNotification ` + + `for tombstoned session ${JSON.stringify(sessionId)} ` + + `(post-close stale event)`, + ); + return; + } + this.sweepExpiredEarlyEvents(now); + let buf = this.earlyEvents.get(sessionId); + if (!buf) { + if (this.earlyEvents.size >= MAX_EARLY_EVENT_SESSIONS) { + // Hitting this cap means the daemon is under notification + // pressure from 64+ concurrent sessions — worth surfacing. + writeStderrLine( + `qwen serve: dropping mcp guardrail extNotification — ` + + `early-event buffer at MAX_EARLY_EVENT_SESSIONS ` + + `(${MAX_EARLY_EVENT_SESSIONS}); possible session-id fanout abuse`, + ); + return; + } + buf = { frames: [], expiresAt: now + EARLY_EVENT_TTL_MS }; + this.earlyEvents.set(sessionId, buf); + } + if (buf.frames.length >= MAX_EARLY_EVENTS_PER_SESSION) { + writeStderrLine( + `qwen serve: dropping mcp guardrail extNotification ` + + `for session ${JSON.stringify(sessionId)} — per-session ` + + `cap (${MAX_EARLY_EVENTS_PER_SESSION}) reached`, + ); + return; + } + buf.frames.push(frame); + } + + private sweepExpiredEarlyEvents(now: number): void { + for (const [sid, buf] of this.earlyEvents) { + if (buf.expiresAt <= now) this.earlyEvents.delete(sid); + } + } + + private sweepExpiredTombstones(now: number): void { + for (const [sid, expiresAt] of this.tombstonedSessionIds) { + if (expiresAt <= now) this.tombstonedSessionIds.delete(sid); + } + } + + /** + * Mark a sessionId as closed so a late `extNotification` from the + * dying child can't leak into the early-event buffer. Bridge factory + * calls this from every `byId.delete(sid)` site (kill path, + * channel.exited handler, closeSession). Idempotent on already- + * tombstoned ids — refreshes the TTL so a recently-killed id stays + * dead long enough for any in-flight stale frames to expire. + */ + markSessionClosed(sessionId: string): void { + const now = Date.now(); + // Bound `tombstonedSessionIds` under session churn. On a daemon + // that closes/kills many sessions but rarely receives + // extNotifications, the map would grow monotonically without this + // sweep. O(map size) but cheap (one integer compare per entry); + // under any realistic workload the map stays small. + this.sweepExpiredTombstones(now); + this.tombstonedSessionIds.set(sessionId, now + EARLY_EVENT_TTL_MS); + // Purge any frames already buffered for this id — they're now + // stale by definition (their session is dead). + this.earlyEvents.delete(sessionId); + } + + /** + * Mark a sessionId as currently being restored via `session/load` / + * `session/resume`. While in this set, `bufferEarlyEvent` accepts + * frames for the id even if it's tombstoned — so restore-time + * guardrail events from the freshly-restored child reach + * `drainEarlyEvents` instead of being rejected by the tombstone. + * + * Bridge factory calls this BEFORE awaiting the ACP restore call. + * `clearRestoreInFlight` is paired in the matching `finally` so a + * failed restore doesn't leave a dangling allow-list entry. + */ + markRestoreInFlight(sessionId: string): void { + this.inFlightRestoreIds.add(sessionId); + } + + /** + * Companion to `markRestoreInFlight`. Bridge factory calls this when + * the restore IIFE settles — after `createSessionEntry` runs + * (success) or after the ACP restore call fails (error). Cleared to + * prevent the Set from growing forever under high restore churn. + */ + clearRestoreInFlight(sessionId: string): void { + this.inFlightRestoreIds.delete(sessionId); + } + + /** + * Drain any frames buffered for `sessionId` onto `entry.events`. + * Bridge calls this immediately after `byId.set(sessionId, entry)` + * in `createSessionEntry`. The frames were captured before the + * entry existed (e.g. MCP discovery during the child's `newSession` + * handler), so draining them now lands them in the replay ring as + * the FIRST events of this session. + * + * Public so the bridge factory can call it directly. Idempotent on + * unknown sessionIds. + */ + drainEarlyEvents(sessionId: string, entry: BridgeClientSessionEntry): void { + // A fresh registration clears any tombstone for this id — this is + // the legitimate "load/resume of a persisted session id" case. + // Any stale pre-tombstone frame was already rejected by + // `bufferEarlyEvent`; clearing the tombstone now means subsequent + // notifications flow through the normal `entry.events.publish` + // path. + this.tombstonedSessionIds.delete(sessionId); + const buf = this.earlyEvents.get(sessionId); + if (!buf) return; + for (const frame of buf.frames) entry.events.publish(frame); + this.earlyEvents.delete(sessionId); + } + + async writeTextFile( + params: WriteTextFileRequest, + ): Promise { + // Delegate to the injected `BridgeFileSystem` when present. + // Production `qwen serve` wires `WorkspaceFileSystem` through a + // serve-side adapter so writes get the trust-gate + TOCTOU + + // symlink + `.gitignore` + audit machinery the inline proxy below + // lacks. Tests, Mode A consumers, channels, and IDE companion + // fall through to the inline path. + if (this.fileSystem) { + // Preserve FsError structure over ACP wire. Without this catch, + // an `FsError({kind:'untrusted_workspace'})` from the adapter + // would land at the agent with the kind/status/hint stripped. + // See `preserveFsErrorOverAcp` for rationale. + try { + return await this.fileSystem.writeText(params); + } catch (err) { + preserveFsErrorOverAcp(err); + } + } + // Stage 1 known divergence: this raw `fs.writeFile` reimplements file + // I/O instead of delegating to core's filesystem service. The + // user-visible scenarios where they differ: + // - BOM handling: this drops/re-encodes whatever the agent passed; + // core would preserve. + // - Non-UTF-8 source files: round-tripping through utf8 mangles + // content. + // - Original line endings: core preserves CRLF on Windows files; + // this writes whatever the agent buffered. + // Wiring core's FileSystemService through the bridge requires + // exposing it as a constructor dep; the cost-benefit is low for + // Stage 1 (most agent-side tools call core directly, NOT through + // these ACP fs methods) and Stage 2 in-process eliminates the + // bridge fs proxy entirely. Tracked as a Stage 2 prerequisite — + // the `BridgeFileSystem` injection addresses exactly this seam. + // + // BSA0D: write-then-rename so a SIGKILL / OOM mid-write doesn't + // leave the target truncated. POSIX `rename` is atomic within the + // same filesystem; on Windows it's atomic when the target doesn't + // exist (we tolerate the race-on-overwrite case as a Stage 2 + // gap). The tmp file lives in the same directory so the rename + // can't cross filesystem boundaries (which would degrade to a + // copy + race re-emerges). + // + // BX8Yw: rename would replace a symlink at the target path with a + // regular file, leaving the original symlink target unchanged + // while the write appears successful. Resolve symlinks via + // `realpath` first so the atomic write lands at the actual file. + // + // BfFvO: dangling-symlink case — `realpath` throws ENOENT when + // the symlink's target doesn't exist. A blanket catch then + // silently falls back to `params.path` (the symlink itself), and + // `rename(tmp, params.path)` would replace the symlink with a + // regular file — exactly the bug BX8Yw was supposed to fix. + // Distinguish "path doesn't exist at all" (truly new file → + // write through) from "dangling symlink" (symlink exists, target + // doesn't → write through to the symlink's intended target so + // the symlink stays a symlink and points at a fresh file). + let realTarget = params.path; + try { + realTarget = await fs.realpath(params.path); + } catch (err) { + const code = + err && typeof err === 'object' && 'code' in err + ? (err as { code?: unknown }).code + : undefined; + if (code !== 'ENOENT') throw err; + // realpath ENOENT can mean (a) path doesn't exist at all, or + // (b) the path is a symlink whose target doesn't exist. Use + // `readlink` to disambiguate. If it succeeds we've got a + // dangling symlink → resolve its target manually so the + // subsequent rename creates the target instead of replacing + // the symlink. + try { + const linkTarget = await fs.readlink(params.path); + realTarget = path.resolve(path.dirname(params.path), linkTarget); + } catch { + // readlink also failed → truly non-existent path → write + // through to the original (it'll be created). + } + } + // BX8Yp + BX9_h: temp filename must include random bytes — + // PID+ms alone collides under `sessionScope: 'thread'` (two + // concurrent sessions writing the same path in the same ms) AND + // can collide between concurrent prompts in one session. Add a + // UUID and create exclusively (`flag: 'wx'`) so any residual + // collision fails before content is overwritten. + const tmp = `${realTarget}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; + // BkwQW: preserve the existing target's mode bits (and owner/group + // where possible) so editing a `0600` secret doesn't downgrade + // it to `0644` via the process umask, and an executable file + // doesn't lose its `+x` bit. Snapshot before write — if the + // target doesn't exist yet, `preserveMode` stays undefined and + // the new file gets the `0o600` default applied at the + // `fs.writeFile` call below (NOT umask defaults — the explicit + // `mode` argument bypasses umask for atomicity, see the `Blehd` + // comment on `writeFile` for why). + let preserveMode: { mode: number; uid: number; gid: number } | undefined; + try { + const targetStat = await fs.stat(realTarget); + preserveMode = { + mode: targetStat.mode & 0o7777, + uid: targetStat.uid, + gid: targetStat.gid, + }; + } catch (err) { + const code = + err && typeof err === 'object' && 'code' in err + ? (err as { code?: unknown }).code + : undefined; + if (code !== 'ENOENT') throw err; + // New file — leave `preserveMode` undefined; the writeFile call + // below substitutes the `0o600` default via `?? 0o600`. + } + try { + // Blehd: pass `mode` to `writeFile` so the temp file is + // CREATED with the preserved mode (atomically, via the + // syscall's open(O_CREAT, mode)). The previous "create with + // umask defaults → chmod after" had a window where a `0600` + // secret-edit existed at `0644` on disk before chmod ran, + // briefly readable by anyone with directory access. Passing + // `mode` shrinks that window to "doesn't exist". On Windows + // the mode bits are mostly ignored by the OS; that's fine + // since the platform has no equivalent threat model here. + await fs.writeFile(tmp, params.content, { + encoding: 'utf8', + flag: 'wx', + mode: preserveMode?.mode ?? 0o600, + }); + if (preserveMode) { + // `writeFile`'s `mode` option is `mode & ~umask` on POSIX, + // so a tight umask (e.g. operator's shell `umask 077` for + // 0o600 default) could still drop bits we wanted preserved. + // Belt-and-suspenders chmod brings the file to EXACTLY the + // target's preserved mode regardless of umask interference. + await fs.chmod(tmp, preserveMode.mode).catch(() => { + /* chmod failed (Windows / fs without permission bits) */ + }); + // chown is owner-restricted on POSIX; non-root daemons hit + // EPERM here. Silent ignore — preserving mode is the + // first-order goal, ownership is a stretch goal. + await fs.chown(tmp, preserveMode.uid, preserveMode.gid).catch(() => { + /* expected EPERM for non-root operators */ + }); + } + await fs.rename(tmp, realTarget); + } catch (err) { + // Best-effort cleanup if the write succeeded but rename failed + // (e.g. permission change between calls). Swallow cleanup + // errors — the original failure is the meaningful one. + await fs.unlink(tmp).catch(() => {}); + throw err; + } + return {}; + } + + async readTextFile( + params: ReadTextFileRequest, + ): Promise { + // Delegate to the injected `BridgeFileSystem` when present + // (parallels the write path above). Production `qwen serve` wires + // `WorkspaceFileSystem` adapter; tests + Mode A + channels + IDE + // companion fall through to the inline proxy below. + if (this.fileSystem) { + // Preserve FsError structure over ACP wire. + // See sibling block in `writeTextFile` for rationale. + try { + return await this.fileSystem.readText(params); + } catch (err) { + preserveFsErrorOverAcp(err); + } + } + // Reject obviously-degenerate `limit` up front. Without this, + // `sliceLineRange` hits the `end < start` path and returns an + // unexpectedly-larger slice (or empty depending on internals). + // ACP doesn't define semantics for limit ≤ 0, so treat as "no + // bytes wanted". + if (typeof params.limit === 'number' && params.limit <= 0) { + return { content: '' }; + } + // BSA0E: cap the file size we'll buffer into RSS at 100 MiB so a + // request like `{ line: 1, limit: 10 }` against a 500 MB log + // doesn't cost the daemon 500 MB of memory just to return 10 + // lines. Stage 2's in-process refactor will replace this proxy + // with a streaming readline implementation that stops at the + // requested range; until then the cap is the cheapest defense. + // + // BX8YO: also reject non-regular files. Character devices, named + // pipes (FIFOs), procfs / sysfs entries, sockets etc. can report + // `stats.size === 0` while producing unbounded data on read, so + // a size-only cap doesn't protect against `/dev/zero` / + // `/dev/urandom` / `/proc/kcore`-style inputs. ACP's contract + // for `readTextFile` is "regular file"; everything else is an + // operator-supplied path mistake or an adversarial-prompt + // attempt and should fail loud. + const READ_FILE_SIZE_CAP = 100 * 1024 * 1024; + const stats = await fs.stat(params.path); + if (!stats.isFile()) { + throw new Error( + `readTextFile: ${params.path} is not a regular file ` + + `(reported as ${describeStatKind(stats)}). ` + + `Pipe / device / proc-like inputs can produce unbounded data ` + + `and aren't supported by the bridge fs proxy.`, + ); + } + if (stats.size > READ_FILE_SIZE_CAP) { + throw new Error( + `readTextFile: ${params.path} is ${stats.size} bytes, ` + + `exceeds the ${READ_FILE_SIZE_CAP}-byte daemon cap. ` + + `Tail/grep externally and feed the relevant slice instead.`, + ); + } + const content = await fs.readFile(params.path, 'utf8'); + if (typeof params.line === 'number' || typeof params.limit === 'number') { + // ACP `ReadTextFileRequest.line` is 1-based per spec — clients passing + // `{ line: 1, limit: 2 }` mean "the first two lines", not "skip the + // first then take two". Convert to a 0-based slice index, clamping + // values < 1 to 0 to be tolerant of unusual inputs. + const startLine = params.line ?? 1; + const start = startLine > 0 ? startLine - 1 : 0; + const end = params.limit != null ? start + params.limit : undefined; + // Avoid `content.split('\n')` — allocating a per-line String[] for + // a 100 MB file roughly doubles the memory footprint just to + // extract a few lines. Manual scan walks `indexOf('\n', …)` only + // until the end-of-range boundary is found, then slices a single + // range of the original string. Stage 2 in-process replaces this + // proxy entirely (the bridge stops reading user fs). + return { content: sliceLineRange(content, start, end) }; + } + return { content }; + } +} diff --git a/packages/acp-bridge/src/bridgeErrors.ts b/packages/acp-bridge/src/bridgeErrors.ts index 55eedaed1a..982e4be9a6 100644 --- a/packages/acp-bridge/src/bridgeErrors.ts +++ b/packages/acp-bridge/src/bridgeErrors.ts @@ -16,8 +16,8 @@ * "session limit reached, retry after N seconds") without parsing * free-form text. * - * Lifted from `packages/cli/src/serve/httpAcpBridge.ts` in #4175 PR - * 22b/1 so the bridge package owns the error contract directly. The + * + * The bridge package owns the error contract directly. The * 7 error classes server.ts imports + 1 each from workspaceAgents.ts * and workspaceMemory.ts continue to resolve through the * httpAcpBridge.ts re-export shim. @@ -25,6 +25,36 @@ import { MAX_WORKSPACE_PATH_LENGTH } from './workspacePaths.js'; +export const NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE = + 'Not currently generating' as const; + +/** + * ACP idle-cancel compatibility contract. + * + * The current CLI agent throws `NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE` + * when a client sends `cancel` while no prompt is active. Older ACP + * surfaces may wrap that text in either `message` or `data.details`. + * Treat harmless wording extensions such as + * "Not currently generating (session idle)" as the same no-op cancel, + * but keep this matcher narrow so unrelated cancel failures still + * propagate to callers. + */ +export function isNotCurrentlyGeneratingCancelError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const maybe = err as { message?: unknown; data?: unknown }; + if (isNotCurrentlyGeneratingText(maybe.message)) return true; + if (!maybe.data || typeof maybe.data !== 'object') return false; + return isNotCurrentlyGeneratingText( + (maybe.data as { details?: unknown }).details, + ); +} + +function isNotCurrentlyGeneratingText(value: unknown): boolean { + return ( + typeof value === 'string' && /\bnot currently generating\b/i.test(value) + ); +} + export class SessionNotFoundError extends Error { readonly sessionId: string; constructor(sessionId: string, extra?: string) { @@ -95,7 +125,7 @@ export class SessionLimitExceededError extends Error { /** * Thrown by `spawnOrAttach` when the requested `workspaceCwd` doesn't - * canonicalize to the daemon's bound workspace. Per #3803 §02 every + * canonicalize to the daemon's bound workspace. Every * bridge instance is bound to exactly one workspace; cross-workspace * requests are rejected at the daemon boundary. The server route * translates this to a 400 response with `code: 'workspace_mismatch'` @@ -172,7 +202,98 @@ export class InvalidSessionMetadataError extends Error { } /** - * #4175 Wave 4 PR 17. Thrown by `initWorkspace` when the target file + * Typed error for unimplemented permission policies. Thrown by `MultiClientPermissionMediator.vote` when the + * active policy is wired into the schema/registry but the mediator + * implementation has not been built yet. + * + * **Currently unreachable in production** — the current code implements + * all 4 policies in the frozen `PermissionPolicy` union. The class + + * route-level 501 mapping in `server.ts:sendPermissionVoteError` are + * RETAINED as forward-compat infrastructure: when a future PR adds a + * 5th policy literal to `PermissionPolicy` and lands its mediator + * implementation across multiple commits, the intermediate-build + * stub can throw this typed error and the operator gets a clean 501 + * instead of a generic 500. + * + * Routes map this to HTTP 501 with a structured body so SDK clients + * can render "your daemon is older than your settings expect; + * upgrade". + */ +export class PermissionPolicyNotImplementedError extends Error { + readonly policy: string; + constructor(policy: string) { + super( + `Permission policy "${policy}" is declared in the contract but ` + + 'not yet implemented in this daemon build.', + ); + this.name = 'PermissionPolicyNotImplementedError'; + this.policy = policy; + } +} + +/** + * Collision defense. Thrown by `MultiClientPermissionMediator.request` + * when an agent-declared `allowedOptionIds` set contains the + * cancel-vote sentinel string. The bridge maps voter cancel intent + * to that exact `optionId`; if the agent legitimately uses it as + * an option label, the mediator can no longer disambiguate. We + * fail loudly at request issue time so the operator sees a clear + * misconfiguration rather than the silent "voter approval was + * treated as cancel" semantic flip. + * + * Routes map this to HTTP 500 — it represents a contract violation + * between agent and daemon, not a client mistake. + */ +export class CancelSentinelCollisionError extends Error { + readonly requestId: string; + readonly sentinel: string; + constructor(requestId: string, sentinel: string) { + super( + `Permission ${requestId}: agent-declared optionId set contains ` + + `the cancel-vote sentinel "${sentinel}", which would prevent ` + + 'the daemon from disambiguating cancel intent from a real vote.', + ); + this.name = 'CancelSentinelCollisionError'; + this.requestId = requestId; + this.sentinel = sentinel; + } +} + +/** + * Permission forbidden error. Thrown by `bridge.respondToSessionPermission` / + * `bridge.respondToPermission` when the active permission policy + * rejects the vote (designated voter mismatch, or remote vote under + * `local-only`). The bridge converts the mediator's + * `PermissionVoteOutcome { kind: 'forbidden', reason: ... }` into + * this typed error so the route layer can map to HTTP 403 without + * pattern-matching on the error message. + * + * `reason` is forwarded verbatim from the mediator's outcome so SDK + * clients can render a precise UI ("you weren't designated to + * approve" vs "this daemon only accepts loopback approvals"). + */ +export class PermissionForbiddenError extends Error { + readonly requestId: string; + readonly sessionId: string; + readonly reason: 'designated_mismatch' | 'remote_not_allowed'; + constructor( + requestId: string, + sessionId: string, + reason: 'designated_mismatch' | 'remote_not_allowed', + ) { + super( + `Permission ${requestId} on session ${sessionId}: ` + + `vote rejected by policy (${reason}).`, + ); + this.name = 'PermissionForbiddenError'; + this.requestId = requestId; + this.sessionId = sessionId; + this.reason = reason; + } +} + +/** + * Workspace init conflict. Thrown by `initWorkspace` when the target file * already exists with non-whitespace content and the caller did not * pass `force: true`. Translated to HTTP 409 by the route. The * `path` and `existingSize` fields let SDK clients render a clear @@ -194,7 +315,79 @@ export class WorkspaceInitConflictError extends Error { } /** - * #4282 fold-in 1 (gpt-5.5 C5). Thrown by `restartMcpServer` when the + * Path escape guard. Thrown by `initWorkspace` when + * the configured `context.fileName` resolves outside the bound + * workspace via path arithmetic (e.g. `../outside.md`). Translated + * to HTTP 400 by the route — distinguishable from a generic 500 so + * an operator sees "your workspace config is wrong" rather than + * "the daemon is broken." The `filename` and `boundWorkspace` + * fields let clients display a precise diagnostic. + */ +export class WorkspaceInitPathEscapeError extends Error { + readonly filename: string; + readonly boundWorkspace: string; + constructor(filename: string, boundWorkspace: string) { + super( + `Configured workspace context filename ${JSON.stringify(filename)} ` + + `resolves outside the bound workspace ${JSON.stringify(boundWorkspace)}. ` + + `Refusing to write.`, + ); + this.name = 'WorkspaceInitPathEscapeError'; + this.filename = filename; + this.boundWorkspace = boundWorkspace; + } +} + +/** + * Path escape guard. Thrown by `initWorkspace` when + * the target file is itself a symlink, OR when the parent path + * canonicalizes (via `realpath`) outside the bound workspace. + * Translated to HTTP 400 by the route — same operator-clarity + * rationale as `WorkspaceInitPathEscapeError`. `target` is the + * resolved path the bridge attempted, `kind` distinguishes the two + * symlink scenarios for diagnostics. + */ +export class WorkspaceInitSymlinkError extends Error { + readonly target: string; + readonly kind: 'target' | 'parent'; + constructor(target: string, kind: 'target' | 'parent', detail: string) { + super(detail); + this.name = 'WorkspaceInitSymlinkError'; + this.target = target; + this.kind = kind; + } +} + +/** + * Race condition guard. Thrown by + * `initWorkspace` when the target file's inode misbehaved at write + * time IN A NON-SYMLINK WAY — typically a TOCTOU race against a + * concurrent writer: + * - `'eexist'`: a regular file (or symlink) appeared at the target + * path between the absence check and our atomic `'wx'` create. + * - `'enoent'`: the target was deleted between the content check + * and the `O_NOFOLLOW` overwrite (concurrent git checkout, editor + * save, etc.). + * + * Split out from `WorkspaceInitSymlinkError` so the HTTP error code + * isn't misleading: an operator chasing a `workspace_init_race` + * code knows it's a benign concurrent-modification window, not a + * symlink attack vector. Same 400 mapping as the sibling class — + * the route layer still recognizes both. + */ +export class WorkspaceInitRaceError extends Error { + readonly target: string; + readonly kind: 'eexist' | 'enoent'; + constructor(target: string, kind: 'eexist' | 'enoent', detail: string) { + super(detail); + this.name = 'WorkspaceInitRaceError'; + this.target = target; + this.kind = kind; + } +} + +/** + * MCP server not found. Thrown by `restartMcpServer` when the * caller asks for a server name that isn't in the daemon's * `McpServers` config. Translated to HTTP 404 + structured body by * the route — distinguishable from a generic 500 so a bad server @@ -210,7 +403,7 @@ export class McpServerNotFoundError extends Error { } /** - * #4282 fold-in 1 (gpt-5.5 C4). Thrown by `restartMcpServer` when + * MCP restart failure. Thrown by `restartMcpServer` when * `discoverMcpToolsForServer` resolves but the MCP client fails to * end up `CONNECTED` post-discover. The manager catches reconnect * errors and returns void, so without an explicit post-check the @@ -231,3 +424,35 @@ export class McpServerRestartFailedError extends Error { this.mcpStatus = mcpStatus; } } + +export class SessionBusyError extends Error { + readonly sessionId: string; + constructor(sessionId: string, message?: string) { + super(message ?? `Session ${sessionId} is busy (prompt running)`); + this.name = 'SessionBusyError'; + this.sessionId = sessionId; + } +} + +export class InvalidRewindTargetError extends Error { + readonly sessionId: string; + constructor(sessionId: string, message?: string) { + super( + message ?? + `Cannot rewind to the requested turn (compressed or does not exist)`, + ); + this.name = 'InvalidRewindTargetError'; + this.sessionId = sessionId; + } +} + +export class BranchWhilePromptActiveError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super( + `Cannot branch session ${sessionId}: a prompt is currently active`, + ); + this.name = 'BranchWhilePromptActiveError'; + this.sessionId = sessionId; + } +} diff --git a/packages/acp-bridge/src/bridgeFileSystem.ts b/packages/acp-bridge/src/bridgeFileSystem.ts new file mode 100644 index 0000000000..951d27ce1f --- /dev/null +++ b/packages/acp-bridge/src/bridgeFileSystem.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ReadTextFileRequest, + ReadTextFileResponse, + WriteTextFileRequest, + WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; + +/** + * Injection seam for the ACP fs proxy on `BridgeClient.readTextFile` / + * `BridgeClient.writeTextFile`. A serve-side adapter wraps + * `WorkspaceFileSystem` so production `qwen serve` writes pick up the + * TOCTOU + symlink + trust-gate + audit machinery. Until that adapter + * ships and `runQwenServe` wires it through `BridgeOptions.fileSystem`, + * BridgeClient continues to use its inline fs proxy (preserving + * pre-extraction behavior). + * + * Lifted from the inline `fs.writeFile` / `fs.readFile` implementations + * BridgeClient carried before the extraction. Bridge tests + Mode A + * embedded callers can omit the field on `BridgeOptions`; BridgeClient + * falls back to its inline proxy so the pre-lift behavior is preserved + * verbatim when no provider is injected. + * + * Method signatures intentionally mirror the ACP SDK request/response + * shapes so the adapter does the minimum amount of translation + * (`{ path, content }` ↔ `WorkspaceFileSystem`'s `ResolvedPath` brand + * types + options bag). + */ +export interface BridgeFileSystem { + /** + * Read a UTF-8 text file. Honors ACP's `line` / `limit` window + * semantics (1-based line, inclusive limit). The adapter is + * expected to surface boundary / trust / encoding errors as + * thrown JS errors — the bridge's existing error-mapping path + * (`mapDomainErrorToErrorKind`) will classify them downstream. + * + * Adapter MUST replicate the inline proxy's two defensive + * gates (the inline path is fully bypassed when a fileSystem is + * injected): + * 1. Reject non-regular files (sockets / pipes / char devices + * / procfs / sysfs entries can produce unbounded data on + * read despite reporting `stats.size === 0`). Inline path + * throws with `describeStatKind(stats)` in the message. + * 2. Cap the buffered size (the inline path uses + * `READ_FILE_SIZE_CAP = 100 MiB` to defend against a small + * `{ line: 1, limit: 10 }` request against a 500 MB log + * from costing 500 MB of RSS just to return 10 lines). + */ + readText(params: ReadTextFileRequest): Promise; + + /** + * Atomically replace `params.path` with `params.content`. Returns + * the ACP-shaped empty response on success; throws an `FsError` + * (classified downstream by `mapDomainErrorToErrorKind`) on + * boundary, trust, or I/O failure. + * + * Adapter MUST provide: + * - **Write-then-rename atomicity** — a SIGKILL / OOM mid-write + * does NOT leave the target truncated. + * - **Target mode preservation** — editing a `0o600` secret + * keeps it at `0o600`; an executable `+x` bit is retained. + * - **`0o600` default for new files** — NOT umask defaults (the + * write syscall's `mode` arg bypasses umask). This is the + * security posture for agent-driven writes where the agent's + * intent about the file's audience is unknown. + * - **Symlink rejection** — paths whose target is a symlink + * surface `symlink_escape`. This is a **divergence from the + * pre-F1 inline `BridgeClient.writeTextFile` proxy** which + * resolved symlinks and wrote through to their target; + * production now matches the more conservative + * HTTP `POST /file` posture. Agents that previously + * relied on writing through symlinked dotfiles will need + * to address the resolved path directly. + * - **Workspace boundary enforcement** — paths outside the + * bound workspace surface `path_outside_workspace`. + * + * Owner/group preservation is best-effort and platform-dependent + * (POSIX `chown` requires root for cross-user changes; Windows + * lacks the concept entirely). The contract does NOT require it. + * + * The serve-side adapter satisfies this via + * `WorkspaceFileSystem.writeTextOverwrite`, which does atomic + * tmp+rename with mode preservation + `0o600` default + symlink + * reject inside a per-path lock. + */ + writeText(params: WriteTextFileRequest): Promise; +} diff --git a/packages/acp-bridge/src/bridgeOptions.ts b/packages/acp-bridge/src/bridgeOptions.ts index 6a44540312..3e9af13fff 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -6,14 +6,32 @@ /** * `BridgeOptions` and the daemon-host injection seam (`DaemonStatusProvider`) - * for the ACP bridge factory. Lifted to `@qwen-code/acp-bridge` in #4175 PR - * 22b/2 so the bridge package owns the construction contract independently - * of `cli/src/serve/`. The factory implementation itself moves in PR 22b/3. + * for the ACP bridge factory. Lifted to `@qwen-code/acp-bridge` so the + * bridge package owns the construction contract independently of + * `cli/src/serve/`. */ -import type { ApprovalMode } from '@qwen-code/qwen-code-core'; +import type { + ApprovalMode, + DaemonBridgeTelemetryMetrics, +} from '@qwen-code/qwen-code-core'; import type { ChannelFactory } from './channel.js'; +import type { PermissionPolicy } from './permission.js'; +import type { PermissionAuditPublisher } from './permissionMediator.js'; import type { ServePreflightCell, ServeWorkspaceEnvStatus } from './status.js'; +import type { BridgeFileSystem } from './bridgeFileSystem.js'; + +/** + * Sink for serve-level diagnostic lines (set by the cli daemon logger). + * When provided, the bridge tees `writeServeDebugLine` output through + * this callback alongside the existing stderr write — used by + * runQwenServe to capture them in the daemon log file. The bridge + * does not own a file logger itself; this is a pure pass-through hook. + */ +export type DiagnosticLineSink = ( + line: string, + level?: 'info' | 'warn' | 'error', +) => void; /** * Optional injection seam for daemon-host-specific status cells — @@ -77,14 +95,34 @@ export interface DaemonStatusProvider { ): Promise; } +export type BridgeTelemetryAttributes = Record< + string, + string | number | boolean +>; + +export type BridgeTelemetryMetrics = DaemonBridgeTelemetryMetrics; + +export interface BridgeTelemetry { + captureContext(): unknown; + runWithContext(captured: unknown, fn: () => Promise): Promise; + withSpan( + operation: string, + attributes: BridgeTelemetryAttributes, + fn: () => Promise, + ): Promise; + event(name: string, attributes: BridgeTelemetryAttributes): void; + injectPromptContext(request: T): T; + metrics?: BridgeTelemetryMetrics; +} + /** - * Construction options for `createHttpAcpBridge`. Most fields are + * Construction options for `createAcpSessionBridge`. Most fields are * tuning knobs with sensible defaults; `boundWorkspace` is the only * strictly-required field. See per-field JSDoc for caller contract. */ export interface BridgeOptions { /** - * §03 decision §1. `single` shares one session per workspace across HTTP + * `single` shares one session per workspace across HTTP * clients (live-collaboration default); `thread` gives each `spawnOrAttach` * call its own session for strict isolation. * @@ -113,7 +151,7 @@ export interface BridgeOptions { * Per-session SSE replay ring depth. Sets `ringSize` on every * `new EventBus(...)` the bridge constructs (both fresh sessions * and restored sessions). Defaults to `DEFAULT_RING_SIZE` (8000, - * #3803 §02 target). Must be a positive finite integer; `0` / + * the daemon design target). Must be a positive finite integer; `0` / * `NaN` / negative throw at boot (fail-CLOSED — same posture as * `maxSessions`, where silently disabling a backpressure knob on a * config typo is worse than failing to start). @@ -143,7 +181,7 @@ export interface BridgeOptions { maxPendingPermissionsPerSession?: number; /** * Absolute, **already-canonical** path this daemon is bound to (per - * #3803 §02: 1 daemon = 1 workspace). `spawnOrAttach` calls whose + * 1 daemon = 1 workspace). `spawnOrAttach` calls whose * `workspaceCwd` doesn't canonicalize to this same value throw * `WorkspaceMismatchError` (route → 400 with code `workspace_mismatch`). * @@ -158,7 +196,7 @@ export interface BridgeOptions { * theoretically diverge from the runQwenServe canonicalize on * NFS-transient / mid-rename filesystems, landing the bridge with * one canonical form while `/capabilities` advertises another). - * Direct embeds / tests calling `createHttpAcpBridge` themselves + * Direct embeds / tests calling `createAcpSessionBridge` themselves * MUST canonicalize before passing. */ boundWorkspace: string; @@ -185,7 +223,7 @@ export interface BridgeOptions { */ childEnvOverrides?: Readonly>; /** - * #4175 Wave 4 PR 17 — optional callback for persisting `tools. + * -- optional callback for persisting `tools. * approvalMode` to the workspace settings file. Invoked by * `setSessionApprovalMode` ONLY when the route caller passes * `{persist: true}`. The default `runQwenServe` wires this to @@ -199,23 +237,6 @@ export interface BridgeOptions { boundWorkspace: string, mode: ApprovalMode, ) => Promise; - /** - * #4175 Wave 4 PR 17 — optional callback for mutating - * `tools.disabled` in workspace settings. Invoked by - * `setWorkspaceToolEnabled` to add (`enabled: false`) or remove - * (`enabled: true`) `toolName` from the persisted disabled set. - * The default `runQwenServe` wires this to a fresh - * `loadSettings(boundWorkspace)` per call so concurrent edits from - * other writers (CLI, another daemon, an editor) are picked up. - * Bridge tests / embedded callers may omit it; without the hook - * `setWorkspaceToolEnabled` throws a clear error rather than - * silently dropping the write. - */ - persistDisabledTools?: ( - boundWorkspace: string, - toolName: string, - enabled: boolean, - ) => Promise; /** * #4175 Wave 5 PR 22b/2 — optional injection seam for daemon-host * status cells (env snapshot, daemon preflight). Production @@ -228,7 +249,7 @@ export interface BridgeOptions { * and `acpChannelLive` from bridge state) and an empty array for * the daemon half of `getWorkspacePreflightStatus` (the ACP-level * cells are still fetched normally when a child is live). This - * matches the "idle status is queryable" pattern PR 12 / 13 + * matches the "idle status is queryable" pattern previous work * established for diagnostic routes — direct embeds and tests * that don't need daemon-host cells can omit the provider * without crashing those routes. @@ -239,4 +260,98 @@ export interface BridgeOptions { * still query the routes; they'll see empty/idle cells. */ statusProvider?: DaemonStatusProvider; + /** Optional daemon telemetry seam. Omitted callers get no-op spans/logs. */ + telemetry?: BridgeTelemetry; + + /** + * Optional fs injection seam. When provided, `BridgeClient.readTextFile` and + * `BridgeClient.writeTextFile` delegate every ACP fs call to this + * implementation instead of using BridgeClient's inline + * `fs.realpath` / `fs.writeFile` / `fs.readFile` proxy. + * + * The immediate F1 follow-up will land a serve-side adapter that + * wraps its `WorkspaceFileSystem` and a `runQwenServe` wiring + * patch so production `qwen serve` writes pick up its TOCTOU + + * symlink-substitution + trust-gate + `.gitignore` + audit + * machinery — closing the follow-up thread about + * `BridgeClient`'s inline fs proxy bypassing `WorkspaceFileSystem` + * (originally raised in code review). Until that lands, BridgeClient's inline + * proxy continues to handle writes (current behavior preserved). + * + * When omitted (tests, Mode A in-process consumers, channels / + * IDE companion using the bridge directly), BridgeClient's inline + * proxy is used — preserves the pre-F1 behavior verbatim so + * existing test fixtures don't need updating and channels / + * IDE keep working without depending on `cli/src/serve/fs/`. + */ + fileSystem?: BridgeFileSystem; + /** + * -- active permission mediation policy for the + * `MultiClientPermissionMediator`. When omitted, defaults to + * `'first-responder'` (the pre-F3 behavior — any validated voter + * wins immediately). The bridge captures this once at construction + * time; `runQwenServe` reads it from `settings.policy. + * permissionStrategy` and the mediator snapshots it onto every + * pending entry at issue time so live-reload of settings does not + * change the rules under in-flight requests. + */ + permissionPolicy?: PermissionPolicy; + /** + * -- optional fixed quorum for `consensus` policy. + * MUST be a positive integer if provided; the F3 settings layer + * validates this and fails startup on non-integer / non-positive + * values. Capped at `M = votersAtIssue.size` at request time to + * prevent unreachable quorum. Unset → `floor(M/2) + 1` (default + * majority). + */ + permissionConsensusQuorum?: number; + /** + * -- injection seam for the permission audit + * publisher. + * + * **When omitted**: the bridge falls back to + * `createNoOpPermissionAuditPublisher` so embedded callers (and + * the bridge unit-test suite) can run the mediator without an + * audit consumer. + * + * **In production** (`qwen serve`), `runQwenServe.ts` allocates a + * `PermissionAuditRing` (default capacity 512), wraps it with + * `createPermissionAuditPublisher`, and passes the result here. + * The ring stays alive for the lifetime of the daemon so a future + * `GET /workspace/permission/audit` route (out of F3 v1 scope) + * can lift it out for query. + * + * Permission timeouts also produce a stderr breadcrumb directly + * from the mediator's timer callback (independent of this + * publisher) so operators tailing daemon stderr always see + * timeouts even when the audit publisher is the no-op fallback. + */ + permissionAudit?: PermissionAuditPublisher; + /** + * Optional: tee `writeServeDebugLine` output. See {@link DiagnosticLineSink}. + * No-op when omitted. Set by cli `runQwenServe` from the daemon logger. + */ + onDiagnosticLine?: DiagnosticLineSink; + /** + * Milliseconds to keep the ACP child alive after the last session + * closes. When a new session arrives during the idle window, the + * warm channel is reused without a cold start. `0` (default) kills + * the channel immediately (current behavior). The timer is `.unref()`'d + * so it does not prevent daemon exit. + */ + channelIdleTimeoutMs?: number; + /** + * How often the session reaper scans for idle sessions, in + * milliseconds. Default: 60_000 (1 minute). `0` or `Infinity` + * disables the reaper entirely. The timer is `.unref()`'d. + */ + sessionReapIntervalMs?: number; + /** + * A session with zero SSE subscribers and no active prompt that has + * not received a heartbeat for this many milliseconds is reaped. + * Note: `clientIds.size` is intentionally NOT checked — the reaper + * covers the crash path where clients never sent a detach request. + * Default: 1_800_000 (30 minutes). `0` or `Infinity` disables. + */ + sessionIdleTimeoutMs?: number; } diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 925c3957a6..408a86278d 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -16,16 +16,38 @@ import type { SetSessionModelResponse, } from '@agentclientprotocol/sdk'; import type { BridgeEvent, SubscribeOptions } from './eventBus.js'; +import type { PermissionPolicy } from './permission.js'; import type { ServeSessionContextStatus, + ServeSessionHooksStatus, ServeSessionSupportedCommandsStatus, - ServeWorkspaceEnvStatus, - ServeWorkspaceMcpStatus, - ServeWorkspacePreflightStatus, - ServeWorkspaceProvidersStatus, - ServeWorkspaceSkillsStatus, + ServeSessionTasksStatus, + ServeWorkspaceExtensionsStatus, + ServeWorkspaceHooksStatus, + ServeWorkspaceMcpToolsStatus, + ServeWorkspaceToolsStatus, + ServeSessionContextUsageStatus, + ServeSessionStatsStatus, } from './status.js'; +export interface RewindSnapshotInfo { + promptId: string; + turnIndex: number; + timestamp: string; + diffStats: { filesChanged: number; insertions: number; deletions: number }; +} + +export interface RewindRequest { + promptId: string; +} + +export interface RewindResponse { + rewound: boolean; + targetTurnIndex: number; + filesChanged: string[]; + filesFailed: string[]; +} + export interface BridgeSpawnRequest { /** Absolute path to the workspace root the child inherits as cwd. */ workspaceCwd: string; @@ -74,6 +96,21 @@ export type BridgeSessionState = LoadSessionResponse | ResumeSessionResponse; export interface BridgeRestoredSession extends BridgeSession { /** ACP state returned by `session/load` / `session/resume`. */ state: BridgeSessionState; + /** Compacted events for all completed turns (O(turns) size). */ + compactedReplay?: BridgeEvent[]; + /** Raw events since last turn boundary (current incomplete turn). */ + liveJournal?: BridgeEvent[]; + /** High-water mark event ID — client uses this as initial SSE cursor. */ + lastEventId?: number; +} + +export interface BridgeBranchSessionRequest { + name?: string; +} + +export interface BridgeBranchedSession extends BridgeRestoredSession { + title: string; + forkedFrom: { sessionId: string; title: string }; } /** Sparse summary used by `GET /workspace/:id/sessions`. */ @@ -81,6 +118,8 @@ export interface BridgeSessionSummary { sessionId: string; workspaceCwd: string; createdAt: string; + updatedAt?: string; + title?: string; displayName?: string; clientCount: number; hasActivePrompt: boolean; @@ -90,9 +129,33 @@ export interface SessionMetadataUpdate { displayName?: string; } +export interface CloseSessionOpts { + /** Override the default `'client_close'` reason in the `session_closed` event. */ + reason?: string; +} + export interface BridgeClientRequestContext { /** Daemon-issued client id echoed through the HTTP transport header. */ clientId?: string; + /** + * `true` when the request arrived from a loopback peer (kernel-stamped + * `req.socket.remoteAddress` ∈ {`127.0.0.1`, `::1`, `::ffff:127.0.0.1`}). + * Populated by permission-vote routes for the `local-only` mediation + * policy; other routes leave this undefined. + * + * **Security**: this is NOT computed from `X-Forwarded-For` or any + * other forwardable HTTP header — those are forgeable. Callers that + * reverse-proxy `qwen serve` should not rely on `local-only` (use a + * dedicated daemon or `designated` policy instead). + */ + fromLoopback?: boolean; + /** + * Caller-generated correlation id for non-blocking prompt mode. + * When present, the bridge stamps `turn_complete` / `turn_error` events + * with this id so the SDK's `prompt()` can match the SSE event to the + * pending HTTP 202 request. + */ + promptId?: string; } /** @@ -120,7 +183,7 @@ export interface BridgeHeartbeatState { clientLastSeenAt: ReadonlyMap; } -export interface HttpAcpBridge { +export interface AcpSessionBridge { /** * Create a new session, or — under `sessionScope: 'single'` — attach to an * existing session for the same workspace. @@ -143,6 +206,16 @@ export interface HttpAcpBridge { req: BridgeRestoreSessionRequest, ): Promise; + /** + * Fork a live session's JSONL transcript and load the fork via resume + * semantics (no history replay). Source must be idle (no active prompt). + */ + branchSession( + sessionId: string, + req: BridgeBranchSessionRequest, + context?: BridgeClientRequestContext, + ): Promise; + /** * Forward a prompt to the agent. Concurrent prompts against the same * session FIFO-serialize through a per-session queue. Throws @@ -171,9 +244,19 @@ export interface HttpAcpBridge { */ subscribeEvents( sessionId: string, - opts?: SubscribeOptions, + opts?: SubscribeOptions & { + /** Yield a synthetic `session_snapshot` frame after replay completes. */ + snapshot?: boolean; + }, ): AsyncIterable; + /** + * Return the most recent monotonic event id for this session's bus. + * Used by non-blocking prompt responses to tell the client where to + * start SSE replay so no events are missed. + */ + getSessionLastEventId(sessionId: string): number; + /** * Explicitly close a live session. Force-closes even when other clients * are attached. Throws `SessionNotFoundError` for unknown ids. @@ -181,6 +264,7 @@ export interface HttpAcpBridge { closeSession( sessionId: string, context?: BridgeClientRequestContext, + opts?: CloseSessionOpts, ): Promise; /** @@ -248,44 +332,81 @@ export interface HttpAcpBridge { knownClientIds(): ReadonlySet; /** - * Read daemon-runtime MCP status for the bound workspace. Does not spawn - * an ACP child when the daemon is idle. + * Generic workspace-status query delegated through the live ACP channel. + * Returns `idle()` when no child is running. Used by DaemonWorkspaceService + * to forward status methods without coupling to their concrete shapes. */ - getWorkspaceMcpStatus(): Promise; + queryWorkspaceStatus(method: string, idle: () => T): Promise; /** - * Read daemon-runtime skill status for the bound workspace. + * Generic workspace command invocation delegated through the live ACP + * channel. Throws `SessionNotFoundError` when no child is running (no + * idle fallback). Used by DaemonWorkspaceService for mutations that + * require an active channel (e.g. MCP restart). */ - getWorkspaceSkillsStatus(): Promise; + invokeWorkspaceCommand( + method: string, + params?: Record, + opts?: { timeoutMs?: number }, + ): Promise; /** - * Read daemon-runtime model-provider status for the bound workspace. + * Read discovered MCP tools for one server from the live ACP registry. + * (New in upstream — kept in bridge pending workspace service migration.) */ - getWorkspaceProvidersStatus(): Promise; + getWorkspaceMcpToolsStatus( + serverName: string, + ): Promise; /** - * Read the daemon-process environment snapshot for the bound workspace. - * Answered entirely from `process.*` state — does not consult ACP. + * Read the live built-in tool registry for the bound workspace. + * (New in upstream — kept in bridge pending workspace service migration.) */ - getWorkspaceEnvStatus(): Promise; - - /** - * Read daemon-runtime preflight diagnostics. Daemon-level cells are - * always populated; ACP-level cells require a live ACP child — when - * the daemon is idle they are emitted with `status: 'not_started'`. - */ - getWorkspacePreflightStatus(): Promise; + getWorkspaceToolsStatus(): Promise; /** Read the current ACP context/config state for a live session. */ getSessionContextStatus( sessionId: string, ): Promise; + /** Read structured context-window usage for a live session. */ + getSessionContextUsageStatus( + sessionId: string, + opts?: { detail?: boolean }, + ): Promise; + /** Read slash-command/skill command availability for a live session. */ getSessionSupportedCommandsStatus( sessionId: string, ): Promise; + /** Read the live background task snapshot for a live session. */ + getSessionTasksStatus(sessionId: string): Promise; + + /** Cancel a background task in a live session. */ + cancelSessionTask( + sessionId: string, + taskId: string, + taskKind: 'agent' | 'shell' | 'monitor', + ): Promise<{ cancelled: boolean }>; + + /** Clear an active goal in a live session without cancelling the running prompt. */ + clearSessionGoal( + sessionId: string, + ): Promise<{ cleared: boolean; condition?: string }>; + + /** Read structured session usage stats (tokens, tools, files). */ + getSessionStatsStatus(sessionId: string): Promise; + + /** Read workspace-level hook configuration status. */ + getWorkspaceHooksStatus(): Promise; + + /** Read session-scoped hook status for a live session. */ + getSessionHooksStatus(sessionId: string): Promise; + + /** Read workspace-level installed extension status. */ + getWorkspaceExtensionsStatus(): Promise; + /** * Switch the active model service for a session. Throws * `SessionNotFoundError` for unknown ids. @@ -296,6 +417,22 @@ export interface HttpAcpBridge { context?: BridgeClientRequestContext, ): Promise; + /** + * Switch UI language and optionally LLM output language for a live + * session, then broadcast a `language_changed` event. When + * `syncOutputLanguage` is true the handler also refreshes every + * session's system prompt so the next LLM call uses the new language. + */ + setSessionLanguage( + sessionId: string, + params: { language: string; syncOutputLanguage: boolean }, + context?: BridgeClientRequestContext, + ): Promise<{ + language: string; + outputLanguage: string | null; + refreshed: boolean; + }>; + /** * Change the approval mode of a live session and broadcast an * `approval_mode_changed` event. `opts.persist === true` also writes @@ -314,47 +451,135 @@ export interface HttpAcpBridge { }>; /** - * Add or remove a tool name from the workspace's `tools.disabled` - * settings list and fan-out a `tool_toggled` event to every live - * session SSE bus. + * Generate a one-sentence "where did I leave off" recap of a live + * session. Forwards through `qwen/control/session/recap`, which + * invokes `generateSessionRecap` (`core/services/sessionRecap.ts`) in + * the ACP child against the per-session chat history. + * + * Best-effort: the helper returns `null` when history is too short or + * the underlying side-query fails — both surface as a 200 response + * with `recap: null`. Hard errors (unknown session, ACP transport + * down) throw as usual. */ - setWorkspaceToolEnabled( - toolName: string, - enabled: boolean, - originatorClientId: string | undefined, - ): Promise<{ toolName: string; enabled: boolean }>; + generateSessionRecap( + sessionId: string, + context?: BridgeClientRequestContext, + ): Promise<{ sessionId: string; recap: string | null }>; /** - * Scaffold an empty `QWEN.md` (or whatever - * `getCurrentGeminiMdFilename()` returns) at the bound workspace - * root. Default refuses to overwrite via - * `WorkspaceInitConflictError`; `opts.force === true` overwrites. + * Run a side question (/btw) against the session's conversation context. + * Uses runForkedAgent (cache path) for a single-turn, tool-free LLM call. + * Returns `answer: null` on empty/failed generation. */ - initWorkspace( - opts: { force?: boolean }, + generateSessionBtw( + sessionId: string, + question: string, + signal?: AbortSignal, + context?: BridgeClientRequestContext, + ): Promise<{ sessionId: string; answer: string | null }>; + + /** + * Execute a shell command directly on the daemon (no LLM involvement). + * Streams output through the session's SSE bus and injects the + * command+result into the LLM's chat history via extMethod. + * Throws `SessionNotFoundError` for unknown ids. + */ + executeShellCommand( + sessionId: string, + command: string, + signal?: AbortSignal, + context?: BridgeClientRequestContext, + ): Promise; + + /** + * List rewindable snapshots for a session with per-turn diff stats. + */ + getRewindSnapshots( + sessionId: string, + ): Promise<{ snapshots: RewindSnapshotInfo[] }>; + + /** + * Rewind a session to a previous turn: truncates conversation history + * and restores files. File restore is best-effort — if the snapshot + * is missing, conversation is still rewound and `filesChanged` is empty. + */ + rewindSession( + sessionId: string, + req: RewindRequest, + context?: BridgeClientRequestContext, + ): Promise; + + /** + * T2.8 (#4514): Add a runtime MCP server through the ACP child's + * `McpClientManager.addRuntimeMcpServer`. On success, broadcasts an + * `mcp_server_added` event to every session bus. Soft-refuse + * (`budget_warning_only` skip) does NOT emit an event — the caller + * receives the skip shape and decides locally. + * + * Throws `SessionNotFoundError` when no ACP channel is live (caller + * should spawn or attach first). Typed ACP errors (budget-exceeded, + * spawn-failed, invalid-config) are re-instantiated from the + * JSON-RPC `data.errorKind` so the route's `sendBridgeError` can + * map them to stable HTTP status codes. + */ + addRuntimeMcpServer( + name: string, + config: Record, + originatorClientId: string, + ): Promise< + | { + name: string; + transport: string; + replaced: boolean; + shadowedSettings: boolean; + toolCount: number; + originatorClientId: string; + } + | { name: string; skipped: true; reason: 'budget_warning_only' } + >; + + /** + * Remove a runtime MCP server through the ACP child's + * `McpClientManager.removeRuntimeMcpServer`. On success, broadcasts + * an `mcp_server_removed` event. Idempotent skip (`not_present`) + * does NOT emit — the caller receives the skip shape. + * + * Throws `SessionNotFoundError` when no ACP channel is live. + */ + removeRuntimeMcpServer( + name: string, + originatorClientId: string, + ): Promise< + | { + name: string; + removed: true; + wasShadowingSettings: boolean; + originatorClientId: string; + } + | { name: string; skipped: true; reason: 'not_present' } + >; + + manageMcpServer( + serverName: string, + action: 'enable' | 'disable' | 'authenticate' | 'clear-auth', originatorClientId: string | undefined, ): Promise<{ - path: string; - action: 'created' | 'overwrote' | 'noop'; + serverName: string; + action: 'enable' | 'disable' | 'authenticate' | 'clear-auth'; + ok: true; + changed?: boolean; + messages?: string[]; + authUrl?: string; }>; - /** - * Restart a configured MCP server through the ACP child's - * `McpClientManager`. Pre-checks the live budget snapshot and - * returns a structured "skipped" response (200 OK) for soft refusals. - */ - restartMcpServer( - serverName: string, + generateWorkspaceAgent( + description: string, originatorClientId: string | undefined, - ): Promise< - | { serverName: string; restarted: true; durationMs: number } - | { - serverName: string; - restarted: false; - skipped: true; - reason: 'in_flight' | 'disabled' | 'budget_would_exceed'; - } - >; + ): Promise<{ + name: string; + description: string; + systemPrompt: string; + }>; /** * Tear down a session — kill the child, drop from maps, publish @@ -378,9 +603,32 @@ export interface HttpAcpBridge { /** Test/inspection hook: number of live sessions. */ readonly sessionCount: number; + /** + * Whether an ACP channel is currently live (spawned and not dying). + * Distinct from `sessionCount > 0`: a channel can be live with zero + * attached sessions during the cold-spawn window, and conversely a + * killed channel may briefly retain sessions before reaping. Consumers + * that need true channel liveness (e.g. the workspace service's + * `acpChannelLive` envelope field) must use this rather than the + * session count. + */ + isChannelLive(): boolean; + /** Test/inspection hook: number of permission requests awaiting a vote. */ readonly pendingPermissionCount: number; + /** + * Active permission mediation policy. Reflects + * the value `runQwenServe` resolved from + * `settings.policy.permissionStrategy` (or the + * `'first-responder'` default). Surfaced through the + * `/capabilities` envelope's `policy.permission` field so SDK + * clients can feature-detect at runtime which strategy is in + * effect, distinct from the build-supported set advertised on + * the `permission_mediation` capability tag. + */ + readonly permissionPolicy: PermissionPolicy; + /** * Synchronous force-kill of every live channel. Called by signal * handlers when the operator double-taps Ctrl+C. @@ -389,4 +637,20 @@ export interface HttpAcpBridge { /** Close all live child processes; called on daemon shutdown. */ shutdown(): Promise; + + /** + * Eagerly spawn the ACP child so the first session doesn't pay + * cold-start latency. Fire-and-forget; failures are logged and the + * first session falls back to lazy spawn. + */ + preheat(): Promise; } + +export interface ShellCommandResult { + exitCode: number | null; + output: string; + aborted: boolean; +} + +/** @deprecated Use `AcpSessionBridge` instead. */ +export type HttpAcpBridge = AcpSessionBridge; diff --git a/packages/acp-bridge/src/channel.ts b/packages/acp-bridge/src/channel.ts index 048affd7e9..87b37269c3 100644 --- a/packages/acp-bridge/src/channel.ts +++ b/packages/acp-bridge/src/channel.ts @@ -9,13 +9,13 @@ import type { Stream } from '@agentclientprotocol/sdk'; /** * One ACP NDJSON channel to a single agent. Tests inject a fake by * replacing the channel factory; production uses - * `defaultSpawnChannelFactory` (still in `cli/src/serve/httpAcpBridge.ts` - * pending the PR 22b lift). + * `defaultSpawnChannelFactory` (in `./spawnChannel.ts`). * - * This contract is consumed by the daemon HTTP bridge today and will be - * shared by `packages/channels/base/AcpBridge.ts` and the VSCode IDE - * companion's `acpConnection.ts` after PR 22b — both currently spawn - * their own `qwen --acp` child via independent code paths. + * This contract is consumed by the daemon HTTP bridge and is available + * for `packages/channels/base/AcpBridge.ts` and the VSCode IDE + * companion's `acpConnection.ts` to consume directly via + * `@qwen-code/acp-bridge/spawnChannel` instead of each reimplementing + * the child lifecycle. The adapter migrations land separately. */ export interface AcpChannel { stream: Stream; diff --git a/packages/acp-bridge/src/compactionEngine.test.ts b/packages/acp-bridge/src/compactionEngine.test.ts new file mode 100644 index 0000000000..4975135f06 --- /dev/null +++ b/packages/acp-bridge/src/compactionEngine.test.ts @@ -0,0 +1,1144 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { TurnBoundaryCompactionEngine } from './compactionEngine.js'; +import { EventBus } from './eventBus.js'; +import type { BridgeEvent } from './eventBus.js'; + +function makeTextChunk(id: number, text: string): BridgeEvent { + return { + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text }, + }, + }, + }; +} + +function makeThoughtChunk(id: number, text: string): BridgeEvent { + return { + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text }, + }, + }, + }; +} + +function makeUserMessage(id: number, text: string): BridgeEvent { + return { + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text }, + }, + }, + }; +} + +function makeToolCall( + id: number, + toolCallId: string, + status: string, + extra: Record = {}, +): BridgeEvent { + return { + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId, + status, + ...extra, + }, + }, + }; +} + +function makeToolCallUpdate( + id: number, + toolCallId: string, + status: string, + extra: Record = {}, +): BridgeEvent { + return { + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId, + status, + ...extra, + }, + }, + }; +} + +function makeTurnComplete(id: number): BridgeEvent { + return { + id, + v: 1, + type: 'turn_complete', + data: { stopReason: 'end_turn' }, + }; +} + +function makeTurnError(id: number): BridgeEvent { + return { + id, + v: 1, + type: 'turn_error', + data: { error: 'cancelled' }, + }; +} + +function makePermissionRequest(id: number, requestId: string): BridgeEvent { + return { + id, + v: 1, + type: 'permission_request', + data: { requestId, request: { tool: 'Bash', command: 'ls' } }, + }; +} + +function makePermissionResolved(id: number, requestId: string): BridgeEvent { + return { + id, + v: 1, + type: 'permission_resolved', + data: { requestId, outcome: 'approved' }, + }; +} + +function makeModelSwitched(id: number, modelId: string): BridgeEvent { + return { + id, + v: 1, + type: 'model_switched', + data: { modelId }, + }; +} + +function makeAvailableCommandsUpdate(id: number): BridgeEvent { + return { + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'available_commands_update', + commands: ['/help'], + }, + }, + }; +} + +function makeTextChunkWithParent( + id: number, + text: string, + parentToolCallId: string, +): BridgeEvent { + const event = makeTextChunk(id, text); + (event.data as { update: Record }).update['_meta'] = { + parentToolCallId, + }; + return event; +} + +function makeThoughtChunkWithParent( + id: number, + text: string, + parentToolCallId: string, +): BridgeEvent { + const event = makeThoughtChunk(id, text); + (event.data as { update: Record }).update['_meta'] = { + parentToolCallId, + }; + return event; +} + +function extractTexts(events: BridgeEvent[]): string[] { + return events + .filter((e) => e.type === 'session_update') + .map((e) => { + const data = e.data as { update?: { content?: { text?: string } } }; + return data?.update?.content?.text ?? ''; + }) + .filter((t) => t !== ''); +} + +describe('TurnBoundaryCompactionEngine', () => { + describe('basic compaction', () => { + it('merges consecutive text chunks into a single event on turn_complete', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'Hello')); + engine.ingest(makeTextChunk(2, ' ')); + engine.ingest(makeTextChunk(3, 'world')); + engine.ingest(makeTurnComplete(4)); + + const snap = engine.snapshot(); + expect(snap.compactedTurns).toHaveLength(2); // merged text + turn_complete + expect(snap.liveJournal).toHaveLength(0); + expect(snap.lastEventId).toBe(4); + + const textEvent = snap.compactedTurns[0]!; + expect(textEvent.id).toBe(3); // last chunk's id + expect(textEvent.type).toBe('session_update'); + const data = textEvent.data as { + update: { sessionUpdate: string; content: { text: string } }; + }; + expect(data.update.sessionUpdate).toBe('agent_message_chunk'); + expect(data.update.content.text).toBe('Hello world'); + }); + + it('merges consecutive thought chunks', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeThoughtChunk(1, 'Let me ')); + engine.ingest(makeThoughtChunk(2, 'think...')); + engine.ingest(makeTextChunk(3, 'Answer')); + engine.ingest(makeTurnComplete(4)); + + const snap = engine.snapshot(); + expect(snap.compactedTurns).toHaveLength(3); // thought + text + turn_complete + + const thoughtEvent = snap.compactedTurns[0]!; + const data = thoughtEvent.data as { + update: { sessionUpdate: string; content: { text: string } }; + }; + expect(data.update.sessionUpdate).toBe('agent_thought_chunk'); + expect(data.update.content.text).toBe('Let me think...'); + }); + + it('keeps user messages as-is', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeUserMessage(1, 'How are you?')); + engine.ingest(makeTextChunk(2, 'I am fine')); + engine.ingest(makeTurnComplete(3)); + + const snap = engine.snapshot(); + expect(snap.compactedTurns).toHaveLength(3); + const data = snap.compactedTurns[0]!.data as { + update: { sessionUpdate: string; content: { text: string } }; + }; + expect(data.update.sessionUpdate).toBe('user_message_chunk'); + expect(data.update.content.text).toBe('How are you?'); + expect(snap.compactedTurns[0]!.id).toBe(1); + }); + }); + + describe('tool call folding', () => { + it('folds tool_call + tool_call_updates into single final-state event', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'Let me check')); + engine.ingest({ + ...makeToolCall(2, 'tc1', 'running', { title: 'Read file' }), + _meta: { serverTimestamp: 100, source: 'initial' }, + }); + engine.ingest({ + ...makeToolCallUpdate(3, 'tc1', 'running', { + content: 'reading...', + }), + _meta: { serverTimestamp: 150 }, + }); + engine.ingest({ + ...makeToolCallUpdate(4, 'tc1', 'done', { + rawOutput: 'file contents', + }), + _meta: { serverTimestamp: 200 }, + }); + engine.ingest(makeTextChunk(5, 'Done')); + engine.ingest(makeTurnComplete(6)); + + const snap = engine.snapshot(); + // text("Let me check") + tool(tc1 final) + text("Done") + turn_complete + expect(snap.compactedTurns).toHaveLength(4); + + const toolEvent = snap.compactedTurns[1]!; + const data = toolEvent.data as { + update: { + toolCallId: string; + status: string; + title: string; + rawOutput: string; + }; + }; + expect(data.update.toolCallId).toBe('tc1'); + expect(data.update.status).toBe('done'); + expect(data.update.title).toBe('Read file'); + expect(data.update.rawOutput).toBe('file contents'); + expect(toolEvent.id).toBe(4); // last update's id + expect(toolEvent._meta).toEqual({ + serverTimestamp: 200, + source: 'initial', + }); + }); + + it('preserves tool call order when multiple tools run', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeToolCall(1, 'tc1', 'running', { title: 'Tool A' })); + engine.ingest(makeToolCall(2, 'tc2', 'running', { title: 'Tool B' })); + engine.ingest(makeToolCallUpdate(3, 'tc1', 'done')); + engine.ingest(makeToolCallUpdate(4, 'tc2', 'done')); + engine.ingest(makeTurnComplete(5)); + + const snap = engine.snapshot(); + const toolEvents = snap.compactedTurns.filter( + (e) => + e.type === 'session_update' && + (e.data as { update?: { sessionUpdate?: string } })?.update + ?.sessionUpdate === 'tool_call', + ); + expect(toolEvents).toHaveLength(2); + expect( + (toolEvents[0]!.data as { update: { title: string } }).update.title, + ).toBe('Tool A'); + expect( + (toolEvents[1]!.data as { update: { title: string } }).update.title, + ).toBe('Tool B'); + }); + }); + + describe('text segmentation across tool calls', () => { + it('preserves separate text segments before and after tool calls', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'Before')); + engine.ingest(makeTextChunk(2, ' tool')); + engine.ingest(makeToolCall(3, 'tc1', 'running')); + engine.ingest(makeToolCallUpdate(4, 'tc1', 'done')); + engine.ingest(makeTextChunk(5, 'After')); + engine.ingest(makeTextChunk(6, ' tool')); + engine.ingest(makeTurnComplete(7)); + + const texts = extractTexts(engine.snapshot().compactedTurns); + expect(texts).toEqual(['Before tool', 'After tool']); + }); + }); + + describe('transient event filtering', () => { + it('drops transient events (slow_client_warning, replay_complete, etc.)', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'Hello')); + engine.ingest({ + v: 1, + type: 'slow_client_warning', + data: { queueSize: 200 }, + }); + engine.ingest({ + id: 2, + v: 1, + type: 'replay_complete', + data: { replayedCount: 5 }, + }); + engine.ingest(makeTurnComplete(3)); + + const snap = engine.snapshot(); + expect(snap.compactedTurns).toHaveLength(2); // text + turn_complete + expect(snap.liveJournal).toHaveLength(0); + }); + }); + + describe('latest-wins events', () => { + it('keeps only the most recent available_commands_update per turn', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeAvailableCommandsUpdate(1)); + engine.ingest(makeAvailableCommandsUpdate(2)); + engine.ingest(makeAvailableCommandsUpdate(3)); + engine.ingest(makeTurnComplete(4)); + + const snap = engine.snapshot(); + const cmdUpdates = snap.compactedTurns.filter( + (e) => + (e.data as { update?: { sessionUpdate?: string } })?.update + ?.sessionUpdate === 'available_commands_update', + ); + expect(cmdUpdates).toHaveLength(1); + expect(cmdUpdates[0]!.id).toBe(3); + }); + }); + + describe('permission events', () => { + it('preserves permission_request and permission_resolved', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'I need permission')); + engine.ingest(makePermissionRequest(2, 'perm-1')); + engine.ingest(makePermissionResolved(3, 'perm-1')); + engine.ingest(makeTextChunk(4, 'Done')); + engine.ingest(makeTurnComplete(5)); + + const snap = engine.snapshot(); + const permEvents = snap.compactedTurns.filter( + (e) => + e.type === 'permission_request' || e.type === 'permission_resolved', + ); + expect(permEvents).toHaveLength(2); + }); + }); + + describe('model_switched events', () => { + it('preserves model_switched events', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeModelSwitched(1, 'opus-4')); + engine.ingest(makeTextChunk(2, 'Response')); + engine.ingest(makeTurnComplete(3)); + + const snap = engine.snapshot(); + const modelEvents = snap.compactedTurns.filter( + (e) => e.type === 'model_switched', + ); + expect(modelEvents).toHaveLength(1); + expect((modelEvents[0]!.data as { modelId: string }).modelId).toBe( + 'opus-4', + ); + }); + }); + + describe('liveJournal (incomplete turn)', () => { + it('accumulates raw events in liveJournal before turn completes', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'H')); + engine.ingest(makeTextChunk(2, 'i')); + + const snap = engine.snapshot(); + expect(snap.compactedTurns).toHaveLength(0); + expect(snap.liveJournal).toHaveLength(2); + expect(snap.lastEventId).toBe(2); + }); + + it('clears liveJournal on turn completion', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'Hello')); + engine.ingest(makeTurnComplete(2)); + engine.ingest(makeTextChunk(3, 'New turn')); + + const snap = engine.snapshot(); + expect(snap.compactedTurns).toHaveLength(2); + expect(snap.liveJournal).toHaveLength(1); + expect(snap.liveJournal[0]!.id).toBe(3); + }); + }); + + describe('multi-turn sessions', () => { + it('compacts multiple turns independently', () => { + const engine = new TurnBoundaryCompactionEngine(); + // Turn 1 + engine.ingest(makeUserMessage(1, 'Hello')); + engine.ingest(makeTextChunk(2, 'Hi')); + engine.ingest(makeTextChunk(3, ' there')); + engine.ingest(makeTurnComplete(4)); + // Turn 2 + engine.ingest(makeUserMessage(5, 'Bye')); + engine.ingest(makeTextChunk(6, 'Good')); + engine.ingest(makeTextChunk(7, 'bye')); + engine.ingest(makeTurnComplete(8)); + + const snap = engine.snapshot(); + expect(snap.lastEventId).toBe(8); + // Turn 1: user + merged_text + turn_complete + // Turn 2: user + merged_text + turn_complete + expect(snap.compactedTurns).toHaveLength(6); + const texts = extractTexts(snap.compactedTurns); + expect(texts).toContain('Hello'); + expect(texts).toContain('Hi there'); + expect(texts).toContain('Bye'); + expect(texts).toContain('Goodbye'); + }); + }); + + describe('turn_error compaction', () => { + it('compacts on turn_error the same as turn_complete', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'partial')); + engine.ingest(makeTextChunk(2, ' response')); + engine.ingest(makeTurnError(3)); + + const snap = engine.snapshot(); + expect(snap.compactedTurns).toHaveLength(2); // merged text + turn_error + expect(snap.liveJournal).toHaveLength(0); + const texts = extractTexts(snap.compactedTurns); + expect(texts).toEqual(['partial response']); + }); + }); + + describe('snapshot consistency', () => { + it('returns defensive copies', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'test')); + engine.ingest(makeTurnComplete(2)); + + const a = engine.snapshot(); + const b = engine.snapshot(); + expect(a.compactedTurns).not.toBe(b.compactedTurns); + expect(a.compactedTurns).toEqual(b.compactedTurns); + expect(a.liveJournal).not.toBe(b.liveJournal); + }); + + it('lastEventId is always consistent with content', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'a')); + expect(engine.snapshot().lastEventId).toBe(1); + + engine.ingest(makeTextChunk(2, 'b')); + expect(engine.snapshot().lastEventId).toBe(2); + + engine.ingest(makeTurnComplete(3)); + expect(engine.snapshot().lastEventId).toBe(3); + }); + }); + + describe('seed', () => { + it('seeds the engine from a persisted snapshot', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.seed({ + compactedTurns: [makeTextChunk(10, 'from disk'), makeTurnComplete(11)], + lastEventId: 11, + }); + + // New events build on top of the seeded state + engine.ingest(makeTextChunk(12, 'live')); + engine.ingest(makeTurnComplete(13)); + + const snap = engine.snapshot(); + expect(snap.compactedTurns).toHaveLength(4); // 2 seeded + 2 new + expect(snap.lastEventId).toBe(13); + }); + + it('seed clears in-flight slots so stale data does not corrupt post-seed output', () => { + const engine = new TurnBoundaryCompactionEngine(); + // Populate in-flight state (no turn_complete to compact them) + engine.ingest(makeTextChunkWithParent(1, 'stale-sub', 'old-task')); + engine.ingest(makeTextChunk(2, 'stale-top')); + engine.ingest(makeToolCall(3, 'tc-stale', 'running')); + + // Seed replaces history — should also clear in-flight slots + engine.seed({ + compactedTurns: [makeTextChunk(100, 'seeded'), makeTurnComplete(101)], + lastEventId: 101, + }); + + // Ingest fresh events and complete the turn + engine.ingest(makeTextChunk(102, 'fresh')); + engine.ingest(makeTurnComplete(103)); + + const snap = engine.snapshot(); + const texts = extractTexts(snap.compactedTurns); + // Should contain only seeded + fresh, not the stale pre-seed events + expect(texts).toEqual(['seeded', 'fresh']); + expect(snap.compactedTurns).toHaveLength(4); // seeded text + seeded tc + fresh text + fresh tc + }); + }); + + describe('close', () => { + it('ignores events after close', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'before')); + engine.close(); + engine.ingest(makeTextChunk(2, 'after')); + + const snap = engine.snapshot(); + expect(snap.compactedTurns).toHaveLength(0); + expect(snap.liveJournal).toHaveLength(0); + }); + }); + + describe('_meta preservation', () => { + it('preserves _meta from the last text chunk', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hello' }, + _meta: { usage: { input: 10 } }, + }, + }, + }); + engine.ingest({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: ' world' }, + _meta: { usage: { input: 10, output: 50 }, durationMs: 1200 }, + }, + }, + }); + engine.ingest(makeTurnComplete(3)); + + const snap = engine.snapshot(); + const textEvent = snap.compactedTurns[0]!; + const data = textEvent.data as { update: { _meta: unknown } }; + expect(data.update._meta).toEqual({ + usage: { input: 10, output: 50 }, + durationMs: 1200, + }); + }); + }); + + describe('edge cases', () => { + it('handles empty turn (turn_complete with no preceding events)', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTurnComplete(1)); + + const snap = engine.snapshot(); + expect(snap.compactedTurns).toHaveLength(1); // just turn_complete + expect(snap.compactedTurns[0]!.type).toBe('turn_complete'); + }); + + it('handles events without id (synthetic frames)', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest({ + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'no id' }, + }, + }, + }); + engine.ingest(makeTurnComplete(1)); + + const snap = engine.snapshot(); + expect(snap.lastEventId).toBe(1); + const texts = extractTexts(snap.compactedTurns); + expect(texts).toEqual(['no id']); + }); + + it('handles thought then text interleaved with tool calls', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeThoughtChunk(1, 'thinking')); + engine.ingest(makeThoughtChunk(2, '...')); + engine.ingest(makeTextChunk(3, 'answer')); + engine.ingest(makeToolCall(4, 'tc1', 'running')); + engine.ingest(makeToolCallUpdate(5, 'tc1', 'done')); + engine.ingest(makeTextChunk(6, 'after tool')); + engine.ingest(makeTurnComplete(7)); + + const snap = engine.snapshot(); + // thought + text("answer") + tool + text("after tool") + turn_complete + expect(snap.compactedTurns).toHaveLength(5); + + const thoughtData = snap.compactedTurns[0]!.data as { + update: { sessionUpdate: string; content: { text: string } }; + }; + expect(thoughtData.update.sessionUpdate).toBe('agent_thought_chunk'); + expect(thoughtData.update.content.text).toBe('thinking...'); + }); + }); +}); + +describe('EventBus + CompactionEngine integration', () => { + it('snapshotReplay returns compacted state after publish + turn_complete', () => { + const engine = new TurnBoundaryCompactionEngine(); + const bus = new EventBus(100, undefined, engine); + + bus.publish({ + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'hello' }, + }, + }, + }); + bus.publish({ + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hi' }, + }, + }, + }); + bus.publish({ + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: ' there' }, + }, + }, + }); + bus.publish({ type: 'turn_complete', data: { stopReason: 'end_turn' } }); + + const snapshot = bus.snapshotReplay(); + expect(snapshot).toBeDefined(); + expect(snapshot!.lastEventId).toBe(4); + expect(snapshot!.compactedTurns).toHaveLength(3); + expect(snapshot!.liveJournal).toHaveLength(0); + + const mergedText = snapshot!.compactedTurns[1]!.data as { + update: { content: { text: string } }; + }; + expect(mergedText.update.content.text).toBe('Hi there'); + expect(snapshot!.compactedTurns[1]!._meta?.['serverTimestamp']).toEqual( + expect.any(Number), + ); + }); + + it('snapshotReplay returns undefined when no engine is configured', () => { + const bus = new EventBus(100); + bus.publish({ type: 'session_update', data: {} }); + expect(bus.snapshotReplay()).toBeUndefined(); + }); + + it('liveJournal contains raw events for incomplete turn', () => { + const engine = new TurnBoundaryCompactionEngine(); + const bus = new EventBus(100, undefined, engine); + + bus.publish({ + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'streaming' }, + }, + }, + }); + bus.publish({ + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '...' }, + }, + }, + }); + + const snapshot = bus.snapshotReplay()!; + expect(snapshot.compactedTurns).toHaveLength(0); + expect(snapshot.liveJournal).toHaveLength(2); + expect(snapshot.lastEventId).toBe(2); + }); + + it('compaction engine is closed when bus closes', () => { + const engine = new TurnBoundaryCompactionEngine(); + const bus = new EventBus(100, undefined, engine); + + bus.publish({ + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'test' }, + }, + }, + }); + bus.close(); + + const snapshot = engine.snapshot(); + expect(snapshot.compactedTurns).toHaveLength(0); + expect(snapshot.liveJournal).toHaveLength(0); + }); +}); + +describe('parentToolCallId-aware text merging', () => { + type UpdatePayload = { + update: { + sessionUpdate: string; + content: { text: string }; + _meta?: Record; + }; + }; + + function getUpdate(event: BridgeEvent): UpdatePayload['update'] { + return (event.data as UpdatePayload).update; + } + + it('separates text chunks with different parentToolCallIds', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunkWithParent(1, 'Agent A says ', 'task-A')); + engine.ingest(makeTextChunkWithParent(2, 'Agent B says ', 'task-B')); + engine.ingest(makeTextChunkWithParent(3, 'hello', 'task-A')); + engine.ingest(makeTextChunkWithParent(4, 'world', 'task-B')); + engine.ingest(makeTurnComplete(5)); + + const snap = engine.snapshot(); + const textEvents = snap.compactedTurns.filter( + (e) => + e.type === 'session_update' && + getUpdate(e).sessionUpdate === 'agent_message_chunk', + ); + expect(textEvents).toHaveLength(2); + expect(getUpdate(textEvents[0]!).content.text).toBe('Agent A says hello'); + expect(getUpdate(textEvents[1]!).content.text).toBe('Agent B says world'); + expect(getUpdate(textEvents[0]!)._meta?.['parentToolCallId']).toBe( + 'task-A', + ); + expect(getUpdate(textEvents[1]!)._meta?.['parentToolCallId']).toBe( + 'task-B', + ); + }); + + it('merges interleaved thought chunks with the same parentToolCallId', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeThoughtChunkWithParent(1, 'A thinks ', 'task-A')); + engine.ingest(makeThoughtChunkWithParent(2, 'B thinks ', 'task-B')); + engine.ingest(makeThoughtChunkWithParent(3, 'more', 'task-A')); + engine.ingest(makeThoughtChunkWithParent(4, 'more', 'task-B')); + engine.ingest(makeTurnComplete(5)); + + const snap = engine.snapshot(); + const thoughtEvents = snap.compactedTurns.filter( + (e) => + e.type === 'session_update' && + getUpdate(e).sessionUpdate === 'agent_thought_chunk', + ); + expect(thoughtEvents).toHaveLength(2); + expect(getUpdate(thoughtEvents[0]!).content.text).toBe('A thinks more'); + expect(getUpdate(thoughtEvents[1]!).content.text).toBe('B thinks more'); + expect(getUpdate(thoughtEvents[0]!)._meta?.['parentToolCallId']).toBe( + 'task-A', + ); + expect(getUpdate(thoughtEvents[1]!)._meta?.['parentToolCallId']).toBe( + 'task-B', + ); + }); + + it('does not merge top-level text with subagent text', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'Top-level ')); + engine.ingest(makeTextChunkWithParent(2, 'subagent ', 'task-A')); + engine.ingest(makeTextChunk(3, 'more top')); + engine.ingest(makeTurnComplete(4)); + + const snap = engine.snapshot(); + const textEvents = snap.compactedTurns.filter( + (e) => + e.type === 'session_update' && + getUpdate(e).sessionUpdate === 'agent_message_chunk', + ); + expect(textEvents).toHaveLength(3); + expect(getUpdate(textEvents[0]!).content.text).toBe('Top-level '); + expect(getUpdate(textEvents[1]!).content.text).toBe('subagent '); + expect(getUpdate(textEvents[2]!).content.text).toBe('more top'); + expect(getUpdate(textEvents[0]!)._meta).toBeUndefined(); + expect(getUpdate(textEvents[1]!)._meta?.['parentToolCallId']).toBe( + 'task-A', + ); + expect(getUpdate(textEvents[2]!)._meta).toBeUndefined(); + }); + + it('same subagent thought + text produce separate slots', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeThoughtChunkWithParent(1, 'thinking...', 'task-A')); + engine.ingest(makeThoughtChunkWithParent(2, ' deeply', 'task-A')); + engine.ingest(makeTextChunkWithParent(3, 'Answer: ', 'task-A')); + engine.ingest(makeTextChunkWithParent(4, 'yes', 'task-A')); + engine.ingest(makeTurnComplete(5)); + + const snap = engine.snapshot(); + const sessionUpdates = snap.compactedTurns.filter( + (e) => e.type === 'session_update', + ); + expect(sessionUpdates).toHaveLength(2); + + const thought = sessionUpdates.find( + (e) => getUpdate(e).sessionUpdate === 'agent_thought_chunk', + )!; + const text = sessionUpdates.find( + (e) => getUpdate(e).sessionUpdate === 'agent_message_chunk', + )!; + expect(getUpdate(thought).content.text).toBe('thinking... deeply'); + expect(getUpdate(text).content.text).toBe('Answer: yes'); + expect(getUpdate(thought)._meta?.['parentToolCallId']).toBe('task-A'); + expect(getUpdate(text)._meta?.['parentToolCallId']).toBe('task-A'); + }); + + it('same-parent tool call segments subagent text into separate slots', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'Before')); + engine.ingest(makeTextChunkWithParent(2, 'sub-A part1', 'task-A')); + // tool_call with parentToolCallId=task-A evicts task-A's text slot + engine.ingest({ + id: 3, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc1', + status: 'running', + _meta: { parentToolCallId: 'task-A' }, + }, + }, + }); + engine.ingest(makeTextChunkWithParent(4, 'sub-A part2', 'task-A')); + engine.ingest(makeTextChunk(5, 'After')); + engine.ingest(makeTurnComplete(6)); + + const snap = engine.snapshot(); + const textEvents = snap.compactedTurns.filter( + (e) => + e.type === 'session_update' && + getUpdate(e).sessionUpdate === 'agent_message_chunk', + ); + expect(textEvents).toHaveLength(4); + expect(getUpdate(textEvents[0]!).content.text).toBe('Before'); + expect(getUpdate(textEvents[1]!).content.text).toBe('sub-A part1'); + expect(getUpdate(textEvents[2]!).content.text).toBe('sub-A part2'); + expect(getUpdate(textEvents[3]!).content.text).toBe('After'); + expect(getUpdate(textEvents[1]!)._meta?.['parentToolCallId']).toBe( + 'task-A', + ); + expect(getUpdate(textEvents[2]!)._meta?.['parentToolCallId']).toBe( + 'task-A', + ); + }); + + it('non-parent tool call does not evict subagent text slots', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunkWithParent(1, 'sub-A', 'task-A')); + // tool_call WITHOUT parentToolCallId should not evict task-A + engine.ingest(makeToolCall(2, 'tc1', 'running')); + engine.ingest(makeTextChunkWithParent(3, ' more', 'task-A')); + engine.ingest(makeTurnComplete(4)); + + const snap = engine.snapshot(); + const textEvents = snap.compactedTurns.filter( + (e) => + e.type === 'session_update' && + getUpdate(e).sessionUpdate === 'agent_message_chunk', + ); + expect(textEvents).toHaveLength(1); + expect(getUpdate(textEvents[0]!).content.text).toBe('sub-A more'); + expect(getUpdate(textEvents[0]!)._meta?.['parentToolCallId']).toBe( + 'task-A', + ); + }); + + it('same-parent tool call evicts thought slots too', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeThoughtChunkWithParent(1, 'thought-before', 'task-A')); + engine.ingest({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc1', + status: 'running', + _meta: { parentToolCallId: 'task-A' }, + }, + }, + }); + engine.ingest(makeThoughtChunkWithParent(3, 'thought-after', 'task-A')); + engine.ingest(makeTurnComplete(4)); + + const snap = engine.snapshot(); + const thoughtEvents = snap.compactedTurns.filter( + (e) => + e.type === 'session_update' && + getUpdate(e).sessionUpdate === 'agent_thought_chunk', + ); + expect(thoughtEvents).toHaveLength(2); + expect(getUpdate(thoughtEvents[0]!).content.text).toBe('thought-before'); + expect(getUpdate(thoughtEvents[1]!).content.text).toBe('thought-after'); + }); + + it('[subA, main, main, subA] produces two merged events', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunkWithParent(1, 'A-start ', 'task-A')); + engine.ingest(makeTextChunk(2, 'main-1 ')); + engine.ingest(makeTextChunk(3, 'main-2')); + engine.ingest(makeTextChunkWithParent(4, 'A-end', 'task-A')); + engine.ingest(makeTurnComplete(5)); + + const snap = engine.snapshot(); + const textEvents = snap.compactedTurns.filter( + (e) => + e.type === 'session_update' && + getUpdate(e).sessionUpdate === 'agent_message_chunk', + ); + expect(textEvents).toHaveLength(2); + expect(getUpdate(textEvents[0]!).content.text).toBe('A-start A-end'); + expect(getUpdate(textEvents[1]!).content.text).toBe('main-1 main-2'); + expect(getUpdate(textEvents[0]!)._meta?.['parentToolCallId']).toBe( + 'task-A', + ); + expect(getUpdate(textEvents[1]!)._meta).toBeUndefined(); + }); + + it('handles 9 parallel subagent thought streams without garbling', () => { + const engine = new TurnBoundaryCompactionEngine(); + const subagents = Array.from({ length: 9 }, (_, i) => `task-${i}`); + let eventId = 1; + + for (let round = 0; round < 3; round++) { + for (const taskId of subagents) { + engine.ingest( + makeThoughtChunkWithParent(eventId++, `[${taskId}:${round}]`, taskId), + ); + } + } + engine.ingest(makeTurnComplete(eventId)); + + const snap = engine.snapshot(); + const thoughtEvents = snap.compactedTurns.filter( + (e) => + e.type === 'session_update' && + getUpdate(e).sessionUpdate === 'agent_thought_chunk', + ); + expect(thoughtEvents).toHaveLength(9); + for (let i = 0; i < 9; i++) { + const taskId = `task-${i}`; + const update = getUpdate(thoughtEvents[i]!); + expect(update.content.text).toBe( + `[${taskId}:0][${taskId}:1][${taskId}:2]`, + ); + expect(update._meta?.['parentToolCallId']).toBe(taskId); + } + }); + + it('chunk without parentToolCallId separates from subagent chunk into top-level path', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunkWithParent(1, 'hello ', 'task-A')); + engine.ingest({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'world' }, + _meta: { usage: { inputTokens: 100 } }, + }, + }, + }); + engine.ingest(makeTurnComplete(3)); + + const snap = engine.snapshot(); + const textEvents = snap.compactedTurns.filter( + (e) => + e.type === 'session_update' && + getUpdate(e).sessionUpdate === 'agent_message_chunk', + ); + // The chunk without parentToolCallId goes to the top-level path, + // so we get two separate events + expect(textEvents).toHaveLength(2); + expect(getUpdate(textEvents[0]!).content.text).toBe('hello '); + expect(getUpdate(textEvents[0]!)._meta?.['parentToolCallId']).toBe( + 'task-A', + ); + expect(getUpdate(textEvents[1]!).content.text).toBe('world'); + }); + + it('tool_call_update does not evict subagent text slots', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunkWithParent(1, 'part1', 'task-A')); + // First tool_call creates the tool block — evicts task-A + engine.ingest({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tc1', + status: 'running', + _meta: { parentToolCallId: 'task-A' }, + }, + }, + }); + engine.ingest(makeTextChunkWithParent(3, 'part2', 'task-A')); + // tool_call_update is a status update, not a new tool — should NOT evict + engine.ingest({ + id: 4, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tc1', + status: 'completed', + _meta: { parentToolCallId: 'task-A' }, + }, + }, + }); + engine.ingest(makeTextChunkWithParent(5, ' part3', 'task-A')); + engine.ingest(makeTurnComplete(6)); + + const snap = engine.snapshot(); + const textEvents = snap.compactedTurns.filter( + (e) => + e.type === 'session_update' && + getUpdate(e).sessionUpdate === 'agent_message_chunk', + ); + // part1 (evicted by tool_call), part2+part3 (merged, not evicted by update) + expect(textEvents).toHaveLength(2); + expect(getUpdate(textEvents[0]!).content.text).toBe('part1'); + expect(getUpdate(textEvents[1]!).content.text).toBe('part2 part3'); + }); + + it('parentToolCallId survives in lastMeta through multi-chunk merge', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunkWithParent(1, 'hello ', 'task-A')); + engine.ingest({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'world' }, + _meta: { parentToolCallId: 'task-A', usage: { inputTokens: 100 } }, + }, + }, + }); + engine.ingest(makeTurnComplete(3)); + + const snap = engine.snapshot(); + const textEvents = snap.compactedTurns.filter( + (e) => + e.type === 'session_update' && + getUpdate(e).sessionUpdate === 'agent_message_chunk', + ); + expect(textEvents).toHaveLength(1); + expect(getUpdate(textEvents[0]!).content.text).toBe('hello world'); + expect(getUpdate(textEvents[0]!)._meta?.['parentToolCallId']).toBe( + 'task-A', + ); + }); + + it('single subagent chunk preserves parentToolCallId in output', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunkWithParent(1, 'hello', 'task-A')); + engine.ingest(makeTurnComplete(2)); + + const snap = engine.snapshot(); + const textEvents = snap.compactedTurns.filter( + (e) => + e.type === 'session_update' && + getUpdate(e).sessionUpdate === 'agent_message_chunk', + ); + expect(textEvents).toHaveLength(1); + expect(getUpdate(textEvents[0]!)._meta?.['parentToolCallId']).toBe( + 'task-A', + ); + }); +}); diff --git a/packages/acp-bridge/src/compactionEngine.ts b/packages/acp-bridge/src/compactionEngine.ts new file mode 100644 index 0000000000..284fd9efc1 --- /dev/null +++ b/packages/acp-bridge/src/compactionEngine.ts @@ -0,0 +1,378 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EVENT_SCHEMA_VERSION, + type BridgeEvent, + type CompactionEngine, + type SessionReplaySnapshot, +} from './eventBus.js'; + +export type { CompactionEngine, SessionReplaySnapshot }; + +interface SessionUpdateData { + update?: { + sessionUpdate?: string; + content?: { type?: string; text?: string }; + toolCallId?: string; + status?: string; + _meta?: unknown; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +const TURN_BOUNDARY_TYPES = new Set(['turn_complete', 'turn_error']); +const TRANSIENT_TYPES = new Set([ + 'slow_client_warning', + 'client_evicted', + 'replay_complete', + 'stream_error', +]); +const LATEST_WINS_UPDATES = new Set([ + 'available_commands_update', + 'current_mode_update', +]); + +type CompactedSlot = + | { + kind: 'text' | 'thought'; + parentToolCallId?: string; + chunks: string[]; + lastEventId: number; + lastMeta: unknown; + lastEnvelopeMeta?: Record; + } + | { kind: 'tool'; toolCallId: string; event: BridgeEvent } + | { kind: 'misc'; event: BridgeEvent } + | { kind: 'latestWins'; key: string; event: BridgeEvent }; + +/** + * Compaction engine that merges events at turn boundaries. + * + * On each `turn_complete` / `turn_error`, all accumulated events for that + * turn are folded: consecutive text/thought chunks merge into single events, + * tool call sequences fold to final state, transient signals are dropped. + * The relative ordering of different event types is preserved. + * + * The result is a replay log whose size is O(conversation_turns), not + * O(streaming_tokens). Typical compression: 25-30x for chatty sessions. + */ +export class TurnBoundaryCompactionEngine implements CompactionEngine { + private compactedTurns: BridgeEvent[] = []; + private liveJournal: BridgeEvent[] = []; + private lastEventId = 0; + private closed = false; + + private slots: CompactedSlot[] = []; + private toolSlotIndex: Map = new Map(); + private textSlotIndex: Map = new Map(); + + ingest(event: BridgeEvent): void { + if (this.closed) return; + if (event.id !== undefined) { + this.lastEventId = event.id; + } + + if (TRANSIENT_TYPES.has(event.type)) return; + + this.liveJournal.push(event); + + if (TURN_BOUNDARY_TYPES.has(event.type)) { + this.compactCurrentTurn(event); + return; + } + + if (event.type === 'session_update') { + this.classifySessionUpdate(event); + return; + } + + this.slots.push({ kind: 'misc', event }); + } + + snapshot(): SessionReplaySnapshot { + return { + compactedTurns: this.compactedTurns.slice(), + liveJournal: this.liveJournal.slice(), + lastEventId: this.lastEventId, + }; + } + + seed(snapshot: { compactedTurns: BridgeEvent[]; lastEventId: number }): void { + if (this.closed) return; + this.compactedTurns = snapshot.compactedTurns.slice(); + this.lastEventId = snapshot.lastEventId; + this.liveJournal = []; + this.slots = []; + this.toolSlotIndex.clear(); + this.textSlotIndex.clear(); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.compactedTurns = []; + this.liveJournal = []; + this.slots = []; + this.toolSlotIndex.clear(); + this.textSlotIndex.clear(); + } + + private classifySessionUpdate(event: BridgeEvent): void { + const data = event.data as SessionUpdateData | undefined; + const updateType = data?.update?.sessionUpdate; + + if (!updateType) { + this.slots.push({ kind: 'misc', event }); + return; + } + + switch (updateType) { + case 'agent_message_chunk': { + this.mergeTextSlot('text', event, data); + break; + } + case 'agent_thought_chunk': { + this.mergeTextSlot('thought', event, data); + break; + } + case 'tool_call': + case 'tool_call_update': { + const toolCallId = data?.update?.toolCallId; + if (!toolCallId) { + this.slots.push({ kind: 'misc', event }); + break; + } + const existingIdx = this.toolSlotIndex.get(toolCallId); + if (existingIdx !== undefined) { + const slot = this.slots[existingIdx] as Extract< + CompactedSlot, + { kind: 'tool' } + >; + slot.event = mergeToolCallEvent(slot.event, event); + } else { + const normalizedEvent = normalizeToolCallType(event); + this.toolSlotIndex.set(toolCallId, this.slots.length); + this.slots.push({ + kind: 'tool', + toolCallId, + event: normalizedEvent, + }); + // Evict text/thought index entries for this tool's parent so + // subsequent chunks from the same subagent create new slots, + // preserving text segmentation around tool-call boundaries. + const toolParent = extractParentToolCallIdFromMeta( + data?.update?._meta, + ); + if (toolParent) { + this.textSlotIndex.delete(`text::${toolParent}`); + this.textSlotIndex.delete(`thought::${toolParent}`); + } + } + break; + } + default: { + if (LATEST_WINS_UPDATES.has(updateType)) { + const existingIdx = this.slots.findIndex( + (s) => s.kind === 'latestWins' && s.key === updateType, + ); + if (existingIdx !== -1) { + ( + this.slots[existingIdx] as Extract< + CompactedSlot, + { kind: 'latestWins' } + > + ).event = event; + } else { + this.slots.push({ kind: 'latestWins', key: updateType, event }); + } + } else { + this.slots.push({ kind: 'misc', event }); + } + break; + } + } + } + + private mergeTextSlot( + kind: 'text' | 'thought', + event: BridgeEvent, + data: SessionUpdateData | undefined, + ): void { + const text = data?.update?.content?.text ?? ''; + const meta = data?.update?._meta; + const parentToolCallId = extractParentToolCallIdFromMeta(meta); + + if (parentToolCallId != null) { + // Subagent path: merge by (kind, parentToolCallId) regardless of + // position. Parallel subagents interleave chunks; the index lets + // us reassemble each subagent's stream without garbling. + const slotKey = `${kind}::${parentToolCallId}`; + const existingIdx = this.textSlotIndex.get(slotKey); + if (existingIdx !== undefined) { + const slot = this.slots[existingIdx] as Extract< + CompactedSlot, + { kind: 'text' | 'thought' } + >; + slot.chunks.push(text); + if (event.id !== undefined) slot.lastEventId = event.id; + slot.lastMeta = meta ?? slot.lastMeta; + slot.lastEnvelopeMeta = event._meta ?? slot.lastEnvelopeMeta; + } else { + this.textSlotIndex.set(slotKey, this.slots.length); + this.slots.push({ + kind, + parentToolCallId, + chunks: [text], + lastEventId: event.id ?? 0, + lastMeta: meta, + lastEnvelopeMeta: event._meta, + }); + } + } else { + // Top-level path: merge only consecutive same-kind chunks that + // also have no parentToolCallId. Preserves text segmentation + // around tool calls (text before / text after stay separate). + const lastSlot = this.slots[this.slots.length - 1]; + if ( + lastSlot && + lastSlot.kind === kind && + lastSlot.parentToolCallId == null + ) { + lastSlot.chunks.push(text); + if (event.id !== undefined) lastSlot.lastEventId = event.id; + lastSlot.lastMeta = meta ?? lastSlot.lastMeta; + lastSlot.lastEnvelopeMeta = event._meta ?? lastSlot.lastEnvelopeMeta; + } else { + this.slots.push({ + kind, + parentToolCallId: undefined, + chunks: [text], + lastEventId: event.id ?? 0, + lastMeta: meta, + lastEnvelopeMeta: event._meta, + }); + } + } + } + + private compactCurrentTurn(boundaryEvent: BridgeEvent): void { + const compacted: BridgeEvent[] = []; + + for (const slot of this.slots) { + switch (slot.kind) { + case 'text': + case 'thought': + compacted.push( + makeMergedSessionUpdateEvent( + slot.kind === 'text' + ? 'agent_message_chunk' + : 'agent_thought_chunk', + slot.chunks.join(''), + slot.lastEventId, + slot.lastMeta, + slot.lastEnvelopeMeta, + ), + ); + break; + case 'tool': + case 'misc': + case 'latestWins': + compacted.push(slot.event); + break; + default: + break; + } + } + + compacted.push(boundaryEvent); + this.compactedTurns.push(...compacted); + this.liveJournal = []; + this.slots = []; + this.toolSlotIndex.clear(); + this.textSlotIndex.clear(); + } +} + +function makeMergedSessionUpdateEvent( + sessionUpdate: string, + text: string, + eventId: number, + meta: unknown, + envelopeMeta: Record | undefined, +): BridgeEvent { + return { + id: eventId || undefined, + v: EVENT_SCHEMA_VERSION, + type: 'session_update', + ...(envelopeMeta !== undefined ? { _meta: envelopeMeta } : {}), + data: { + update: { + sessionUpdate, + content: { type: 'text', text }, + ...(meta != null ? { _meta: meta } : {}), + }, + }, + }; +} + +function normalizeToolCallType(event: BridgeEvent): BridgeEvent { + const data = event.data as SessionUpdateData | undefined; + if (data?.update?.sessionUpdate === 'tool_call_update') { + return { + ...event, + data: { + ...data, + update: { ...data.update, sessionUpdate: 'tool_call' }, + }, + }; + } + return event; +} + +function extractParentToolCallIdFromMeta(meta: unknown): string | undefined { + if (typeof meta === 'object' && meta !== null) { + const val = (meta as Record)['parentToolCallId']; + return typeof val === 'string' && val.length > 0 ? val : undefined; + } + return undefined; +} + +function mergeToolCallEvent( + existing: BridgeEvent, + incoming: BridgeEvent, +): BridgeEvent { + const existingData = existing.data as SessionUpdateData | undefined; + const incomingData = incoming.data as SessionUpdateData | undefined; + const existingUpdate = existingData?.update ?? {}; + const incomingUpdate = incomingData?.update ?? {}; + + const merged: Record = { ...existingUpdate }; + for (const [key, value] of Object.entries(incomingUpdate)) { + if (value !== undefined && value !== null) { + merged[key] = value; + } + } + // Always use 'tool_call' as the compacted type + merged['sessionUpdate'] = 'tool_call'; + const mergedMeta = + existing._meta || incoming._meta + ? { ...(existing._meta ?? {}), ...(incoming._meta ?? {}) } + : undefined; + + return { + id: incoming.id ?? existing.id, + v: EVENT_SCHEMA_VERSION, + type: 'session_update', + ...(mergedMeta ? { _meta: mergedMeta } : {}), + data: { + ...existingData, + ...incomingData, + update: merged, + }, + }; +} diff --git a/packages/acp-bridge/src/eventBus.test.ts b/packages/acp-bridge/src/eventBus.test.ts index 029526b8f8..9fca49fd3f 100644 --- a/packages/acp-bridge/src/eventBus.test.ts +++ b/packages/acp-bridge/src/eventBus.test.ts @@ -34,6 +34,32 @@ describe('EventBus', () => { expect(bus.lastEventId).toBe(2); }); + it('stamps published events with serverTimestamp metadata', () => { + const bus = new EventBus(); + const before = Date.now(); + const event = bus.publish({ + type: 'foo', + data: 1, + _meta: { source: 'test' }, + }); + const after = Date.now(); + + expect(event?._meta?.['source']).toBe('test'); + expect(event?._meta?.['serverTimestamp']).toBeGreaterThanOrEqual(before); + expect(event?._meta?.['serverTimestamp']).toBeLessThanOrEqual(after); + }); + + it('preserves an existing serverTimestamp when publishing', () => { + const bus = new EventBus(); + const event = bus.publish({ + type: 'foo', + data: 1, + _meta: { serverTimestamp: 123 }, + }); + + expect(event?._meta?.['serverTimestamp']).toBe(123); + }); + it('delivers live publishes to a subscriber', async () => { const bus = new EventBus(); const abort = new AbortController(); @@ -65,7 +91,7 @@ describe('EventBus', () => { abort.abort(); }); - it('replay + live: new events follow the replay tail', async () => { + it('replay + live: new events follow the replay tail (with replay_complete sentinel)', async () => { const bus = new EventBus(); bus.publish({ type: 'foo', data: 'a' }); bus.publish({ type: 'foo', data: 'b' }); @@ -75,8 +101,29 @@ describe('EventBus', () => { setTimeout(() => bus.publish({ type: 'foo', data: 'c' }), 5); - const events = await collect(iter, 3); - expect(events.map((e) => e.data)).toEqual(['a', 'b', 'c']); + // The replay loop drains the ring, emits a `replay_complete` + // sentinel (id-less, lets consumers drop catch-up indicators), and + // then live events flow. Sentinel goes AFTER the ring tail so the + // consumer sees historical frames first, then the "you're live now" + // signal, then live events. + const events = await collect(iter, 4); + expect(events.map((e) => e.type)).toEqual([ + 'foo', + 'foo', + 'replay_complete', + 'foo', + ]); + expect(events.map((e) => e.data)).toEqual([ + 'a', + 'b', + // D4: canonical `lastReplayedEventId` + deprecated `lastEventId` alias. + expect.objectContaining({ + lastReplayedEventId: 2, + lastEventId: 2, + replayedCount: 2, + }), + 'c', + ]); abort.abort(); }); @@ -274,13 +321,21 @@ describe('EventBus', () => { // A `lastEventId: 0` resume with a queue cap larger than the ring // collects exactly 8000 live frames; ids start at 2 because id=1 // was the one shifted out of the ring. + // + // #4175 F4 prereq: `lastEventId: 0` + earliest-id-in-ring = 2 + // crosses the eviction-detection threshold (earliest > last + 1), + // so an extra synthetic `state_resync_required` frame is emitted + // FIRST. The filter below restricts to live ids, which excludes + // the synthetic (no id), so the original "8000 live frames" + // invariant is preserved. const abort = new AbortController(); const iter = bus.subscribe({ lastEventId: 0, maxQueued: 9000, signal: abort.signal, }); - const events = await collect(iter, 8000); + // Collect 8001 frames now: 1 synthetic resync + 8000 live. + const events = await collect(iter, 8001); abort.abort(); const liveIds = events .filter((e) => e.id !== undefined) @@ -288,6 +343,8 @@ describe('EventBus', () => { expect(liveIds).toHaveLength(8000); expect(liveIds[0]).toBe(2); expect(liveIds[liveIds.length - 1]).toBe(8001); + // The synthetic resync frame is the first one. + expect(events[0]?.type).toBe('state_resync_required'); }); it('eviction detaches the abort listener from a stalled consumer (BmJT1)', async () => { @@ -405,12 +462,15 @@ describe('EventBus', () => { const events: BridgeEvent[] = []; for await (const e of iter) { events.push(e); - if (events.length === 11) break; + // 10 replay + 1 replay_complete sentinel + 1 live = 12 total + if (events.length === 12) break; } // The live frame must arrive — NOT a `client_evicted` terminal. expect(events.find((e) => e.type === 'client_evicted')).toBeUndefined(); expect(events.at(-1)?.type).toBe('live'); expect(events.filter((e) => e.type === 'replay')).toHaveLength(10); + // `replay_complete` sentinel signals end-of-replay before live frames. + expect(events.filter((e) => e.type === 'replay_complete')).toHaveLength(1); abort.abort(); }); @@ -480,9 +540,227 @@ describe('EventBus', () => { const out: BridgeEvent[] = []; for await (const e of iter) { out.push(e); - if (out.length === 3) break; + // state_resync_required (synthetic) + 3 replay frames + + // replay_complete sentinel = 5 frames. + if (out.length === 5) break; } - expect(out.map((e) => e.id)).toEqual([3, 4, 5]); + // First frame is the synthetic state_resync_required (no id). + expect(out[0]?.type).toBe('state_resync_required'); + expect(out[0]?.id).toBeUndefined(); + // Then the 3 surviving ring frames. + expect(out.slice(1, 4).map((e) => e.id)).toEqual([3, 4, 5]); + // The replay_complete sentinel fires at the end of replay even on + // the resync path — `replayedCount` is the actual frames pushed (3), + // NOT `earliestAvailableId - lastEventId` (which would over-count + // across the evicted hole). + expect(out[4]?.type).toBe('replay_complete'); + expect(out[4]?.id).toBeUndefined(); + expect(out[4]?.data).toMatchObject({ replayedCount: 3 }); abort.abort(); }); + + describe('state_resync_required (#4175 F4 prereq, Ilya0527 issue #15)', () => { + it('emits state_resync_required when lastEventId is past the ring head', async () => { + // Setup: ring holds 3, ids 1..5 published → ring contains [3,4,5]. + // Consumer reconnects with Last-Event-ID: 1 → events 2 was evicted. + // Daemon must emit state_resync_required FIRST so SDK reducer + // knows its state is stale before applying any replay frames. + const bus = new EventBus(3); + for (let i = 1; i <= 5; i++) bus.publish({ type: 'foo', data: i }); + const abort = new AbortController(); + const iter = bus.subscribe({ + lastEventId: 1, + signal: abort.signal, + }); + const out: BridgeEvent[] = []; + for await (const e of iter) { + out.push(e); + // resync + 3 replay frames + replay_complete = 5. + if (out.length === 5) break; + } + // First frame is the resync terminal (synthetic, no id). + expect(out[0]?.type).toBe('state_resync_required'); + expect(out[0]?.id).toBeUndefined(); + const data = out[0]?.data as { + reason: string; + lastDeliveredId: number; + earliestAvailableId: number; + }; + expect(data.reason).toBe('ring_evicted'); + expect(data.lastDeliveredId).toBe(1); + expect(data.earliestAvailableId).toBe(3); // event 2 was evicted + // Replay continues after the resync frame (per design — SDK can + // compute "what you missed" diff later) — so we still get the + // 3 surviving ring frames. + expect(out.slice(1, 4).map((e) => e.id)).toEqual([3, 4, 5]); + // replay_complete sentinel closes the replay even when a resync + // gap preceded it; replayedCount counts only the 3 surviving + // frames actually delivered (not the evicted hole). + expect(out[4]?.type).toBe('replay_complete'); + expect(out[4]?.data).toMatchObject({ replayedCount: 3 }); + abort.abort(); + }); + + it('does NOT emit state_resync_required when lastEventId is in the ring', async () => { + // Consumer's lastEventId is well within the ring → no gap → no + // resync needed. + const bus = new EventBus(10); + for (let i = 1; i <= 5; i++) bus.publish({ type: 'foo', data: i }); + const abort = new AbortController(); + const iter = bus.subscribe({ + lastEventId: 2, + signal: abort.signal, + }); + const out: BridgeEvent[] = []; + for await (const e of iter) { + out.push(e); + if (out.length === 3) break; + } + // No resync frame — just the 3 replay frames (ids 3, 4, 5). + expect(out.map((e) => e.id)).toEqual([3, 4, 5]); + expect(out.some((e) => e.type === 'state_resync_required')).toBe(false); + abort.abort(); + }); + + it('does NOT emit state_resync_required at the exact boundary (lastEventId === earliest - 1)', async () => { + // Boundary: ring's earliest id is N, lastEventId is N-1. + // No gap → no resync. Off-by-one guard. + const bus = new EventBus(3); + for (let i = 1; i <= 5; i++) bus.publish({ type: 'foo', data: i }); + // Ring is now [3, 4, 5]. lastEventId=2 means "I have 1 and 2"; + // next expected is 3, which IS in the ring. No gap. + const abort = new AbortController(); + const iter = bus.subscribe({ + lastEventId: 2, + signal: abort.signal, + }); + const out: BridgeEvent[] = []; + for await (const e of iter) { + out.push(e); + // 3 replay frames + 1 replay_complete sentinel = 4 total + if (out.length === 4) break; + } + expect(out.some((e) => e.type === 'state_resync_required')).toBe(false); + // Replay frames in order, then the sentinel (id-less, signals + // catch-up complete). + expect(out.filter((e) => e.type === 'foo').map((e) => e.id)).toEqual([ + 3, 4, 5, + ]); + expect(out.filter((e) => e.type === 'replay_complete')).toHaveLength(1); + abort.abort(); + }); + + it('emits epoch_reset resync when lastEventId is past the bus high-water (D1)', async () => { + // doudouOUC #4484 post-merge review (D1): a fresh bus (nextId=1, + // empty ring) that receives a consumer presenting `lastEventId: 5` + // means the consumer's cursor is from a PREVIOUS bus epoch (daemon + // restart rebuilt the EventBus). Pre-fix this slid past the + // `ring_evicted` check (empty ring) and emitted a bare + // `replay_complete{replayedCount:0}` — a false "you're caught up" + // while the consumer's reducer still held dead-epoch state. Now it + // must emit `state_resync_required{reason:'epoch_reset'}` first. + const bus = new EventBus(10); + const abort = new AbortController(); + const iter = bus.subscribe({ + lastEventId: 5, + signal: abort.signal, + }); + // Publish one live event AFTER subscribe to confirm the stream works. + setTimeout(() => bus.publish({ type: 'foo', data: 1 }), 0); + const out: BridgeEvent[] = []; + for await (const e of iter) { + out.push(e); + // resync + replay_complete (0 frames) + 1 live = 3 total. + if (out.length === 3) break; + } + expect(out[0]?.type).toBe('state_resync_required'); + expect(out[0]?.id).toBeUndefined(); + const data = out[0]?.data as { + reason: string; + lastDeliveredId: number; + earliestAvailableId: number; + }; + expect(data.reason).toBe('epoch_reset'); + expect(data.lastDeliveredId).toBe(5); + expect(data.earliestAvailableId).toBe(1); + expect(out[1]?.type).toBe('replay_complete'); + expect(out[1]?.data).toMatchObject({ replayedCount: 0 }); + expect(out[2]?.type).toBe('foo'); + expect(out[2]?.id).toBe(1); + abort.abort(); + }); + + it('epoch_reset replays the WHOLE fresh ring (stale cursor must not filter new low ids)', async () => { + // After a restart the new epoch starts ids at 1 again. A consumer + // reconnecting with `lastEventId: 50` (dead epoch) must still receive + // the fresh ring's low-id events — filtering replay by 50 would drop + // ids 1..3 entirely, leaving the consumer permanently behind. + const bus = new EventBus(10); + for (let i = 1; i <= 3; i++) bus.publish({ type: 'foo', data: i }); + const abort = new AbortController(); + const iter = bus.subscribe({ + lastEventId: 50, + signal: abort.signal, + }); + const out: BridgeEvent[] = []; + for await (const e of iter) { + out.push(e); + // resync + 3 replay frames + replay_complete = 5. + if (out.length === 5) break; + } + expect(out[0]?.type).toBe('state_resync_required'); + expect((out[0]?.data as { reason: string }).reason).toBe('epoch_reset'); + // All three fresh events replay despite ids < stale cursor. + expect(out.slice(1, 4).map((e) => e.id)).toEqual([1, 2, 3]); + expect(out[4]?.type).toBe('replay_complete'); + expect(out[4]?.data).toMatchObject({ replayedCount: 3 }); + abort.abort(); + }); + + it('does NOT emit epoch_reset at the caught-up boundary (lastEventId === high-water)', async () => { + // Consumer fully caught up: lastEventId equals the bus high-water + // (nextId - 1). nextId is one past it, so `lastEventId >= nextId` is + // false — no epoch reset. Off-by-one guard for D1. + const bus = new EventBus(10); + for (let i = 1; i <= 3; i++) bus.publish({ type: 'foo', data: i }); + // high-water is 3; nextId is 4. lastEventId: 3 is the caught-up case. + const abort = new AbortController(); + const iter = bus.subscribe({ + lastEventId: 3, + signal: abort.signal, + }); + setTimeout(() => bus.publish({ type: 'foo', data: 99 }), 0); + const out: BridgeEvent[] = []; + for await (const e of iter) { + out.push(e); + // replay_complete (0 frames) + 1 live = 2. + if (out.length === 2) break; + } + expect(out.some((e) => e.type === 'state_resync_required')).toBe(false); + expect(out[0]?.type).toBe('replay_complete'); + expect(out[1]?.id).toBe(4); + abort.abort(); + }); + + it('does NOT emit state_resync_required when no lastEventId is provided (fresh subscribe)', async () => { + // First-time subscriber has no prior state to resync — resync + // would be meaningless. Check the no-lastEventId branch is + // skipped entirely. + const bus = new EventBus(3); + for (let i = 1; i <= 5; i++) bus.publish({ type: 'foo', data: i }); + const abort = new AbortController(); + const iter = bus.subscribe({ signal: abort.signal }); + // Live-only — publish one event after subscribe to give the + // iterator something to yield. + setTimeout(() => bus.publish({ type: 'foo', data: 99 }), 0); + const out: BridgeEvent[] = []; + for await (const e of iter) { + out.push(e); + if (out.length === 1) break; + } + expect(out[0]?.type).toBe('foo'); + expect(out.some((e) => e.type === 'state_resync_required')).toBe(false); + abort.abort(); + }); + }); }); diff --git a/packages/acp-bridge/src/eventBus.ts b/packages/acp-bridge/src/eventBus.ts index 861e02fbc1..dc23c78fdc 100644 --- a/packages/acp-bridge/src/eventBus.ts +++ b/packages/acp-bridge/src/eventBus.ts @@ -7,7 +7,7 @@ /** * Event-bus for the daemon's per-session NDJSON stream. * - * Design notes (from issue #3803 §04 / threat-model): + * Design notes (from the threat-model): * - Each event carries a monotonic `id` (per session) so the SSE * `Last-Event-ID` reconnect protocol can pick up where the client left * off. Backed by a bounded ring of recent events for replay. @@ -19,6 +19,18 @@ * Aborting the supplied AbortSignal closes the iterator promptly. */ +export interface SessionReplaySnapshot { + compactedTurns: BridgeEvent[]; + liveJournal: BridgeEvent[]; + lastEventId: number; +} + +export interface CompactionEngine { + ingest(event: BridgeEvent): void; + snapshot(): SessionReplaySnapshot; + close(): void; +} + export const EVENT_SCHEMA_VERSION = 1 as const; /** A single frame published on the bus. */ @@ -37,6 +49,10 @@ export interface BridgeEvent { type: string; /** Frame payload — opaque JSON. */ data: unknown; + /** + * Envelope metadata shared by SSE and load/replay responses. + */ + _meta?: Record; /** * Identifier of the client that triggered the event, when known. Used by * fan-out consumers to suppress echoes of their own actions. @@ -68,7 +84,7 @@ const DEFAULT_MAX_QUEUED = 256; * turn, real workloads can be 10× that or more once tool-call / * thought streams pile up). 1000 was the original default and could * be exhausted by a moderate turn before the client reconnected; - * 8000 matches the target set in #3803 §02 for chatty Stage 1 + * 8000 matches the target set for chatty Stage 1 * sessions, with ~30–60× headroom over a typical-but-busy turn at * the cost of a few hundred KB of RAM per session. Operators can * override per-daemon via `qwen serve --event-ring-size `. @@ -96,6 +112,13 @@ const WARN_RESET_RATIO = 0.375; */ const DEFAULT_MAX_SUBSCRIBERS = 64; +function getServerTimestamp(meta: Record | undefined): number { + const existing = meta?.['serverTimestamp']; + return typeof existing === 'number' && Number.isFinite(existing) + ? existing + : Date.now(); +} + interface InternalSub { queue: BoundedAsyncQueue; evicted: boolean; @@ -120,7 +143,7 @@ interface InternalSub { */ warned: boolean; /** - * BmJT1: cleanup hook for the eviction path (overflow → close queue + * Note: cleanup hook for the eviction path (overflow → close queue * → remove from `subs`). Without this, the abort listener registered * in `subscribe()` would stay attached against the consumer's * AbortSignal — and the consumer is by definition stalled (that's @@ -147,7 +170,7 @@ export class SubscriberLimitExceededError extends Error { } } -// FIXME(stage-1.5, chiga0 finding 2): +// FIXME(stage-1.5): // `EventBus` is currently private to the SSE route handler. Stage 1.5 // should lift it to a top-level building block (likely // `packages/event-bus`) so other agent-exposing surfaces @@ -166,8 +189,13 @@ export class EventBus { constructor( private readonly ringSize: number = DEFAULT_RING_SIZE, private readonly maxSubscribers: number = DEFAULT_MAX_SUBSCRIBERS, + private readonly compactionEngine?: CompactionEngine, ) {} + snapshotReplay(): SessionReplaySnapshot | undefined { + return this.compactionEngine?.snapshot(); + } + /** Most recent id ever assigned by `publish`. 0 if no events published. */ get lastEventId(): number { return this.nextId - 1; @@ -183,7 +211,7 @@ export class EventBus { * (with `id` + `v` assigned) on success, or `undefined` when the * bus is closed. * - * **Never throws** (BX9_p contract). Closing the bus mid-publish + * **Never throws** (never-throws contract). Closing the bus mid-publish * is the only abnormal path and is handled as a return-undefined * no-op; subscriber-enqueue failures are caught internally and * translated to per-subscriber eviction. Call sites can rely on @@ -205,14 +233,25 @@ export class EventBus { // straightforward; nobody can observe a frame nobody can subscribe // to anyway. if (this.closed) return undefined; + const existingMeta = input._meta; const event: BridgeEvent = { id: this.nextId++, v: EVENT_SCHEMA_VERSION, ...input, + _meta: { + ...(existingMeta ?? {}), + serverTimestamp: getServerTimestamp(existingMeta), + }, }; this.ring.push(event); + try { + this.compactionEngine?.ingest(event); + } catch { + // CompactionEngine is best-effort; a throw must not break the + // publish() never-throws contract (never-throws). + } // Eviction-by-shift is O(n) once the ring is full. At the current - // default `ringSize=8000` (#3803 §02) the per-publish shift work + // default `ringSize=8000` (the target) the per-publish shift work // measures in low milliseconds on chatty sessions — still well // below per-frame latency budgets. A circular-buffer refactor // would push it to O(1) but adds index bookkeeping; deferred until @@ -244,7 +283,7 @@ export class EventBus { // consumer iterator unwinds with a final synthetic event. sub.queue.forcePush(evictionFrame); sub.queue.close(); - // BmJT1: dispose the subscription cleanly. `sub.dispose()` + // Note: dispose the subscription cleanly. `sub.dispose()` // both removes from `this.subs` AND detaches the // AbortSignal listener that `subscribe()` registered. Pre- // fix the eviction path only did `this.subs.delete(sub)`, @@ -357,22 +396,140 @@ export class EventBus { this.subs.add(sub); if (opts.lastEventId !== undefined) { + // Detect ring eviction on resume + // (ring eviction detection): if the earliest event still in the ring has + // `id > lastEventId + 1`, then events between `lastEventId + 1` + // and `earliestInRing - 1` were evicted before the consumer + // reconnected — the consumer's reducer has a gap it doesn't + // know about. Pre-fix the resume silently succeeded ("you + // caught up!") even though the SDK reducer's state was now + // diverged from the daemon's truth. + // + // Emit `state_resync_required` as an id-less synthetic frame + // (no `id` — same no-burn pattern as `client_evicted`, so it + // doesn't occupy a slot in the per-session monotonic sequence + // other subscribers observe). **Unlike `client_evicted`, the + // stream stays OPEN after this frame** — the resync frame is + // emitted FIRST (before replay), and replay + live frames + // continue flowing afterward. The SDK reducer treats this as + // "your state is stale; call loadSession before applying any + // further deltas" — see `awaitingResync` flag in the SDK + // reducer. The prior wording was corrected to note + // that called this "TERMINAL" — that's misleading for oncall; + // `client_evicted` is genuinely terminal (closes stream), + // `state_resync_required` is recovery-oriented (keeps stream + // open). + // + // Replay continues after the resync frame (per design): the + // SDK reducer will auto-skip delta application until + // loadSession clears the flag, but the frames stay on the + // wire so SDK has the option to compute a "what you missed" + // diff later. This is network-friendly (no extra reconnect). + // Epoch-reset detection (epoch-reset detection). + // `this.nextId` is the next id this bus will assign, so the bus has + // only ever emitted ids `< nextId` THIS epoch. A consumer presenting + // `lastEventId >= nextId` therefore saw an id this epoch never + // produced — the only way that happens is a previous bus epoch + // (daemon restart / EventBus rebuild resets `nextId` to 1 and clears + // the ring). The `ring_evicted` check below is structurally blind to + // this: after a restart the ring is empty (`earliestInRing === + // undefined`), so it is skipped and the consumer would otherwise get + // a bare `replay_complete{replayedCount:0}` — a false "you're caught + // up" while its accumulated reducer state is stale data from the dead + // epoch. Emit `state_resync_required` (reason `epoch_reset`) first. + const epochReset = opts.lastEventId >= this.nextId; + if (epochReset) { + queue.forcePush({ + v: EVENT_SCHEMA_VERSION, + type: 'state_resync_required', + data: { + reason: 'epoch_reset', + lastDeliveredId: opts.lastEventId, + // Ring is typically empty right after a restart; fall back to + // `nextId` (the first id this epoch will assign) so the field + // stays meaningful ("fresh sequence starts here"). + earliestAvailableId: this.ring[0]?.id ?? this.nextId, + }, + }); + } else { + const earliestInRing = this.ring[0]?.id; + if ( + earliestInRing !== undefined && + earliestInRing > opts.lastEventId + 1 + ) { + queue.forcePush({ + v: EVENT_SCHEMA_VERSION, + type: 'state_resync_required', + data: { + reason: 'ring_evicted', + lastDeliveredId: opts.lastEventId, + earliestAvailableId: earliestInRing, + }, + }); + } + } + // After an epoch reset the consumer's cursor belongs to a dead epoch, + // so every current-epoch event is "new" to it. Filtering replay by the + // stale `lastEventId` (e.g. 50) would drop the fresh low-id events + // (1,2,3…) entirely. Replay the whole current ring in that case. + const replayFrom = epochReset ? 0 : opts.lastEventId; // Force-push replay frames so they bypass the per-subscriber size // cap. The cap protects against a slow live consumer; replay is // already historical and silently dropping it would undermine the // `Last-Event-ID` resume contract (the consumer would think they // caught up). If the gap really is enormous, the queue will be // primed with a long backlog the consumer drains at its own pace. + let replayedCount = 0; + let lastReplayedId: number | undefined; for (const e of this.ring) { // The ring only ever contains live events (publish() always // assigns an id before pushing to ring), so `e.id` is never // undefined here — but the type system can't see that since // BridgeEvent.id is optional for synthetic terminal frames. // Guard explicitly to keep narrow typing without runtime cost. - if (e.id !== undefined && e.id > opts.lastEventId) { + if (e.id !== undefined && e.id > replayFrom) { queue.forcePush(e); + replayedCount += 1; + lastReplayedId = e.id; } } + // Emit a `replay_complete` sentinel so consumers can deterministically + // drop catch-up indicators. Fires both when replay actually + // delivered frames AND when there was nothing to replay (so the + // consumer always sees the transition from "catching up" to + // "live"). Synthetic frame — no `id` so it doesn't burn a slot in + // the per-session sequence (same pattern as `client_evicted` / + // `state_resync_required`). + // + // Without this sentinel, a consumer attaching via Last-Event-ID + // has no positive signal that replay drained — they have to + // heuristically time out the spinner. The state_resync_required + // path already has its own frame (above); the success path + // needed parity. + // + // `replayedCount` is the actual number of frames force-pushed, + // counted in the loop above — NOT `lastId - opts.lastEventId`, + // which would over-count when the ring has holes (state_resync + // path leaves a gap before the ring's earliest id). + queue.forcePush({ + v: EVENT_SCHEMA_VERSION, + type: 'replay_complete', + data: { + // Note: `lastReplayedEventId` + // is the canonical wire name — the old `lastEventId` collided + // semantically with the SSE protocol's `Last-Event-ID` (envelope + // `id`) in raw daemon traces. Emit both: `lastReplayedEventId` + // for current SDKs and `lastEventId` as a deprecated alias so + // pre-rename consumers keep working (additive, non-breaking). + ...(lastReplayedId !== undefined + ? { + lastReplayedEventId: lastReplayedId, + lastEventId: lastReplayedId, + } + : {}), + replayedCount, + }, + }); } let disposed = false; @@ -432,6 +589,7 @@ export class EventBus { this.closed = true; for (const sub of this.subs) sub.queue.close(); this.subs.clear(); + this.compactionEngine?.close(); } } diff --git a/packages/acp-bridge/src/index.ts b/packages/acp-bridge/src/index.ts index 642d8c1026..316d85e5f2 100644 --- a/packages/acp-bridge/src/index.ts +++ b/packages/acp-bridge/src/index.ts @@ -8,8 +8,13 @@ export * from './eventBus.js'; export * from './inMemoryChannel.js'; export * from './channel.js'; export * from './permission.js'; +export * from './permissionMediator.js'; export * from './workspacePaths.js'; export * from './status.js'; export * from './bridgeErrors.js'; export * from './bridgeTypes.js'; export * from './bridgeOptions.js'; +export * from './spawnChannel.js'; +export * from './bridgeClient.js'; +export * from './bridge.js'; +export * from './bridgeFileSystem.js'; diff --git a/packages/acp-bridge/src/internal/stderrLine.ts b/packages/acp-bridge/src/internal/stderrLine.ts new file mode 100644 index 0000000000..4c4095c04a --- /dev/null +++ b/packages/acp-bridge/src/internal/stderrLine.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Shared `writeStderrLine` helper for `bridge.ts` + `bridgeClient.ts`. + * + * Originally inlined per-file to keep the + * modules free of any reverse import on `cli/src/utils/stdioHelpers.ts`. + * Both consumers now live in the + * **same** `@qwen-code/acp-bridge` package — the cross-package + * justification no longer applies, and a future behavior change + * (timestamp prefix, log level, structured field) would require + * touching two identical copies. Extracted here so both `bridge.ts` + * and `bridgeClient.ts` import from a single source of truth. + * + * Not part of the package's public API — `internal/` subpath is + * excluded from `exports` in `package.json`. `spawnChannel.ts` + * deliberately does NOT consume this (its stderr writes carry their + * own `[serve pid=… cwd=…]` line prefix and use raw + * `process.stderr.write` for that reason). + * + * Byte-identical to the original `cli/src/utils/stdioHelpers.ts` + * implementation. + */ +export function writeStderrLine(message: string): void { + process.stderr.write(message.endsWith('\n') ? message : `${message}\n`); +} diff --git a/packages/acp-bridge/src/internal/testUtils.ts b/packages/acp-bridge/src/internal/testUtils.ts new file mode 100644 index 0000000000..03ba2dd6cd --- /dev/null +++ b/packages/acp-bridge/src/internal/testUtils.ts @@ -0,0 +1,315 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + * + * Shared bridge test fixtures used by `bridge.test.ts` (acp-bridge + * package) and `daemonStatusProvider.test.ts` (cli package). Extracted + * so both suites can exercise the same + * `FakeAgent` / `makeChannel` / `makeBridge` helpers without + * cross-package duplication. + * + * Cross-package resolution uses two channels because TypeScript's + * `nodenext` moduleResolution will not fall back to tsconfig `paths` + * once a package's `exports` rejects a subpath. So: + * + * 1. `package.json` lists `./internal/testUtils` in `exports` so + * TypeScript can resolve types at compile time (and the cli's + * vitest run can resolve it at runtime even without an alias). + * 2. `packages/cli/vitest.config.ts` adds a `resolve.alias` for + * the same specifier that points at `src/` instead of `dist/`, + * so the cli test reads source directly — editing + * `testUtils.ts` doesn't require rebuilding acp-bridge. + * + * External consumers of `@qwen-code/acp-bridge` should NOT depend on + * these helpers — the `internal/` directory matches the neighboring + * `internal/stderrLine.ts` convention; the `@internal` JSDoc tag is + * an additional package-private signal (stderrLine.ts uses prose + * rather than the tag, but the intent is the same). The compiled + * file is excluded from npm publish via the package's `.npmignore`, + * so external consumers can't `import` it even though the source + * remains in the build for in-repo cli vitest resolution. + */ + +import * as path from 'node:path'; +import { + AgentSideConnection, + PROTOCOL_VERSION, + ndJsonStream, +} from '@agentclientprotocol/sdk'; +import type { + Agent, + AuthenticateRequest, + AuthenticateResponse, + CancelNotification, + InitializeRequest, + InitializeResponse, + LoadSessionRequest, + LoadSessionResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + ResumeSessionRequest, + ResumeSessionResponse, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, + SetSessionModeRequest, + SetSessionModeResponse, +} from '@agentclientprotocol/sdk'; +import { createAcpSessionBridge } from '../bridge.js'; +import type { BridgeOptions } from '../bridgeOptions.js'; +import type { AcpSessionBridge } from '../bridgeTypes.js'; +import type { AcpChannel } from '../channel.js'; + +// Workspace fixtures must round-trip through `path.resolve` so the +// expected values match what the bridge canonicalizes internally on +// every platform — a literal `/work/a` resolves to `D:\work\a` on +// Windows and the assertion drifts. Same for the FakeAgent's +// `sess:` synthetic id, since the cwd it sees is the post-resolve +// value the bridge passes through `connection.newSession`. +export const WS_A = path.resolve(path.sep, 'work', 'a'); +export const WS_B = path.resolve(path.sep, 'work', 'b'); +export const SESS_A = `sess:${WS_A}`; + +/** + * Convenience wrapper: `createAcpSessionBridge` requires `boundWorkspace` + * (per #3803 §02 — 1 daemon = 1 workspace). Tests that only ever talk + * to `WS_A` would otherwise repeat `boundWorkspace: WS_A` everywhere; + * this helper defaults it. Tests that need a different bind path (e.g. + * the mismatch test) pass `boundWorkspace` explicitly. + * + * Unlike the pre-split cli-side helper, this version does NOT default + * `statusProvider` — that's a daemon-host-specific seam and + * the acp-bridge tests exercise the no-provider fallback paths. The + * cli-side `daemonStatusProvider.test.ts` defines its own wrapper that + * wires `createDaemonStatusProvider()` for the 4 daemon-host + * integration tests. + */ +export function makeBridge( + opts: Partial = {}, +): AcpSessionBridge { + return createAcpSessionBridge({ + boundWorkspace: WS_A, + ...opts, + }); +} + +export interface FakeAgentOpts { + /** What the fake agent returns from `newSession`. */ + sessionIdPrefix?: string; + /** Inject a per-call delay before responding to `initialize`. */ + initializeDelayMs?: number; + /** Force `initialize` to throw. */ + initializeThrows?: Error; + /** + * Custom prompt handler. Default returns `end_turn` synchronously. Useful + * for test cases that want to observe prompt ordering. + */ + promptImpl?: ( + p: PromptRequest, + self: FakeAgent, + ) => Promise | PromptResponse; + cancelImpl?: (p: CancelNotification, self: FakeAgent) => Promise | void; + /** + * Custom `newSession` handler. Default returns a synthesized id (see + * `newSession` below). Used by tests that need to exercise the + * doSpawn newSession-failure path (e.g. throwing to cover the + * `isDying`-mark-then-kill cleanup). + */ + newSessionImpl?: ( + p: NewSessionRequest, + self: FakeAgent, + ) => Promise | NewSessionResponse; + loadSessionImpl?: ( + p: LoadSessionRequest, + self: FakeAgent, + ) => Promise | LoadSessionResponse; + resumeSessionImpl?: ( + p: ResumeSessionRequest, + self: FakeAgent, + ) => Promise | ResumeSessionResponse; + extMethodImpl?: ( + method: string, + params: Record, + self: FakeAgent, + ) => Promise> | Record; +} + +export class FakeAgent implements Agent { + newSessionCalls: NewSessionRequest[] = []; + loadSessionCalls: LoadSessionRequest[] = []; + resumeSessionCalls: ResumeSessionRequest[] = []; + promptCalls: PromptRequest[] = []; + cancelCalls: CancelNotification[] = []; + extMethodCalls: Array<{ method: string; params: Record }> = + []; + constructor(private readonly opts: FakeAgentOpts = {}) {} + + async initialize(_p: InitializeRequest): Promise { + if (this.opts.initializeThrows) throw this.opts.initializeThrows; + if (this.opts.initializeDelayMs) { + await new Promise((r) => setTimeout(r, this.opts.initializeDelayMs)); + } + return { + protocolVersion: PROTOCOL_VERSION, + agentInfo: { name: 'fake-agent', version: '0' }, + authMethods: [], + agentCapabilities: {}, + }; + } + + async newSession(p: NewSessionRequest): Promise { + this.newSessionCalls.push(p); + if (this.opts.newSessionImpl) { + return this.opts.newSessionImpl(p, this); + } + const prefix = this.opts.sessionIdPrefix ?? 'sess'; + // Stage 1.5 multi-session: one FakeAgent can host multiple + // sessions (same as the real ACP agent), so each newSession call + // returns a fresh id. Suffix by call-count so tests that issue + // multiple newSession on the same channel get distinct ids. + const count = this.newSessionCalls.length; + const suffix = count === 1 ? '' : `#${count}`; + return { sessionId: `${prefix}:${p.cwd}${suffix}` }; + } + + async loadSession(p: LoadSessionRequest): Promise { + this.loadSessionCalls.push(p); + if (this.opts.loadSessionImpl) { + return this.opts.loadSessionImpl(p, this); + } + return {}; + } + async unstable_resumeSession( + p: ResumeSessionRequest, + ): Promise { + this.resumeSessionCalls.push(p); + if (this.opts.resumeSessionImpl) { + return this.opts.resumeSessionImpl(p, this); + } + return {}; + } + async authenticate(_p: AuthenticateRequest): Promise { + throw new Error('not implemented in test fake'); + } + async prompt(p: PromptRequest): Promise { + this.promptCalls.push(p); + if (this.opts.promptImpl) { + return this.opts.promptImpl(p, this); + } + return { stopReason: 'end_turn' }; + } + async cancel(p: CancelNotification): Promise { + this.cancelCalls.push(p); + if (this.opts.cancelImpl) { + await this.opts.cancelImpl(p, this); + } + } + async setSessionMode( + _p: SetSessionModeRequest, + ): Promise { + throw new Error('not implemented in test fake'); + } + async setSessionConfigOption( + _p: SetSessionConfigOptionRequest, + ): Promise { + throw new Error('not implemented in test fake'); + } + async extMethod( + method: string, + params: Record, + ): Promise> { + this.extMethodCalls.push({ method, params }); + if (this.opts.extMethodImpl) { + return this.opts.extMethodImpl(method, params, this); + } + return {}; + } +} + +export interface ChannelHandle { + channel: AcpChannel; + agent: FakeAgent; + killed: boolean; + /** + * Resolve `channel.exited` without going through `kill()`. Optionally + * supply exit info so the bridge's `session_died` event carries the + * same `exitCode` / `signalCode` it would in a real crash (BX9_P). + */ + crash: (info?: { + exitCode: number | null; + signalCode: NodeJS.Signals | null; + }) => void; +} + +/** + * Create a paired in-memory NDJSON channel: bridge sees `clientChannel`, + * fake agent sees `agentStream`. Each `TransformStream` carries one + * direction. + * + * Not migrated to `createInMemoryChannel()` (used by the other + * `createInMemoryChannel` sites in `bridge.test.ts`): `kill()` below + * needs the underlying `ab` / `ba` writables to simulate + * child-process termination, which the bare helper deliberately does + * not expose. See `inMemoryChannel.ts` JSDoc for the rationale. + */ +export function makeChannel(opts: FakeAgentOpts = {}): ChannelHandle { + const ab = new TransformStream(); + const ba = new TransformStream(); + const clientStream = ndJsonStream(ab.writable, ba.readable); + const agentStream = ndJsonStream(ba.writable, ab.readable); + let resolveExited: + | ((info?: { + exitCode: number | null; + signalCode: NodeJS.Signals | null; + }) => void) + | undefined; + const exited = new Promise< + { exitCode: number | null; signalCode: NodeJS.Signals | null } | undefined + >((res) => { + resolveExited = res; + }); + const handle: ChannelHandle = { + channel: undefined as unknown as AcpChannel, + agent: new FakeAgent(opts), + killed: false, + /** Test hook: simulate an unexpected child crash. */ + crash: (info?: { + exitCode: number | null; + signalCode: NodeJS.Signals | null; + }) => resolveExited!(info), + }; + // Spin up the fake agent on the agent side. + new AgentSideConnection(() => handle.agent, agentStream); + handle.channel = { + stream: clientStream, + exited, + kill: async () => { + handle.killed = true; + try { + await ab.writable.close(); + } catch { + /* ignore */ + } + try { + await ba.writable.close(); + } catch { + /* ignore */ + } + resolveExited!(); + }, + killSync: () => { + // Test fake: just mark killed; the async streams will close + // naturally on test cleanup. Mirrors the real spawn factory's + // SIGKILL semantics (fire-and-forget). + handle.killed = true; + resolveExited!(); + }, + }; + return handle; +} diff --git a/packages/acp-bridge/src/mcpTimeouts.ts b/packages/acp-bridge/src/mcpTimeouts.ts new file mode 100644 index 0000000000..c3cfcb2c81 --- /dev/null +++ b/packages/acp-bridge/src/mcpTimeouts.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Upper bound on a single MCP server (re)discovery. The MCP manager's +// per-server discovery can take up to 5 minutes +// (McpClientManager.MAX_DISCOVERY_TIMEOUT_MS). Both the bridge +// (server-side race deadline) and the SDK (client-side default) must +// agree on this value +export const MCP_RESTART_SERVER_DEADLINE_MS = 300_000; + +// Extra headroom so the client AbortSignal never fires before the +// daemon finishes serializing its success/error response. +export const MCP_RESTART_CLIENT_HEADROOM_MS = 30_000; diff --git a/packages/acp-bridge/src/permission.ts b/packages/acp-bridge/src/permission.ts index c4dc5efca0..9f78e946e7 100644 --- a/packages/acp-bridge/src/permission.ts +++ b/packages/acp-bridge/src/permission.ts @@ -7,10 +7,12 @@ /** * `PermissionMediator` — type-only interface contract for daemon * permission flow. **No implementation lives here.** Permission voting - * still runs inside `BridgeClient.requestPermission` / - * `respondToPermission` in `packages/cli/src/serve/httpAcpBridge.ts`, - * hard-coded to `first-responder`. PR 24 (#4175 Wave 5) will move that - * code behind this interface and add the other three policies. + * still runs inside `BridgeClient.requestPermission` + * (`@qwen-code/acp-bridge/bridgeClient`) and + * `respondToPermission` (inside `createHttpAcpBridge` factory closure + * at `@qwen-code/acp-bridge/bridge` after F1 step 3), hard-coded to + * `first-responder`. A future change will move that code behind this + * interface and add the other three policies. * * The four policies are ordered from cheapest to strongest: * @@ -30,8 +32,9 @@ * Use case: workstations where remote control should never grant * privilege escalation. * - * See `httpAcpBridge.ts:1096-1106` for the original FIXME that - * scoped this contract. + * See `bridgeClient.ts BridgeClient.requestPermission` for the + * current first-responder implementation; the `FIXME(stage-1.5)` + * block above that method scoped this contract. */ export type PermissionPolicy = | 'first-responder' @@ -42,8 +45,8 @@ export type PermissionPolicy = /** * One pending permission tracked by a `PermissionMediator`. The * shape mirrors the current `PendingPermission` record in - * `httpAcpBridge.ts:1003` so PR 24's lift is a structural rename - * rather than a redesign. + * `@qwen-code/acp-bridge/bridgeClient` + * so the mediation implementation's lift is a structural rename rather than a redesign. */ export interface PermissionRequestRecord { /** ACP `RequestPermission` request id, unique per session. */ @@ -79,7 +82,7 @@ export interface PermissionVote { readonly requestId: string; readonly sessionId: string; /** - * Daemon-stamped (PR 7 / #4231) — never client self-declared. + * Daemon-stamped (the daemon) — never client self-declared. * `local-only` rejects votes whose remote address is not * loopback regardless of `clientId`. */ @@ -92,6 +95,9 @@ export interface PermissionVote { /** True when the request originated on a loopback connection. * `local-only` requires this. */ readonly fromLoopback: boolean; + /** Opaque metadata forwarded from the voter's response body to + * the resolution (e.g. AskUserQuestion answers). */ + readonly metadata?: Readonly>; } /** @@ -105,17 +111,34 @@ export type PermissionVoteOutcome = | { readonly kind: 'already_resolved'; readonly resolvedOptionId: string } | { readonly kind: 'forbidden'; + /** + * `designated_mismatch` fires for both: + * - `designated` policy: voter `clientId` is not the prompt + * `originatorClientId`. + * - `consensus` policy: voter `clientId` is undefined OR not + * in the issue-time `votersAtIssue` snapshot. Overloaded + * here to keep the contract closed; future versions may + * widen this union with a more specific reason if SDK + * consumers need to distinguish. + * + * `remote_not_allowed` fires under `local-only` policy when + * `vote.fromLoopback === false`. + */ readonly reason: 'designated_mismatch' | 'remote_not_allowed'; } | { readonly kind: 'unknown_request' }; /** - * Final resolution shape. PR 24 will produce one per request once + * Final resolution shape. The implementation will produce one per request once * either a quorum is reached, the originator votes (designated), or * a timeout expires. */ export type PermissionResolution = - | { readonly kind: 'option'; readonly optionId: string } + | { + readonly kind: 'option'; + readonly optionId: string; + readonly metadata?: Readonly>; + } | { readonly kind: 'cancelled'; readonly reason: 'timeout' | 'session_closed' | 'agent_cancelled'; @@ -124,12 +147,12 @@ export type PermissionResolution = /** * The contract `qwen serve`'s permission route layer talks to. * Today there is one implementation (first-responder) wired - * inline in `BridgeClient`; PR 24 will provide all four behind + * inline in `BridgeClient`; The implementation will provide all four behind * this surface plus pair-token authentication and an audit log. */ export interface PermissionMediator { /** Active policy. May be reconfigured per session in future - * versions, but PR 24 ships with daemon-wide policy only. */ + * versions, but the current version ships with daemon-wide policy only. */ readonly policy: PermissionPolicy; /** diff --git a/packages/acp-bridge/src/permissionMediator.test.ts b/packages/acp-bridge/src/permissionMediator.test.ts new file mode 100644 index 0000000000..0d612e0f06 --- /dev/null +++ b/packages/acp-bridge/src/permissionMediator.test.ts @@ -0,0 +1,1219 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + CANCEL_VOTE_SENTINEL, + MultiClientPermissionMediator, + type MediatorDeps, + type PermissionAuditPublisher, + type PermissionDecisionReason, +} from './permissionMediator.js'; +import { + type PermissionPolicy, + type PermissionRequestRecord, + type PermissionResolution, + type PermissionVote, + type PermissionVoteOutcome, +} from './permission.js'; +import { type BridgeEvent } from './eventBus.js'; +import { + CancelSentinelCollisionError, + InvalidPermissionOptionError, +} from './bridgeErrors.js'; + +interface AuditCall { + readonly kind: 'requested' | 'voted' | 'forbidden' | 'resolved' | 'timeout'; + readonly args: readonly unknown[]; +} + +function makeRecordingAudit(): { + audit: PermissionAuditPublisher; + calls: AuditCall[]; +} { + const calls: AuditCall[] = []; + const audit: PermissionAuditPublisher = { + recordRequested(record, policy, votersAtIssue) { + calls.push({ + kind: 'requested', + args: [record, policy, votersAtIssue], + }); + }, + recordVoted(record, vote, outcome) { + calls.push({ kind: 'voted', args: [record, vote, outcome] }); + }, + recordForbidden(record, vote, reason) { + calls.push({ kind: 'forbidden', args: [record, vote, reason] }); + }, + recordResolved(record, resolution, decisionReason) { + calls.push({ + kind: 'resolved', + args: [record, resolution, decisionReason], + }); + }, + recordTimeout(record) { + calls.push({ kind: 'timeout', args: [record] }); + }, + }; + return { audit, calls }; +} + +interface EmitCall { + readonly sessionId: string; + readonly event: Omit; +} + +function makeRecordingEmit(): { + emit: MediatorDeps['emit']; + events: EmitCall[]; +} { + const events: EmitCall[] = []; + const emit: MediatorDeps['emit'] = (sessionId, event) => { + events.push({ sessionId, event }); + }; + return { emit, events }; +} + +function makeRecord( + overrides: Partial = {}, +): PermissionRequestRecord { + return { + requestId: overrides.requestId ?? 'req-1', + sessionId: overrides.sessionId ?? 'sess-1', + originatorClientId: + 'originatorClientId' in overrides + ? overrides.originatorClientId + : 'client_A', + allowedOptionIds: + overrides.allowedOptionIds ?? + new Set(['proceed_once', 'proceed_always', 'reject_once']), + issuedAtMs: overrides.issuedAtMs ?? 1_000_000, + }; +} + +function makeVote(overrides: Partial = {}): PermissionVote { + return { + requestId: overrides.requestId ?? 'req-1', + sessionId: overrides.sessionId ?? 'sess-1', + clientId: 'clientId' in overrides ? overrides.clientId : 'client_A', + optionId: overrides.optionId ?? 'proceed_once', + receivedAtMs: overrides.receivedAtMs ?? 1_000_010, + fromLoopback: overrides.fromLoopback ?? false, + }; +} + +function makeMediator( + policy: PermissionPolicy = 'first-responder', + voters: ReadonlySet = new Set(['client_A', 'client_B', 'client_C']), +) { + const { audit, calls } = makeRecordingAudit(); + const { emit, events } = makeRecordingEmit(); + const deps: MediatorDeps = { + emit, + audit, + now: () => 1_000_000, + votersForSession: () => voters, + }; + const mediator = new MultiClientPermissionMediator(policy, deps); + return { mediator, deps, audit, calls, emit, events }; +} + +describe('MultiClientPermissionMediator — first-responder', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('synchronously registers pending in `request()` (N1 invariant)', () => { + const { mediator } = makeMediator(); + const record = makeRecord(); + + // The Promise returned by request() must be already-pending; the + // pending entry must be visible to peekSessionFor BEFORE we await. + void mediator.request(record, 5_000); + + // No await between request() and peekSessionFor; the pending must + // be in the map synchronously. + expect(mediator.peekSessionFor(record.requestId)).toBe(record.sessionId); + }); + + it('resolves on first valid vote and emits permission_resolved with voter clientId as originator (O8)', async () => { + const { mediator, calls, events } = makeMediator(); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + const outcome = mediator.vote(makeVote({ clientId: 'client_B' })); + expect(outcome).toEqual({ + kind: 'resolved', + resolvedOptionId: 'proceed_once', + }); + + const resolution = await promise; + expect(resolution).toEqual({ kind: 'option', optionId: 'proceed_once' }); + + // Emitted exactly one permission_resolved event for the session. + // O8 INVARIANT: originatorClientId is the VOTER's clientId, not the + // prompt originator. This is a documented pre-F3 inconsistency + // (permission_request stamps prompt-originator; permission_resolved + // stamps voter). F3 deliberately preserves it for wire compat. + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + sessionId: 'sess-1', + event: { + type: 'permission_resolved', + data: { + requestId: 'req-1', + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + // A4: canonical voterClientId in data, same value as the + // (deprecated) envelope originatorClientId below. + voterClientId: 'client_B', + }, + originatorClientId: 'client_B', + }, + }); + + // Audit trail: requested → voted → resolved. + expect(calls.map((c) => c.kind)).toEqual([ + 'requested', + 'voted', + 'resolved', + ]); + + const resolvedCall = calls[2]!; + const decisionReason = resolvedCall.args[2] as PermissionDecisionReason; + expect(decisionReason).toEqual({ + type: 'first-responder', + resolverClientId: 'client_B', + }); + }); + + it('omits both voterClientId and originatorClientId on permission_resolved when voter has no clientId', async () => { + const { mediator, events } = makeMediator(); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + mediator.vote(makeVote({ clientId: undefined })); + await promise; + + // Loopback voter without X-Qwen-Client-Id — the spread guard omits + // both fields entirely (A4: no-voter resolutions carry neither). + expect(events).toHaveLength(1); + expect(events[0]!.event).not.toHaveProperty('originatorClientId'); + expect(events[0]!.event.data).not.toHaveProperty('voterClientId'); + }); + + it('returns already_resolved on a duplicate vote and re-emits the SSE notification', async () => { + const { mediator, events } = makeMediator(); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + mediator.vote(makeVote({ clientId: 'client_A' })); + await promise; + + // Late voter — same requestId, different clientId. + const outcome = mediator.vote( + makeVote({ clientId: 'client_C', optionId: 'proceed_always' }), + ); + expect(outcome).toEqual({ + kind: 'already_resolved', + resolvedOptionId: 'proceed_once', + }); + + // First permission_resolved + a re-emitted permission_already_resolved + // for the late voter. The replayed event does NOT carry + // `originatorClientId` — pre-F3 publishPermissionAlreadyResolved + // omitted the field and `httpAcpBridge.test.ts:2880` enshrines + // that wire shape. Resolver attribution lives in audit only. + expect(events.map((e) => e.event.type)).toEqual([ + 'permission_resolved', + 'permission_already_resolved', + ]); + const lateEvent = events[1]!; + expect(lateEvent.event.data).toEqual({ + requestId: 'req-1', + sessionId: 'sess-1', + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + }); + expect(lateEvent.event).not.toHaveProperty('originatorClientId'); + }); + + it('returns unknown_request when the requestId was never seen', () => { + const { mediator } = makeMediator(); + const outcome = mediator.vote(makeVote({ requestId: 'nonexistent' })); + expect(outcome).toEqual({ kind: 'unknown_request' }); + }); + + it('rejects cross-session votes as unknown_request', async () => { + const { mediator } = makeMediator(); + const record = makeRecord(); + void mediator.request(record, 5_000); + + const outcome = mediator.vote(makeVote({ sessionId: 'sess-other' })); + expect(outcome).toEqual({ kind: 'unknown_request' }); + }); + + it('throws InvalidPermissionOptionError when optionId is not in the allow set', () => { + const { mediator } = makeMediator(); + const record = makeRecord(); + void mediator.request(record, 5_000); + + expect(() => + mediator.vote(makeVote({ optionId: 'proceed_always_forged' })), + ).toThrow(InvalidPermissionOptionError); + }); +}); + +describe('MultiClientPermissionMediator — voter cancel sentinel', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves cancelled on cancel sentinel regardless of policy', async () => { + for (const policy of [ + 'first-responder', + 'designated', + 'consensus', + 'local-only', + ] as const satisfies readonly PermissionPolicy[]) { + const { mediator, events, calls } = makeMediator(policy); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + const outcome = mediator.vote( + makeVote({ optionId: CANCEL_VOTE_SENTINEL }), + ); + expect(outcome).toEqual({ + kind: 'resolved', + resolvedOptionId: CANCEL_VOTE_SENTINEL, + }); + + const resolution = await promise; + expect(resolution).toEqual({ + kind: 'cancelled', + reason: 'agent_cancelled', + }); + + expect(events.map((e) => e.event.type)).toEqual(['permission_resolved']); + expect(events[0]!.event.data).toMatchObject({ + outcome: { outcome: 'cancelled' }, + }); + expect(events[0]!.event.originatorClientId).toBe('client_A'); + + const decisionReason = calls.find((c) => c.kind === 'resolved')! + .args[2] as PermissionDecisionReason; + expect(decisionReason).toEqual({ + type: 'voter-cancelled', + resolverClientId: 'client_A', + }); + } + }); + + it('does NOT validate cancel sentinel against allowedOptionIds', () => { + // The bridge constructs the sentinel from `{outcome:'cancelled'}` which + // never carries an optionId; the mediator must accept it without + // checking the allow set. + const { mediator } = makeMediator(); + const record = makeRecord({ + allowedOptionIds: new Set(['proceed_once']), + }); + void mediator.request(record, 5_000); + + expect(() => + mediator.vote(makeVote({ optionId: CANCEL_VOTE_SENTINEL })), + ).not.toThrow(); + }); + + // Wenshao review #4335 / 3271978359 — the existing + // `resolves cancelled on cancel sentinel regardless of policy` + // test uses a voter (`client_A`) that would be ACCEPTED by every + // policy: it's the prompt originator under designated and is in + // votersAtIssue under consensus. The cross-policy guarantee only + // matters for voters who would otherwise be REJECTED — these two + // adversarial cases lock in the cross-policy escape hatch + // semantics described on the CANCEL_VOTE_SENTINEL JSDoc. + it('cancel sentinel resolves under `designated` even when voter is NOT the originator', async () => { + const { mediator, events } = makeMediator('designated'); + const record = makeRecord(); // originator = 'client_A' + const promise = mediator.request(record, 5_000); + + // A normal `proceed_once` vote from client_B would be + // forbidden:designated_mismatch — but cancel must still resolve. + const outcome = mediator.vote( + makeVote({ clientId: 'client_B', optionId: CANCEL_VOTE_SENTINEL }), + ); + expect(outcome).toEqual({ + kind: 'resolved', + resolvedOptionId: CANCEL_VOTE_SENTINEL, + }); + const resolution = await promise; + expect(resolution).toEqual({ + kind: 'cancelled', + reason: 'agent_cancelled', + }); + expect(events.map((e) => e.event.type)).toEqual(['permission_resolved']); + }); + + it('cancel sentinel resolves under `consensus` even when voter is NOT in votersAtIssue', async () => { + const { mediator, events } = makeMediator( + 'consensus', + // votersAtIssue snapshot does NOT contain client_late_join + new Set(['client_A', 'client_B']), + ); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + // A normal `proceed_once` vote from a non-snapshot voter would + // be forbidden:designated_mismatch — but cancel must still resolve. + const outcome = mediator.vote( + makeVote({ + clientId: 'client_late_join', + optionId: CANCEL_VOTE_SENTINEL, + }), + ); + expect(outcome).toEqual({ + kind: 'resolved', + resolvedOptionId: CANCEL_VOTE_SENTINEL, + }); + const resolution = await promise; + expect(resolution).toEqual({ + kind: 'cancelled', + reason: 'agent_cancelled', + }); + expect(events.map((e) => e.event.type)).toEqual(['permission_resolved']); + }); + + it('rejects request() at issue time when allowedOptionIds collides with cancel sentinel', () => { + // Collision defense (Commit 1 review I1): if the agent's allow + // set legitimately contains '__cancelled__', the mediator can no + // longer disambiguate a real vote on that option from a cancel + // intent. Fail loud at request() rather than silently flipping + // a real approval to cancel later. + const { mediator } = makeMediator(); + const record = makeRecord({ + allowedOptionIds: new Set(['proceed_once', CANCEL_VOTE_SENTINEL]), + }); + expect(() => mediator.request(record, 5_000)).toThrow( + CancelSentinelCollisionError, + ); + + // The mediator state must remain clean after the throw — no + // pending entry leaked. + expect(mediator.peekSessionFor('req-1')).toBeUndefined(); + }); +}); + +describe('MultiClientPermissionMediator — forgetSession', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('cancels every pending request matching the session', async () => { + const { mediator, events } = makeMediator(); + const recordA = makeRecord({ requestId: 'req-A', sessionId: 'sess-1' }); + const recordB = makeRecord({ requestId: 'req-B', sessionId: 'sess-1' }); + const recordOther = makeRecord({ + requestId: 'req-C', + sessionId: 'sess-2', + }); + const promiseA = mediator.request(recordA, 5_000); + const promiseB = mediator.request(recordB, 5_000); + const promiseOther = mediator.request(recordOther, 5_000); + + mediator.forgetSession('sess-1'); + + const [resA, resB] = await Promise.all([promiseA, promiseB]); + expect(resA).toEqual({ kind: 'cancelled', reason: 'session_closed' }); + expect(resB).toEqual({ kind: 'cancelled', reason: 'session_closed' }); + + // The other session's pending stays alive. + expect(mediator.peekSessionFor('req-C')).toBe('sess-2'); + + // Two permission_resolved emits, both for sess-1. + const sess1Events = events.filter((e) => e.sessionId === 'sess-1'); + expect(sess1Events).toHaveLength(2); + expect(sess1Events.map((e) => e.event.type)).toEqual([ + 'permission_resolved', + 'permission_resolved', + ]); + + // Resolve the third so promise doesn't dangle. + mediator.vote( + makeVote({ + requestId: 'req-C', + sessionId: 'sess-2', + optionId: 'proceed_once', + }), + ); + await promiseOther; + }); + + it('is idempotent — second call is a no-op', () => { + const { mediator, events } = makeMediator(); + const record = makeRecord(); + void mediator.request(record, 5_000); + + mediator.forgetSession('sess-1'); + const eventsAfterFirst = events.length; + + mediator.forgetSession('sess-1'); + expect(events.length).toBe(eventsAfterFirst); + }); + + it('does not affect resolved entries (already-decided permissions stay queryable)', async () => { + const { mediator } = makeMediator(); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + mediator.vote(makeVote()); + await promise; + + mediator.forgetSession('sess-1'); + + // peekSessionFor still works for the resolved record (legacy + // bridge.respondToPermission relies on this for the + // permission_already_resolved fallback). + expect(mediator.peekSessionFor('req-1')).toBe('sess-1'); + }); +}); + +describe('MultiClientPermissionMediator — timeout', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves cancelled when the timer fires before any vote', async () => { + const { mediator, events, calls } = makeMediator(); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + vi.advanceTimersByTime(5_000); + + const resolution = await promise; + expect(resolution).toEqual({ kind: 'cancelled', reason: 'timeout' }); + + // Timer-driven resolution has no voter — `permission_resolved` + // must omit `originatorClientId` rather than spread `undefined`. + expect(events).toHaveLength(1); + expect(events[0]!.event.type).toBe('permission_resolved'); + expect(events[0]!.event).not.toHaveProperty('originatorClientId'); + + expect(calls.map((c) => c.kind)).toEqual([ + 'requested', + 'timeout', + 'resolved', + ]); + + const resolvedCall = calls[2]!; + const decisionReason = resolvedCall.args[2] as PermissionDecisionReason; + expect(decisionReason).toMatchObject({ + type: 'timeout', + issuedAtMs: 1_000_000, + timeoutMs: 5_000, + }); + expect((decisionReason as { firedAtMs: number }).firedAtMs).toBe(1_000_000); + }); + + it('clears the timer when the entry resolves via vote', async () => { + const { mediator, calls } = makeMediator(); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + mediator.vote(makeVote()); + await promise; + + // Fast-forward — the cleared timer must NOT fire. + vi.advanceTimersByTime(10_000); + + expect(calls.some((c) => c.kind === 'timeout')).toBe(false); + }); + + // Wenshao review #4335 / 3270622304 — pre-F3 wrote a stderr line on + // every permission timeout; F3's mediator timer must preserve that + // breadcrumb so operators tailing daemon stderr still see timeouts + // even when the audit publisher is the no-op fallback (embedded + // callers / unit tests). + it('writes a stderr breadcrumb when the timer fires', async () => { + const writes: string[] = []; + const writeSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: string | Uint8Array): boolean => { + writes.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return true; + }); + try { + const { mediator } = makeMediator(); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + vi.advanceTimersByTime(5_000); + await promise; + + const breadcrumb = writes.find((w) => + w.includes('timed out after 5000ms'), + ); + expect(breadcrumb).toBeDefined(); + expect(breadcrumb).toContain('req-1'); + expect(breadcrumb).toContain('sess-1'); + } finally { + writeSpy.mockRestore(); + } + }); +}); + +describe('MultiClientPermissionMediator — peekSessionFor', () => { + it('returns undefined for unknown requestIds', () => { + const { mediator } = makeMediator(); + expect(mediator.peekSessionFor('never-seen')).toBeUndefined(); + }); + + it('returns sessionId for pending and resolved alike', async () => { + const { mediator } = makeMediator(); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + expect(mediator.peekSessionFor('req-1')).toBe('sess-1'); + + mediator.vote(makeVote()); + await promise; + expect(mediator.peekSessionFor('req-1')).toBe('sess-1'); + }); +}); + +describe('MultiClientPermissionMediator — designated', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves when the originator votes', async () => { + const { mediator, calls } = makeMediator('designated'); + const record = makeRecord({ originatorClientId: 'client_A' }); + const promise = mediator.request(record, 5_000); + const outcome = mediator.vote(makeVote({ clientId: 'client_A' })); + expect(outcome).toEqual({ + kind: 'resolved', + resolvedOptionId: 'proceed_once', + }); + await promise; + const decisionReason = calls.find((c) => c.kind === 'resolved')! + .args[2] as PermissionDecisionReason; + expect(decisionReason).toEqual({ + type: 'designated-originator', + originatorClientId: 'client_A', + }); + }); + + it('rejects votes from non-originators with permission_forbidden', async () => { + const { mediator, events, calls } = makeMediator('designated'); + const record = makeRecord({ originatorClientId: 'client_A' }); + const promise = mediator.request(record, 5_000); + const outcome = mediator.vote(makeVote({ clientId: 'client_B' })); + expect(outcome).toEqual({ + kind: 'forbidden', + reason: 'designated_mismatch', + }); + expect(events.map((e) => e.event.type)).toEqual(['permission_forbidden']); + expect(events[0]!.event.data).toEqual({ + requestId: 'req-1', + sessionId: 'sess-1', + clientId: 'client_B', + reason: 'designated_mismatch', + }); + expect(events[0]!.event.originatorClientId).toBe('client_A'); + expect(calls.find((c) => c.kind === 'forbidden')).toBeDefined(); + // The pending must still be alive after a forbidden vote. + expect(mediator.peekSessionFor('req-1')).toBe('sess-1'); + mediator.forgetSession('sess-1'); + await promise; + }); + + it('falls back to first-responder when prompt has no originator (anonymous)', async () => { + const { mediator, calls } = makeMediator('designated'); + const record = makeRecord({ originatorClientId: undefined }); + const promise = mediator.request(record, 5_000); + const outcome = mediator.vote(makeVote({ clientId: 'client_C' })); + expect(outcome).toEqual({ + kind: 'resolved', + resolvedOptionId: 'proceed_once', + }); + await promise; + const decisionReason = calls.find((c) => c.kind === 'resolved')! + .args[2] as PermissionDecisionReason; + expect(decisionReason).toEqual({ + type: 'first-responder', + resolverClientId: 'client_C', + }); + }); +}); + +describe('MultiClientPermissionMediator — consensus', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves on first option to reach quorum (M=3, default N=2)', async () => { + const { mediator, events, calls } = makeMediator( + 'consensus', + new Set(['client_A', 'client_B', 'client_C']), + ); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + const v1 = mediator.vote(makeVote({ clientId: 'client_A' })); + expect(v1).toEqual({ kind: 'recorded', votesNeeded: 1 }); + expect(events.map((e) => e.event.type)).toEqual([ + 'permission_partial_vote', + ]); + expect(events[0]!.event.data).toEqual({ + requestId: 'req-1', + sessionId: 'sess-1', + votesReceived: 1, + votesNeeded: 1, + quorum: 2, + optionTallies: { proceed_once: 1 }, + }); + + const v2 = mediator.vote(makeVote({ clientId: 'client_B' })); + expect(v2).toEqual({ + kind: 'resolved', + resolvedOptionId: 'proceed_once', + }); + await promise; + + expect(events.map((e) => e.event.type)).toEqual([ + 'permission_partial_vote', + 'permission_resolved', + ]); + + const decisionReason = calls.find((c) => c.kind === 'resolved')! + .args[2] as PermissionDecisionReason; + expect(decisionReason).toEqual({ + type: 'consensus-quorum', + resolvedOptionId: 'proceed_once', + quorum: 2, + tally: 2, + }); + }); + + it('keeps the original vote on idempotent re-vote (no tally change, no partial_vote re-emit)', async () => { + const { mediator, events } = makeMediator( + 'consensus', + new Set(['client_A', 'client_B', 'client_C']), + ); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + mediator.vote(makeVote({ clientId: 'client_A', optionId: 'proceed_once' })); + expect(events).toHaveLength(1); + + const v2 = mediator.vote( + makeVote({ clientId: 'client_A', optionId: 'proceed_always' }), + ); + expect(v2).toEqual({ kind: 'recorded', votesNeeded: 1 }); + expect(events).toHaveLength(1); + + mediator.vote(makeVote({ clientId: 'client_B', optionId: 'proceed_once' })); + await promise; + }); + + // Wenshao review #4335 / 3271041464 — when a voter's idempotent + // re-vote attempts a different optionId, the audit ring must + // record the ORIGINALLY-voted option (the one in the tally), not + // the new attempt. Otherwise an operator reading the audit trail + // sees `client_A voted for option_B` while the tally has client_A + // in option_A's bucket — a misleading record of a vote that + // never counted. + it('records the original optionId in audit on idempotent re-vote (3271041464)', async () => { + const { mediator, calls } = makeMediator( + 'consensus', + new Set(['client_A', 'client_B', 'client_C']), + ); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + // Original vote: client_A → proceed_once. + mediator.vote(makeVote({ clientId: 'client_A', optionId: 'proceed_once' })); + + // Re-vote attempt: client_A → proceed_always (silently kept as + // proceed_once in the tally; SHOULD be audited as proceed_once). + mediator.vote( + makeVote({ clientId: 'client_A', optionId: 'proceed_always' }), + ); + + // Resolve to terminate the test cleanly. + mediator.vote(makeVote({ clientId: 'client_B', optionId: 'proceed_once' })); + await promise; + + // Two `voted` audit calls fired (one per vote attempt). The + // first records the original option as cast; the second records + // the original option even though the wire attempt was different. + const votedCalls = calls.filter((c) => c.kind === 'voted'); + expect(votedCalls).toHaveLength(3); // client_A original, client_A re-vote, client_B winning vote + // First call — straightforward: client_A cast proceed_once. + expect((votedCalls[0]!.args[1] as { optionId: string }).optionId).toBe( + 'proceed_once', + ); + // Second call — the idempotent re-vote case: the audit must show + // proceed_once (the option in the tally), NOT proceed_always + // (the attempted re-vote). This is the regression-guard the + // pre-fix code violated. + expect((votedCalls[1]!.args[1] as { optionId: string }).optionId).toBe( + 'proceed_once', + ); + }); + + it('rejects anonymous voter with permission_forbidden', () => { + const { mediator, events } = makeMediator( + 'consensus', + new Set(['client_A', 'client_B', 'client_C']), + ); + const record = makeRecord(); + void mediator.request(record, 5_000); + + const v = mediator.vote(makeVote({ clientId: undefined })); + expect(v).toEqual({ kind: 'forbidden', reason: 'designated_mismatch' }); + expect(events.map((e) => e.event.type)).toEqual(['permission_forbidden']); + // I-4 (Commit 4 review) — N3 invariant: forbidden event stamps + // the prompt originator, not the rejected voter. + expect(events[0]!.event.originatorClientId).toBe('client_A'); + // Anonymous voter — `clientId` MUST NOT appear on the data + // object (no field rather than `clientId: undefined`). + expect(events[0]!.event.data).not.toHaveProperty('clientId'); + mediator.forgetSession('sess-1'); + }); + + it('rejects voter not in votersAtIssue snapshot', () => { + const { mediator, events } = makeMediator( + 'consensus', + new Set(['client_A', 'client_B']), + ); + const record = makeRecord(); + void mediator.request(record, 5_000); + + const v = mediator.vote(makeVote({ clientId: 'client_late_join' })); + expect(v).toEqual({ kind: 'forbidden', reason: 'designated_mismatch' }); + expect(events.map((e) => e.event.type)).toEqual(['permission_forbidden']); + // I-4 (Commit 4 review) — prompt originator on N3 forbidden event. + expect(events[0]!.event.originatorClientId).toBe('client_A'); + expect(events[0]!.event.data).toMatchObject({ + clientId: 'client_late_join', + reason: 'designated_mismatch', + }); + mediator.forgetSession('sess-1'); + }); + + // Wenshao review #4335 / 3272568031 — `writeForbiddenStderr` has 3 + // call sites (voteDesignated / voteConsensus / voteLocalOnly) but + // before this commit only the SSE event + audit record were tested. + // Pin the stderr breadcrumb format and presence so a refactor can't + // silently drop it. + it('writes stderr breadcrumbs for all 3 forbidden-vote paths', () => { + const writes: string[] = []; + const writeSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: string | Uint8Array): boolean => { + writes.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return true; + }); + try { + // 1. designated — non-originator voter rejected. + { + const { mediator } = makeMediator('designated'); + void mediator.request(makeRecord(), 5_000); + mediator.vote(makeVote({ clientId: 'client_B' })); + mediator.forgetSession('sess-1'); + } + // 2. consensus — voter not in votersAtIssue rejected. + { + const { mediator } = makeMediator( + 'consensus', + new Set(['client_A', 'client_B']), + ); + void mediator.request(makeRecord(), 5_000); + mediator.vote(makeVote({ clientId: 'client_late_join' })); + mediator.forgetSession('sess-1'); + } + // 3. local-only — non-loopback voter rejected. + { + const { mediator } = makeMediator('local-only'); + void mediator.request(makeRecord(), 5_000); + mediator.vote( + makeVote({ clientId: 'client_remote', fromLoopback: false }), + ); + mediator.forgetSession('sess-1'); + } + + const breadcrumbs = writes.filter((w) => w.includes('vote rejected')); + expect(breadcrumbs).toHaveLength(3); + expect(breadcrumbs[0]).toContain('designated_mismatch'); + expect(breadcrumbs[0]).toContain('voter is not the prompt originator'); + expect(breadcrumbs[1]).toContain('designated_mismatch'); + expect(breadcrumbs[1]).toContain('not in consensus votersAtIssue'); + expect(breadcrumbs[2]).toContain('remote_not_allowed'); + expect(breadcrumbs[2]).toContain('local-only policy'); + // Each breadcrumb names the requestId + sessionId for grep-ability. + for (const b of breadcrumbs) { + expect(b).toContain('req-1'); + expect(b).toContain('sess-1'); + } + } finally { + writeSpy.mockRestore(); + } + }); + + it('M=4 N=3 split 2-2 never resolves and times out', async () => { + // I-5 (Commit 4 review) — explicitly cover the + // "no winner; only cancel via timeout / forgetSession" case + // that the M=3 N=2 property test cannot reach. + const { mediator } = makeMediator( + 'consensus', + new Set(['client_A', 'client_B', 'client_C', 'client_D']), + ); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + mediator.vote(makeVote({ clientId: 'client_A', optionId: 'proceed_once' })); + mediator.vote(makeVote({ clientId: 'client_B', optionId: 'proceed_once' })); + mediator.vote( + makeVote({ clientId: 'client_C', optionId: 'proceed_always' }), + ); + const v4 = mediator.vote( + makeVote({ clientId: 'client_D', optionId: 'proceed_always' }), + ); + // Quorum N = floor(4/2)+1 = 3. Top tally is 2/2 split. No winner. + expect(v4).toEqual({ kind: 'recorded', votesNeeded: 1 }); + + // Timeout fires → cancelled. + vi.advanceTimersByTime(5_000); + const resolution = await promise; + expect(resolution).toEqual({ kind: 'cancelled', reason: 'timeout' }); + }); + + it('honors consensusQuorum override capped at M', async () => { + const { audit } = makeRecordingAudit(); + const { emit } = makeRecordingEmit(); + const deps: MediatorDeps = { + emit, + audit, + consensusQuorum: 100, + now: () => 1_000_000, + votersForSession: () => new Set(['client_A', 'client_B', 'client_C']), + }; + const mediator = new MultiClientPermissionMediator('consensus', deps); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + mediator.vote(makeVote({ clientId: 'client_A' })); + mediator.vote(makeVote({ clientId: 'client_B' })); + const v3 = mediator.vote(makeVote({ clientId: 'client_C' })); + expect(v3).toEqual({ kind: 'resolved', resolvedOptionId: 'proceed_once' }); + await promise; + }); + + it('property-style: enumerate vote interleavings for M=3 N=2 — first option to N wins', async () => { + const voters = ['client_A', 'client_B', 'client_C']; + const options: ReadonlyArray<'option_yes' | 'option_no'> = [ + 'option_yes', + 'option_no', + ]; + for (let assignmentMask = 0; assignmentMask < 8; assignmentMask++) { + const assignments = voters.map((_, idx) => + ((assignmentMask >> idx) & 1) === 1 ? options[0] : options[1], + ); + const orderings: Array<[number, number, number]> = [ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [2, 0, 1], + [2, 1, 0], + ]; + for (const order of orderings) { + const { mediator } = makeMediator('consensus', new Set(voters)); + const record = makeRecord({ + requestId: `req-prop-${assignmentMask}-${order.join('')}`, + allowedOptionIds: new Set(options), + }); + const promise = mediator.request(record, 5_000); + + let referenceWinner: string | null = null; + const refTally = new Map>(); + const recordedOutcomes: PermissionVoteOutcome[] = []; + for (const idx of order) { + const voter = voters[idx]!; + const option = assignments[idx]!; + if (referenceWinner === null) { + let set = refTally.get(option); + if (!set) { + set = new Set(); + refTally.set(option, set); + } + set.add(voter); + if (set.size >= 2) referenceWinner = option; + } + const outcome = mediator.vote({ + requestId: record.requestId, + sessionId: record.sessionId, + clientId: voter, + optionId: option, + receivedAtMs: 0, + fromLoopback: false, + }); + recordedOutcomes.push(outcome); + if (outcome.kind === 'resolved') break; + } + + const mediatorWinner = recordedOutcomes.find( + (o) => o.kind === 'resolved', + ) as { kind: 'resolved'; resolvedOptionId: string } | undefined; + if (referenceWinner !== null) { + expect(mediatorWinner).toBeDefined(); + expect(mediatorWinner!.resolvedOptionId).toBe(referenceWinner); + await promise; + } else { + mediator.forgetSession(record.sessionId); + await promise; + } + } + } + }); + + it('emits permission_partial_vote BEFORE permission_resolved (ordering invariant)', async () => { + const { mediator, events } = makeMediator( + 'consensus', + new Set(['client_A', 'client_B', 'client_C']), + ); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + mediator.vote(makeVote({ clientId: 'client_A' })); + mediator.vote(makeVote({ clientId: 'client_B' })); + await promise; + const types = events.map((e) => e.event.type); + const partialIdx = types.indexOf('permission_partial_vote'); + const resolvedIdx = types.indexOf('permission_resolved'); + expect(partialIdx).toBeGreaterThanOrEqual(0); + expect(resolvedIdx).toBeGreaterThan(partialIdx); + }); +}); + +describe('MultiClientPermissionMediator — local-only', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves on a loopback vote', async () => { + const { mediator, calls } = makeMediator('local-only'); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + const outcome = mediator.vote(makeVote({ fromLoopback: true })); + expect(outcome).toEqual({ + kind: 'resolved', + resolvedOptionId: 'proceed_once', + }); + await promise; + const decisionReason = calls.find((c) => c.kind === 'resolved')! + .args[2] as PermissionDecisionReason; + expect(decisionReason).toEqual({ + type: 'local-only-loopback', + resolverClientId: 'client_A', + }); + }); + + it('rejects a non-loopback vote with permission_forbidden / remote_not_allowed', async () => { + // Use a distinct prompt originator from the voter so the N3 + // stamping invariant is observable (I-4 Commit 4 review). + const { mediator, events, calls } = makeMediator('local-only'); + const record = makeRecord({ originatorClientId: 'client_PROMPT' }); + const promise = mediator.request(record, 5_000); + const outcome = mediator.vote(makeVote({ fromLoopback: false })); + expect(outcome).toEqual({ + kind: 'forbidden', + reason: 'remote_not_allowed', + }); + expect(events.map((e) => e.event.type)).toEqual(['permission_forbidden']); + expect(events[0]!.event.data).toEqual({ + requestId: 'req-1', + sessionId: 'sess-1', + clientId: 'client_A', + reason: 'remote_not_allowed', + }); + // I-4 (Commit 4 review) — N3 invariant: forbidden event stamps + // the prompt originator (`client_PROMPT`), NOT the rejected + // voter (`client_A`). + expect(events[0]!.event.originatorClientId).toBe('client_PROMPT'); + expect(calls.find((c) => c.kind === 'forbidden')).toBeDefined(); + mediator.forgetSession('sess-1'); + await promise; + }); +}); + +describe('MultiClientPermissionMediator — N2 cleanup ordering', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves the Promise even when emit throws', async () => { + const { audit } = makeRecordingAudit(); + const emit = vi.fn(() => { + throw new Error('bus closed during shutdown'); + }); + const deps: MediatorDeps = { + emit, + audit, + now: () => 0, + votersForSession: () => new Set(['client_A']), + }; + const mediator = new MultiClientPermissionMediator('first-responder', deps); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + mediator.vote(makeVote()); + + const resolution: PermissionResolution = await promise; + expect(resolution).toEqual({ kind: 'option', optionId: 'proceed_once' }); + + // Pending must have been deleted despite emit throwing. + expect(mediator.peekSessionFor('req-1')).toBe('sess-1'); + const dupOutcome: PermissionVoteOutcome = mediator.vote(makeVote()); + expect(dupOutcome.kind).toBe('already_resolved'); + }); + + it('resolves the Promise even when audit throws on recordRequested + recordResolved', async () => { + const audit: PermissionAuditPublisher = { + recordRequested: vi.fn(() => { + throw new Error('audit ring full'); + }), + recordVoted: vi.fn(), + recordForbidden: vi.fn(), + recordResolved: vi.fn(() => { + throw new Error('audit ring full'); + }), + recordTimeout: vi.fn(), + }; + const { emit } = makeRecordingEmit(); + const deps: MediatorDeps = { + emit, + audit, + now: () => 0, + votersForSession: () => new Set(['client_A']), + }; + const mediator = new MultiClientPermissionMediator('first-responder', deps); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + mediator.vote(makeVote()); + const resolution = await promise; + expect(resolution).toEqual({ kind: 'option', optionId: 'proceed_once' }); + }); + + it('resolves the Promise even when audit.recordVoted throws (vote path)', async () => { + const audit: PermissionAuditPublisher = { + recordRequested: vi.fn(), + recordVoted: vi.fn(() => { + throw new Error('audit publisher transient error'); + }), + recordForbidden: vi.fn(), + recordResolved: vi.fn(), + recordTimeout: vi.fn(), + }; + const { emit } = makeRecordingEmit(); + const deps: MediatorDeps = { + emit, + audit, + now: () => 0, + votersForSession: () => new Set(['client_A']), + }; + const mediator = new MultiClientPermissionMediator('first-responder', deps); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + // Pre-fix bug: recordVoted threw before resolveEntry, leaving the + // Promise hung. With safeAudit wrapping, vote() must still resolve. + expect(() => mediator.vote(makeVote())).not.toThrow(); + const resolution = await promise; + expect(resolution).toEqual({ kind: 'option', optionId: 'proceed_once' }); + }); + + it('resolves the Promise even when audit.recordVoted throws (cancel sentinel path)', async () => { + const audit: PermissionAuditPublisher = { + recordRequested: vi.fn(), + recordVoted: vi.fn(() => { + throw new Error('audit publisher transient error'); + }), + recordForbidden: vi.fn(), + recordResolved: vi.fn(), + recordTimeout: vi.fn(), + }; + const { emit } = makeRecordingEmit(); + const deps: MediatorDeps = { + emit, + audit, + now: () => 0, + votersForSession: () => new Set(['client_A']), + }; + const mediator = new MultiClientPermissionMediator('first-responder', deps); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + expect(() => + mediator.vote(makeVote({ optionId: CANCEL_VOTE_SENTINEL })), + ).not.toThrow(); + const resolution = await promise; + expect(resolution).toEqual({ + kind: 'cancelled', + reason: 'agent_cancelled', + }); + }); + + it('resolves the Promise even when audit.recordTimeout throws (timeout path)', async () => { + const audit: PermissionAuditPublisher = { + recordRequested: vi.fn(), + recordVoted: vi.fn(), + recordForbidden: vi.fn(), + recordResolved: vi.fn(), + recordTimeout: vi.fn(() => { + throw new Error('audit publisher transient error'); + }), + }; + const { emit } = makeRecordingEmit(); + const deps: MediatorDeps = { + emit, + audit, + now: () => 9_999, + votersForSession: () => new Set(['client_A']), + }; + const mediator = new MultiClientPermissionMediator('first-responder', deps); + const record = makeRecord(); + const promise = mediator.request(record, 5_000); + + // Pre-fix bug: recordTimeout was naked inside the timer callback; + // a throw left the Promise hung permanently and the pending entry + // leaked. With safeAudit wrapping, the timeout still resolves. + vi.advanceTimersByTime(5_000); + const resolution = await promise; + expect(resolution).toEqual({ kind: 'cancelled', reason: 'timeout' }); + expect(mediator.peekSessionFor('req-1')).toBe('sess-1'); + }); +}); diff --git a/packages/acp-bridge/src/permissionMediator.ts b/packages/acp-bridge/src/permissionMediator.ts new file mode 100644 index 0000000000..7757516321 --- /dev/null +++ b/packages/acp-bridge/src/permissionMediator.ts @@ -0,0 +1,1198 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * `MultiClientPermissionMediator` — implementation of the + * `PermissionMediator` contract from `./permission.ts`. + * + * Owns ALL pending and resolved permission state for the bridge. + * `httpAcpBridge.ts` no longer keeps `pendingPermissions: Map` or + * `resolvedPermissions: LRU` — those are inside this class. + * + * Strategy dispatch: a single class with `switch (entry.policy)` inside + * `vote()`. Per-policy logic stays small (5–15 lines each); strategy + * sub-classes would be more boilerplate than substance. + * + * + */ + +import { + type PermissionMediator, + type PermissionPolicy, + type PermissionRequestRecord, + type PermissionResolution, + type PermissionVote, + type PermissionVoteOutcome, +} from './permission.js'; +import { type BridgeEvent } from './eventBus.js'; +import { + CancelSentinelCollisionError, + InvalidPermissionOptionError, +} from './bridgeErrors.js'; + +/** + * Sentinel `optionId` value the bridge maps voter `{outcome:'cancelled'}` + * to before calling `mediator.vote`. The mediator recognizes this and + * resolves the pending as `{kind:'cancelled', reason:'agent_cancelled'}` + * regardless of the active policy. + * + * **Bridge-side precondition**: callers MUST NOT forward an incoming + * `vote.optionId === CANCEL_VOTE_SENTINEL` from a wire client — the + * mediator treats the sentinel as cancel intent without consulting the + * `allowedOptionIds` set, so wire-originated sentinel votes would + * silently flip a real approval into a cancel. The bridge constructs + * the sentinel only from a `{outcome:'cancelled'}` ACP body that + * carries no `optionId` of its own. + * + * **Cross-policy escape hatch (intentional)**: cancel routes BEFORE + * policy dispatch. A non-loopback voter under `local-only` and a + * not-in-voter-set client under `consensus` can both still resolve the + * pending as cancelled by posting `{outcome:'cancelled'}`. This is + * deliberate — voter-cancel is the agent-side abort path; if the + * threat model required policy-gated cancel, that would be a future + * contract change. Documented here so a future maintainer doesn't + * "fix" the bypass. + * + * **Collision defense**: `mediator.request` rejects records whose + * `allowedOptionIds` contains the sentinel by throwing + * `CancelSentinelCollisionError` so an agent legitimately publishing + * `'__cancelled__'` as an option label can't masquerade as cancel. + */ +export const CANCEL_VOTE_SENTINEL = '__cancelled__' as const; + +/** + * Bounded FIFO size for the `resolved` map (duplicate-vote dedup + + * `permission_already_resolved` source). + * The eviction in `rememberResolved` uses + * `resolvedOrder.shift()` (drop oldest), not LRU; mirrors the FIFO + * `PermissionAuditRing` correction. Mirrors the + * `MAX_RESOLVED_PERMISSION_RECORDS` constant from the previous inline + * implementation in `httpAcpBridge.ts` (512 entries). Stores only + * requestId / sessionId / outcome, so 512 records stays well under + * 100 KB across normal UI reconnect/race windows. + */ +const MAX_RESOLVED_PERMISSION_RECORDS = 512; + +/** + * Structured "why did this resolve like that?" record attached to + * audit `permission.resolved` events. Borrowed from claude-code's + * `PermissionDecisionReason`. + * + * **Wire-vs-audit overload note**: `'agent-cancelled'` and + * `'voter-cancelled'` both project to the same wire shape + * (`PermissionResolution { kind:'cancelled', reason:'agent_cancelled' }`) + * because the ACP protocol doesn't distinguish them. The discrimination + * lives only in the audit log — useful for forensics, invisible on the + * bus. Deliberately preserves this overload to avoid breaking the + * frozen `permission.ts` contract. + * + * `resolverClientId: string | undefined` on `'first-responder'`, + * `'local-only-loopback'`, and `'voter-cancelled'` is undefined when + * the resolving voter connected over loopback without a registered + * `X-Qwen-Client-Id` header — a legitimate path for the local TUI + * default flow. The field is required-but-nullable rather than + * optional to force callers to think about the loopback case. + */ +export type PermissionDecisionReason = + | { + readonly type: 'first-responder'; + readonly resolverClientId: string | undefined; + } + | { + readonly type: 'designated-originator'; + readonly originatorClientId: string; + } + | { + readonly type: 'consensus-quorum'; + readonly resolvedOptionId: string; + readonly quorum: number; + readonly tally: number; + } + | { + readonly type: 'local-only-loopback'; + readonly resolverClientId: string | undefined; + } + | { + readonly type: 'timeout'; + readonly issuedAtMs: number; + readonly timeoutMs: number; + /** `deps.now()` at timer fire — distinct from `issuedAtMs + + * timeoutMs` under load (timer queue scheduling delay). */ + readonly firedAtMs: number; + } + | { readonly type: 'session-closed' } + /** Agent cancelled the underlying prompt before any voter resolved + * the permission. Wire shape collides with `'voter-cancelled'`. */ + | { readonly type: 'agent-cancelled' } + /** A voter posted `{outcome:'cancelled'}`. Wire shape collides with + * `'agent-cancelled'`. */ + | { + readonly type: 'voter-cancelled'; + readonly resolverClientId: string | undefined; + }; + +/** + * Audit sink the mediator writes to. Implementation lives in + * `packages/cli/src/serve/permissionAudit.ts` and writes into an + * in-memory bounded ring on the bridge — NOT onto the SSE bus + * (audit records and SSE wire events are intentionally separate + * channels by design). + * + * The mediator depends only on this interface, so unit tests can + * substitute a no-op or a recording stub without dragging the host + * package's audit ring in. + */ +export interface PermissionAuditPublisher { + recordRequested( + record: PermissionRequestRecord, + policy: PermissionPolicy, + votersAtIssue: ReadonlySet, + ): void; + recordVoted( + record: PermissionRequestRecord, + vote: PermissionVote, + outcome: PermissionVoteOutcome, + ): void; + recordForbidden( + record: PermissionRequestRecord, + vote: PermissionVote, + reason: 'designated_mismatch' | 'remote_not_allowed', + ): void; + recordResolved( + record: PermissionRequestRecord, + resolution: PermissionResolution, + decisionReason: PermissionDecisionReason, + ): void; + recordTimeout(record: PermissionRequestRecord): void; +} + +/** + * Best-effort string-form of an unknown error value for breadcrumb + * lines written to `process.stderr`. Avoids the failure modes of + * blindly calling `String(err)` on a Symbol or `JSON.stringify` on + * a circular object. Whole body is try/catch'd: a pathological + * `Error` subclass with throwing `.name` / `.message` accessors + * (e.g. `Proxy`-wrapped errors, getter-overriding subclasses) MUST + * NOT escape from `safeAudit` / `safeEmit` and break the + * never-blocks-Promise-settle invariant. + */ +function stringifyError(err: unknown): string { + try { + if (err instanceof Error) return `${err.name}: ${err.message}`; + return String(err); + } catch { + return '[unstringifiable error]'; + } +} + +/** + * No-op `PermissionAuditPublisher` used as the bridge's default when + * the host omits `BridgeOptions.permissionAudit`. Production + * `qwen serve` provides a ring-backed publisher; embedded callers and + * unit tests that don't care about audit can let the bridge fall back + * here. Single canonical fallback prevents stub-vs-prod divergence + * (single canonical fallback). + */ +export function createNoOpPermissionAuditPublisher(): PermissionAuditPublisher { + return { + recordRequested() {}, + recordVoted() {}, + recordForbidden() {}, + recordResolved() {}, + recordTimeout() {}, + }; +} + +/** + * Dependency hooks the mediator needs from its host (the bridge). + * Plumbed through `MultiClientPermissionMediator`'s constructor; tests + * pass a stub. + */ +export interface MediatorDeps { + /** + * Best-effort fan-out of a wire event onto the per-session SSE bus. + * The mediator passes `sessionId` explicitly so the bridge can route + * to `byId.get(sessionId)?.events.publish(event)` without reverse- + * lookup. If the entry is gone (session torn down between issue and + * emit), the bridge silently drops; the audit record still lands. + */ + emit: (sessionId: string, event: Omit) => void; + /** Audit ring writer. */ + audit: PermissionAuditPublisher; + /** + * Optional fixed quorum for `consensus`. When set, capped to + * `M = votersAtIssue.size` to prevent unreachable quorum. When + * unset, mediator computes `floor(M/2) + 1`. + */ + consensusQuorum?: number; + /** Wallclock supplier — injectable for deterministic tests. Used by + * the timeout decision-reason `firedAtMs` field. */ + now: () => number; + /** + * Snapshot of registered voter `clientId`s for the session at the + * moment of `request()`. The mediator captures this into + * `MediatorPending.votersAtIssue`; consensus rejects votes from + * `clientId`s not in the snapshot. + * + * Implementation: `(sid) => new Set(byId.get(sid)?.clientIds.keys() ?? [])`. + * Refcount is intentionally NOT exposed. + * + * **MUST return synchronously**. `mediator.request()` calls this + * inside the Promise executor with no `await`, per the N1 + * race-prevention invariant. An async implementation (returning + * `Promise>`) would defer the pending registration + * past the bridge's `publish → register → await` sequencing point and + * silently break a `forgetSession` racing with the issue path. + * + * **Forward-compat trap**: when the session was torn down between + * the bridge's `publish` and the mediator's `request` (extremely + * narrow race), the implementation should return an empty Set + * rather than throw. The `first-responder` policy ignores the + * snapshot, so an empty set is harmless. + * Under `consensus` policy, an empty `votersAtIssue` means EVERY vote on + * the request gets rejected for "not in voter set" — the request + * can only resolve via `forgetSession` cleanup or `permissionTimeoutMs`. + * The bridge's torn-down-session race is short enough that this is + * acceptable; document if a longer-window source of empty-voter + * snapshots emerges. + * + * **Late-joiner timing window** (voter snapshot timing). + * The bridge sequence is `entry.events.publish(...)` → + * (synchronous) → `await mediator.request(record, ...)`. The + * publish is synchronous (`EventBus.publish` returns after fanning + * to in-memory subscriber queues, no event-loop yield) and the + * mediator's Promise executor is also synchronous through this + * call (synchronous-register invariant), so a NEW HTTP client cannot register its + * `clientId` on `entry.clientIds` between publish and snapshot. + * However, an SSE subscriber that connected BEFORE the publish but + * has NOT yet hit any session route (no `X-Qwen-Client-Id` known + * to the bridge) will not appear in the snapshot — `consensus` + * silently rejects its later vote as `forbidden`. UIs that surface + * the active voter set (eligible-voters chip) should treat + * `permission_request` as the authoritative cutoff, not subsequent + * client-identity registrations. This version does not surface + * `votersAtIssue` to the wire; future PRs that add an + * `eligibleVoters[]` field on `permission_request.data` should + * source it from the same snapshot to keep client-side and + * server-side membership decisions aligned. + */ + votersForSession: (sessionId: string) => ReadonlySet; +} + +/** + * Pending permission record owned by the mediator. Uniform shape across + * all four policies — `tallies` and `votersAtIssue` are present even + * for non-consensus (empty in that case) so we don't need a discriminated + * union over `policy`. Memory cost is two empty containers per pending + * (~120 bytes), negligible against the per-session pending cap of 64. + */ +interface MediatorPending { + readonly requestId: string; + readonly sessionId: string; + /** Captured at request issue time so live-reload of the daemon + * policy doesn't change the rules under in-flight requests. */ + readonly policy: PermissionPolicy; + readonly originatorClientId: string | undefined; + readonly allowedOptionIds: ReadonlySet; + readonly issuedAtMs: number; + readonly timeoutMs: number; + /** Settles the Promise returned by `request()`. */ + readonly resolve: (resolution: PermissionResolution) => void; + /** Per-option vote sets for `consensus`; empty for other policies. */ + readonly tallies: Map>; + /** Snapshot of eligible voters for `consensus`; empty for others. */ + readonly votersAtIssue: ReadonlySet; + /** Mediator-internal — do not read or write from outside the class. */ + timer: ReturnType | undefined; + /** + * Set to `true` once the + * `consensusQuorum` override cap has emitted its stderr + * breadcrumb for this pending so we don't repeat the line every + * time `consensusQuorumFor` is called within the same request. + */ + consensusQuorumCapNoted: boolean; +} + +interface PermissionResolutionRecord { + readonly requestId: string; + readonly sessionId: string; + readonly resolution: PermissionResolution; + /** Voter's clientId (or undefined for timeout / session-closed paths) + * — replayed onto `permission_already_resolved` so late SSE + * subscribers see the same `originatorClientId` the original + * `permission_resolved` carried (wire compat). */ + readonly resolverClientId: string | undefined; +} + +/** + * Multi-client permission coordination implementation. + * + * Lifecycle: + * - `request(record, timeoutMs)` synchronously registers a pending + * entry inside the returned Promise's executor (no `await` before + * register — see synchronous-register invariant) and arms the timeout. + * - `vote(vote)` dispatches by `entry.policy` and either resolves, + * records, rejects, or reports unknown. + * - `forgetSession(sessionId)` cancels every pending matching the + * session as `{kind:'cancelled', reason:'session_closed'}`. + * + * State is mediator-owned: `pending: Map` + * and `resolved: BoundedMap`. + * Outside callers (the bridge) keep ONLY `entry.pendingPermissionIds` + * for the per-session cap check; the mediator is the source of truth. + */ +export class MultiClientPermissionMediator implements PermissionMediator { + readonly policy: PermissionPolicy; + + private readonly deps: MediatorDeps; + private readonly pending = new Map(); + private readonly resolved = new Map(); + private readonly resolvedOrder: string[] = []; + /** + * Dedup flag for the + * unanimity-required stderr breadcrumb. Without this, every + * permission request on a 2-client consensus session would emit + * an identical line (the unanimity condition is the NORMAL + * operating mode for M=2, not a rare edge); a busy session with + * many tool calls would produce dozens of duplicate stderr lines + * within seconds. One emit per mediator (= per daemon lifetime + * since the bridge constructs one) is enough to make the + * configuration visible without spam. + */ + private unanimityBreadcrumbEmitted = false; + + constructor(policy: PermissionPolicy, deps: MediatorDeps) { + this.policy = policy; + this.deps = deps; + } + + /** + * Register a fresh permission request from the agent. + * + * **Promise contract — once the Promise is returned, it never + * rejects.** All runtime failure modes (timeout, session closure, + * voter cancel, emit/audit publisher exceptions) are encoded as + * `PermissionResolution { kind:'cancelled', reason:... }`. + * Consumers can `await` the returned Promise and forward the + * result without a `.catch()` block. + * + * **Synchronous-throw exception**: + * when the agent's `allowedOptionIds` contains the + * cancel-vote sentinel string, this method throws + * `CancelSentinelCollisionError` synchronously BEFORE constructing + * the Promise. The synchronous shape is intentional — a + * never-settling Promise alongside a thrown error would be worse + * than a clean fail-fast — but callers must wrap this method + * itself in `try/catch` (or call it from an `async` function so + * the throw bubbles via the function's own Promise machinery). + * `bridgeClient.ts` currently has its own pre-check at the bridge + * layer; embedded callers must do the same. See `@throws` below. + * + * **Synchronous-register invariant**: pending entry, audit + * record, and timer setup all happen inside the Promise executor + * without `await`. The bridge's `publish → mediator.request → await` + * sequence relies on this — a `forgetSession` between publish and + * await would otherwise miss the new pending and leak it until + * timeout. + * + * @throws `CancelSentinelCollisionError` SYNCHRONOUSLY (not as a + * Promise rejection) if `record.allowedOptionIds` contains the + * cancel-vote sentinel string. This is a contract violation + * between agent and daemon and fails loudly at issue time + * rather than silently miscounting votes downstream. Callers + * inside an `async` function get the thrown error through the + * function's own Promise; synchronous callers must use + * `try/catch`. + */ + request( + record: PermissionRequestRecord, + timeoutMs: number, + ): Promise { + // Collision defense — fail loudly if an agent legitimately uses + // the sentinel string as an option label. Throws synchronously + // BEFORE constructing the Promise so the caller doesn't end up + // holding a never-settling Promise alongside a thrown error. + if (record.allowedOptionIds.has(CANCEL_VOTE_SENTINEL)) { + throw new CancelSentinelCollisionError( + record.requestId, + CANCEL_VOTE_SENTINEL, + ); + } + return new Promise((resolve) => { + // === BEGIN SYNCHRONOUS REGISTER (no awaits permitted) === + const policy = this.policy; + const votersAtIssue = this.deps.votersForSession(record.sessionId); + const pending: MediatorPending = { + requestId: record.requestId, + sessionId: record.sessionId, + policy, + originatorClientId: record.originatorClientId, + allowedOptionIds: record.allowedOptionIds, + issuedAtMs: record.issuedAtMs, + timeoutMs, + resolve, + tallies: new Map(), + votersAtIssue, + timer: undefined, + consensusQuorumCapNoted: false, + }; + this.pending.set(record.requestId, pending); + this.safeAudit(() => + this.deps.audit.recordRequested(record, policy, votersAtIssue), + ); + // When consensus is in + // force but the bridge captured zero eligible voters at + // issue time, the request can ONLY resolve via timeout (no + // vote will ever pass `votersAtIssue.has(clientId)`). Emit + // a stderr breadcrumb so operators don't have to derive that + // from "5 minutes of silence + permission_request frame". + // Doesn't change semantics; the timer still fires per the + // configured `permissionTimeoutMs`. + if (policy === 'consensus' && votersAtIssue.size === 0) { + try { + process.stderr.write( + `permissionMediator: consensus request ${record.requestId} ` + + `for session ${record.sessionId} issued with empty ` + + `votersAtIssue; can only resolve via permissionTimeoutMs ` + + `(${timeoutMs}ms)\n`, + ); + } catch { + // Stderr unavailable — silent drop. + } + } + // For even-sized voter + // sets the default formula `floor(M/2)+1` requires unanimity + // ONLY when M=2 (the practical surprise case); M=4 → quorum=3 + // is supermajority; M=6 → quorum=4 is supermajority too. The + // condition `floor(M/2)+1 === M` is true only for M=1 + // (single-voter; quorum=1 = M trivially) and M=2. + // + // Dedup to one emit per + // mediator lifetime via `unanimityBreadcrumbEmitted`. Without + // this, a 2-client consensus session emits the line on EVERY + // permission request (unanimity is the M=2 normal operating + // mode, not a rare edge). The flag also ensures the line is + // visible at least once when the daemon boots into this + // configuration — operators see it on the first + // requestPermission and can ignore the dedup'd silence + // afterward. + if ( + policy === 'consensus' && + this.deps.consensusQuorum === undefined && + votersAtIssue.size >= 2 && + Math.floor(votersAtIssue.size / 2) + 1 === votersAtIssue.size && + !this.unanimityBreadcrumbEmitted + ) { + this.unanimityBreadcrumbEmitted = true; + try { + process.stderr.write( + `permissionMediator: consensus request ${record.requestId} ` + + `for session ${record.sessionId} requires unanimity ` + + `(votersAtIssue.size=${votersAtIssue.size}, default ` + + `quorum=floor(M/2)+1=${votersAtIssue.size}); split votes ` + + `will only resolve via permissionTimeoutMs (${timeoutMs}ms). ` + + `This breadcrumb fires once per mediator lifetime; ` + + `subsequent unanimity-required requests are silent.\n`, + ); + } catch { + // Stderr unavailable — silent drop. + } + } + if (timeoutMs > 0) { + pending.timer = setTimeout(() => { + // Timer fires asynchronously — guard against the entry + // already having been resolved by a vote OR replaced by a + // fresh request that reused the same requestId after LRU + // eviction. The identity check (`!== pending`) covers + // both cases — `this.pending.has(requestId)` would mistake + // a fresh request for a stale-timer fire on the old one. + if (this.pending.get(record.requestId) !== pending) return; + const firedAtMs = this.deps.now(); + // Restore stderr breadcrumb. + // Pre-extraction wrote "timed out + // after Xms" directly to daemon stderr; The mediator delegates to + // the audit publisher, but production audit can still be + // a no-op for embedded callers, so emit the breadcrumb + // here unconditionally. Wrapped in try/catch because + // process.stderr.write can synchronously throw on EPIPE + // (closed stderr) — losing observability is preferable + // to crashing the daemon's timer queue. + try { + process.stderr.write( + `qwen serve: permission ${record.requestId} ` + + `(session ${record.sessionId}) timed out after ${timeoutMs}ms\n`, + ); + } catch { + // Stderr unavailable — drop the breadcrumb and continue. + } + this.safeAudit(() => this.deps.audit.recordTimeout(record)); + this.resolveEntry( + pending, + { kind: 'cancelled', reason: 'timeout' }, + { + type: 'timeout', + issuedAtMs: pending.issuedAtMs, + timeoutMs: pending.timeoutMs, + firedAtMs, + }, + undefined, + ); + }, timeoutMs); + const t = pending.timer; + if (t && typeof t === 'object' && 'unref' in t) { + (t as { unref(): void }).unref(); + } + } + // === END SYNCHRONOUS REGISTER === + }); + } + + vote(vote: PermissionVote): PermissionVoteOutcome { + const pending = this.pending.get(vote.requestId); + + if (!pending) { + const prior = this.resolved.get(vote.requestId); + if (prior && prior.sessionId === vote.sessionId) { + // Re-emit `permission_already_resolved` so late SSE + // subscribers see the conclusion. Note: + // the previous `publishPermissionAlreadyResolved` did NOT stamp + // `originatorClientId` on this event. Preserve byte-for-byte + // — `httpAcpBridge.test.ts:2880` asserts + // `originatorClientId: undefined`. Resolver attribution lives + // in the audit log via `decisionReason.resolverClientId`, + // not on the wire frame. + const optionId = + prior.resolution.kind === 'option' + ? prior.resolution.optionId + : CANCEL_VOTE_SENTINEL; + this.safeEmit(prior.sessionId, { + type: 'permission_already_resolved', + data: { + requestId: prior.requestId, + sessionId: prior.sessionId, + outcome: this.toAcpOutcome(prior.resolution), + }, + }); + return { kind: 'already_resolved', resolvedOptionId: optionId }; + } + return { kind: 'unknown_request' }; + } + + if (pending.sessionId !== vote.sessionId) { + return { kind: 'unknown_request' }; + } + + // Voter cancel — bypasses policy dispatch; resolves cancelled + // regardless of who voted (the bridge already validated `clientId`). + if (vote.optionId === CANCEL_VOTE_SENTINEL) { + const outcome: PermissionVoteOutcome = { + kind: 'resolved', + resolvedOptionId: CANCEL_VOTE_SENTINEL, + }; + // Audit ordering invariant: `voted` before `resolved`. + this.safeAudit(() => + this.deps.audit.recordVoted(this.toRecord(pending), vote, outcome), + ); + this.resolveEntry( + pending, + { kind: 'cancelled', reason: 'agent_cancelled' }, + { + type: 'voter-cancelled', + resolverClientId: vote.clientId, + }, + vote.clientId, + ); + return outcome; + } + + // Validate optionId against the agent-declared allow set. Throws + // `InvalidPermissionOptionError`; the route layer maps to 400. + if (!pending.allowedOptionIds.has(vote.optionId)) { + throw new InvalidPermissionOptionError(vote.requestId, vote.optionId); + } + + // Per-policy handlers own their own audit.recordVoted call to + // preserve the `voted → resolved` ordering invariant (the + // resolveEntry call inside each handler is what triggers the + // `resolved` audit record). + switch (pending.policy) { + case 'first-responder': + return this.voteFirstResponder(pending, vote); + case 'designated': + return this.voteDesignated(pending, vote); + case 'consensus': + return this.voteConsensus(pending, vote); + case 'local-only': + return this.voteLocalOnly(pending, vote); + default: { + // Exhaustiveness — a future PermissionPolicy literal added + // without a case here will fail compilation at this line. + const _exhaustive: never = pending.policy; + void _exhaustive; + throw new Error(`Unknown permission policy "${pending.policy}"`); + } + } + } + + forgetSession(sessionId: string): void { + // Snapshot the keys to avoid mutating the Map during iteration. + const requestIds: string[] = []; + for (const [id, pending] of this.pending) { + if (pending.sessionId === sessionId) requestIds.push(id); + } + for (const id of requestIds) { + // Defensive — JS is single-threaded so today this re-lookup + // can't return a different entry, but resolveEntry's emit / + // audit calls fire synchronously and a future maintainer + // adding `await` or a re-entrant hook would invalidate the + // assumption. The Map.get is cheap insurance and not dead + // code if the loop body is ever modified. + const pending = this.pending.get(id); + if (!pending) continue; + this.resolveEntry( + pending, + { kind: 'cancelled', reason: 'session_closed' }, + { type: 'session-closed' }, + undefined, + ); + } + } + + /** + * Lookup the sessionId for a given requestId. Used by the legacy + * `bridge.respondToPermission(requestId, ...)` route which doesn't + * carry a sessionId in the URL. NOT part of the + * `PermissionMediator` interface contract — bridge holds the + * concrete class reference and calls this directly. + */ + peekSessionFor(requestId: string): string | undefined { + const pending = this.pending.get(requestId); + if (pending) return pending.sessionId; + const prior = this.resolved.get(requestId); + return prior?.sessionId; + } + + /** + * Daemon-wide in-flight pending count for diagnostics. The bridge + * exposes this through its `pendingPermissionCount` getter so + * operators can spot stuck FIFOs without reaching into mediator + * internals. NOT part of the `PermissionMediator` interface + * contract. + */ + get pendingCount(): number { + return this.pending.size; + } + + // =========================================================== + // Per-policy vote handlers + // =========================================================== + + private voteFirstResponder( + pending: MediatorPending, + vote: PermissionVote, + ): PermissionVoteOutcome { + return this.resolveWithVote(pending, vote, { + type: 'first-responder', + resolverClientId: vote.clientId, + }); + } + + private voteDesignated( + pending: MediatorPending, + vote: PermissionVote, + ): PermissionVoteOutcome { + if (pending.originatorClientId === undefined) { + return this.voteFirstResponder(pending, vote); + } + if (vote.clientId !== pending.originatorClientId) { + return this.rejectForbidden( + pending, + vote, + 'designated_mismatch', + 'designated_mismatch (voter is not the prompt originator)', + ); + } + return this.resolveWithVote(pending, vote, { + type: 'designated-originator', + originatorClientId: pending.originatorClientId, + }); + } + + private voteConsensus( + pending: MediatorPending, + vote: PermissionVote, + ): PermissionVoteOutcome { + if ( + vote.clientId === undefined || + !pending.votersAtIssue.has(vote.clientId) + ) { + return this.rejectForbidden( + pending, + vote, + 'designated_mismatch', + 'designated_mismatch (voter not in consensus votersAtIssue snapshot)', + ); + } + + for (const [originalOptionId, set] of pending.tallies.entries()) { + if (vote.clientId !== undefined && set.has(vote.clientId)) { + const outcome: PermissionVoteOutcome = { + kind: 'recorded', + votesNeeded: this.votesNeededFor(pending), + }; + this.safeAudit(() => + this.deps.audit.recordVoted( + this.toRecord(pending), + { ...vote, optionId: originalOptionId }, + outcome, + ), + ); + return outcome; + } + } + + let bucket = pending.tallies.get(vote.optionId); + if (!bucket) { + bucket = new Set(); + pending.tallies.set(vote.optionId, bucket); + } + bucket.add(vote.clientId); + + const quorum = this.consensusQuorumFor(pending); + if (bucket.size >= quorum) { + return this.resolveWithVote(pending, vote, { + type: 'consensus-quorum', + resolvedOptionId: vote.optionId, + quorum, + tally: bucket.size, + }); + } + + const outcome: PermissionVoteOutcome = { + kind: 'recorded', + votesNeeded: this.votesNeededFor(pending), + }; + this.safeAudit(() => + this.deps.audit.recordVoted(this.toRecord(pending), vote, outcome), + ); + this.safeEmit(pending.sessionId, { + type: 'permission_partial_vote', + data: { + requestId: pending.requestId, + sessionId: pending.sessionId, + votesReceived: this.totalTalliedFor(pending), + votesNeeded: outcome.votesNeeded, + quorum, + optionTallies: this.optionTalliesFor(pending), + }, + ...(pending.originatorClientId !== undefined + ? { originatorClientId: pending.originatorClientId } + : {}), + }); + return outcome; + } + + /** + * Vote dispatch for `local-only` policy: only `fromLoopback: true` + * voters can resolve a permission. + * + * **Cancel-sentinel asymmetry** (cancel-sentinel note). + * `vote()` recognizes the cancel sentinel BEFORE calling this + * method (cross-policy escape hatch — see the + * `CANCEL_VOTE_SENTINEL` JSDoc for the rationale), so a remote + * voter under `local-only` CAN abort a pending permission via + * `{outcome:'cancelled'}` even though they cannot RESOLVE one. The + * settings-side description for `local-only` and the design doc call + * out this gap explicitly. Operators who want strict-cancel-too + * semantics must (a) deploy a dedicated daemon process at + * loopback bind, OR (b) wait for the follow-up PR that lifts + * cancel into per-policy gating; This version keeps the current + * cross-policy cancel for consistency with first-responder / + * designated / consensus. + */ + private voteLocalOnly( + pending: MediatorPending, + vote: PermissionVote, + ): PermissionVoteOutcome { + if (!vote.fromLoopback) { + return this.rejectForbidden( + pending, + vote, + 'remote_not_allowed', + 'remote_not_allowed (local-only policy; vote not from loopback)', + ); + } + return this.resolveWithVote(pending, vote, { + type: 'local-only-loopback', + resolverClientId: vote.clientId, + }); + } + + // =========================================================== + // Shared vote-resolution helpers + // =========================================================== + + private resolveWithVote( + pending: MediatorPending, + vote: PermissionVote, + decisionReason: PermissionDecisionReason, + ): PermissionVoteOutcome { + const outcome: PermissionVoteOutcome = { + kind: 'resolved', + resolvedOptionId: vote.optionId, + }; + this.safeAudit(() => + this.deps.audit.recordVoted(this.toRecord(pending), vote, outcome), + ); + this.resolveEntry( + pending, + { + kind: 'option', + optionId: vote.optionId, + ...(vote.metadata ? { metadata: vote.metadata } : {}), + }, + decisionReason, + vote.clientId, + ); + return outcome; + } + + private rejectForbidden( + pending: MediatorPending, + vote: PermissionVote, + reason: 'designated_mismatch' | 'remote_not_allowed', + stderrDetail: string, + ): PermissionVoteOutcome { + this.safeAudit(() => + this.deps.audit.recordForbidden(this.toRecord(pending), vote, reason), + ); + this.safeEmit(pending.sessionId, { + type: 'permission_forbidden', + data: { + requestId: pending.requestId, + sessionId: pending.sessionId, + ...(vote.clientId !== undefined ? { clientId: vote.clientId } : {}), + reason, + }, + ...(pending.originatorClientId !== undefined + ? { originatorClientId: pending.originatorClientId } + : {}), + }); + this.writeForbiddenStderr(pending, vote, stderrDetail); + return { kind: 'forbidden', reason }; + } + + // =========================================================== + // Consensus tally helpers + // =========================================================== + + /** + * Compute the quorum size for a `consensus` request. Default + * `floor(M/2) + 1` of `votersAtIssue.size`; overridden by + * `deps.consensusQuorum` when set, capped to `M` so an operator + * misconfig (N > M) can't deadlock. + * + * When the cap fires, write + * a one-time stderr breadcrumb per request so operators don't + * have to diff their `policy.consensusQuorum` against + * `votersAtIssue.size` manually to understand why a quorum + * resolved sooner than configured. Tracked on `MediatorPending` + * so the breadcrumb fires once even though `consensusQuorumFor` + * may be called multiple times per request (vote tally + final + * resolution). + */ + private consensusQuorumFor(pending: MediatorPending): number { + const m = pending.votersAtIssue.size; + const override = this.deps.consensusQuorum; + if (override !== undefined) { + const capped = Math.min(override, Math.max(m, 1)); + if (capped < override && !pending.consensusQuorumCapNoted) { + pending.consensusQuorumCapNoted = true; + try { + process.stderr.write( + `permissionMediator: consensusQuorum override ${override} ` + + `capped to ${capped} (votersAtIssue.size=${m}) for ` + + `request ${pending.requestId} session ${pending.sessionId}\n`, + ); + } catch { + // Stderr unavailable — silent drop. + } + } + return capped; + } + return Math.max(1, Math.floor(m / 2) + 1); + } + + private totalTalliedFor(pending: MediatorPending): number { + let total = 0; + for (const set of pending.tallies.values()) total += set.size; + return total; + } + + /** + * `votesNeeded` = `quorum - max(tally per option)`. When no + * option has any votes (degenerate; `permission_partial_vote` + * is only emitted AFTER the first vote, so this should never + * appear on the wire), returns `quorum` itself. Always ≥ 1 + * because the resolved-on-quorum path returns before this + * helper runs. + */ + private votesNeededFor(pending: MediatorPending): number { + const quorum = this.consensusQuorumFor(pending); + let max = 0; + for (const set of pending.tallies.values()) { + if (set.size > max) max = set.size; + } + return Math.max(quorum - max, 1); + } + + private optionTalliesFor(pending: MediatorPending): Record { + const out: Record = {}; + for (const [optionId, set] of pending.tallies) { + out[optionId] = set.size; + } + return out; + } + + // =========================================================== + // Resolution + cleanup + // =========================================================== + + /** + * Settle a pending entry. Cleanup order is hardened (cleanup-order invariant): + * 1. clearTimeout (so a timer can never fire on a half-cleaned entry). + * 2. Delete from `pending` (state-first half — entry no longer + * reachable for new votes). + * 3. emit wire `permission_resolved` (best-effort — emit failures + * do not block the Promise settle). MUST come before step 4 + * so a re-entrant subscriber synchronously casting another + * vote during emit sees `pending === undefined && resolved + * === undefined` (silent false), matching the previous ordering. + * + * 4. write to `resolved` (the second half of state move — late + * voters arriving after this see `permission_already_resolved`). + * 5. audit.recordResolved (best-effort, same). + * 6. Settle the Promise (LAST — callbacks running re-entrantly + * see consistent state). + * + * Previously the spec bundled + * "delete pending + write resolved" into step 2 ahead of emit, + * which contradicted the code. The fix + * splits the two halves of the state move around the emit so + * the spec faithfully describes the ordering invariant. + * + * @param resolverClientId wire compat: the + * `permission_resolved` SSE frame stamps this as + * `originatorClientId`. The previous `resolvePending` in + * `httpAcpBridge.ts:1518-1523` filled it from the voter's + * trusted clientId. We preserve byte-for-byte; vote-driven + * paths pass `vote.clientId` (which may be undefined for + * loopback no-header voters); timer + session-closed paths + * pass undefined (no voter). + */ + private resolveEntry( + pending: MediatorPending, + resolution: PermissionResolution, + decisionReason: PermissionDecisionReason | undefined, + resolverClientId: string | undefined, + ): void { + if (this.pending.get(pending.requestId) !== pending) { + // Already resolved on a different path (race between timer and + // a final vote arriving in the same tick). Idempotent no-op. + return; + } + if (pending.timer !== undefined) { + clearTimeout(pending.timer); + pending.timer = undefined; + } + this.pending.delete(pending.requestId); + // Note: emit the SSE `permission_resolved` BEFORE + // writing to the resolved-LRU. Previously ordered emit-then-LRU and a + // re-entrant subscriber synchronously casting another vote during + // emit would have seen `pending === undefined && resolved === + // undefined` (silent false). Reversing that order would let the + // re-entrant vote find the new LRU record and emit a redundant + // `permission_already_resolved`. Match the previous ordering for + // wire-shape preservation. + this.safeEmit(pending.sessionId, { + type: 'permission_resolved', + data: { + requestId: pending.requestId, + outcome: this.toAcpOutcome(resolution), + // Note: `voterClientId` is the canonical, + // unambiguous name for "who cast the resolving vote". The envelope + // `originatorClientId` below carries the SAME value for wire + // compat (it is semantically the voter on `permission_resolved`, + // unlike on `permission_request` where it is the prompt originator). + // Both are optional and omitted together for no-voter resolutions + // (timer expiry / session-closed / loopback voter with no clientId). + ...(resolverClientId !== undefined + ? { voterClientId: resolverClientId } + : {}), + }, + // Preserve pre-extraction behavior: voter's clientId is stamped + // here (not the prompt originator's). Documented inconsistency + // with `permission_request.originatorClientId` (which IS the + // prompt originator); we do not fix the inconsistency to + // avoid breaking the wire shape. A4 keeps it as a deprecated + // alias of `data.voterClientId`. + ...(resolverClientId !== undefined + ? { originatorClientId: resolverClientId } + : {}), + }); + this.rememberResolved({ + requestId: pending.requestId, + sessionId: pending.sessionId, + resolution, + resolverClientId, + }); + if (decisionReason !== undefined) { + this.safeAudit(() => + this.deps.audit.recordResolved( + this.toRecord(pending), + resolution, + decisionReason, + ), + ); + } + pending.resolve(resolution); + } + + private rememberResolved(record: PermissionResolutionRecord): void { + if (!this.resolved.has(record.requestId)) { + this.resolvedOrder.push(record.requestId); + } + this.resolved.set(record.requestId, record); + while (this.resolvedOrder.length > MAX_RESOLVED_PERMISSION_RECORDS) { + const oldest = this.resolvedOrder.shift(); + if (oldest !== undefined) this.resolved.delete(oldest); + } + } + + private safeEmit( + sessionId: string, + event: Omit, + ): void { + try { + this.deps.emit(sessionId, event); + } catch (err) { + // Emit failures (bus closed mid-shutdown) never block settle. + // Note: surface as a stderr breadcrumb so + // silent regressions in the host's emit path (e.g. a future + // contract violation that throws instead of returning + // undefined) don't disappear unnoticed. + // + // Stderr safety — the breadcrumb itself + // must be defensive. `process.stderr.write` can synchronously + // throw on EPIPE during daemon shutdown; if it does, the + // exception escapes `safeEmit` and propagates out of + // `resolveEntry`, leaving the pending Promise unsettled + // (request already deleted from `this.pending`). The agent + // would hang on `requestPermission` until the timeout fires. + // Mirror the timer callback's `try/catch` posture: losing + // observability is preferable to a stuck Promise. + try { + process.stderr.write( + `permissionMediator: emit failed for session=${JSON.stringify(sessionId)} type=${JSON.stringify(event.type)}: ${stringifyError(err)}\n`, + ); + } catch { + // Stderr unavailable — drop the breadcrumb and continue. + } + } + } + + /** + * Emit a stderr breadcrumb + * for every vote rejection (the three forbidden paths in + * voteDesignated / voteConsensus / voteLocalOnly). Mirrors the + * timeout breadcrumb pattern: audit ring + SSE event are + * transient observability surfaces (no v1 query route, SSE drops + * on disconnect), so an operator tailing daemon stderr would see + * zero indication of permission rejections without this. + * + * Wrapped in `try/catch` because `process.stderr.write` can + * synchronously throw on EPIPE during shutdown — a stderr + * unavailability must not propagate up through `safeEmit` / + * `safeAudit` and break the resolveEntry cleanup ladder. Mirrors + * the safeEmit/safeAudit defensive posture (see the + * matching hang scenario in safeEmit). + */ + private writeForbiddenStderr( + pending: MediatorPending, + vote: PermissionVote, + reasonDetail: string, + ): void { + try { + const voterDescriptor = + vote.clientId === undefined + ? '' + : JSON.stringify(vote.clientId); + process.stderr.write( + `qwen serve: permission ${pending.requestId} ` + + `(session ${pending.sessionId}): vote rejected ` + + `(${reasonDetail}) by client ${voterDescriptor}\n`, + ); + } catch { + // Stderr unavailable — drop the breadcrumb and continue. + } + } + + /** + * Run an audit-publisher call defensively. The audit ring is + * best-effort observability — a publisher exception (ring full, + * host bug, transient I/O) MUST NOT throw out of `request()`, + * `vote()`, or the timer callback. Without this guard, the + * Promise the agent is awaiting would be left unsettled and the + * pending entry would leak. + * + * Single helper used at all five audit call sites so the + * "audit is best-effort" invariant is uniformly enforced (the + * pre-fix asymmetric `try/catch` at 2 of 5 sites was a real + * silent-failure hole. + * + * doc placement — JSDoc was previously + * stacked above `writeForbiddenStderr` so IDE hover and API + * doc generation showed the wrong attribution. Moved adjacent + * to its actual definition. + */ + private safeAudit(fn: () => void): void { + try { + fn(); + } catch (err) { + // Stderr safety — see the matching + // try/catch on the breadcrumb in `safeEmit`. The audit-failure + // breadcrumb must not itself crash the safe wrapper, or the + // `resolveEntry` cleanup ladder could leave the pending + // Promise unsettled. + try { + process.stderr.write( + `permissionMediator: audit publisher threw: ${stringifyError(err)}\n`, + ); + } catch { + // Stderr unavailable — drop the breadcrumb and continue. + } + } + } + + private toRecord(pending: MediatorPending): PermissionRequestRecord { + return { + requestId: pending.requestId, + sessionId: pending.sessionId, + originatorClientId: pending.originatorClientId, + allowedOptionIds: pending.allowedOptionIds, + issuedAtMs: pending.issuedAtMs, + }; + } + + private toAcpOutcome( + resolution: PermissionResolution, + ): { outcome: 'selected'; optionId: string } | { outcome: 'cancelled' } { + if (resolution.kind === 'option') { + return { outcome: 'selected', optionId: resolution.optionId }; + } + return { outcome: 'cancelled' }; + } +} diff --git a/packages/acp-bridge/src/spawnChannel.test.ts b/packages/acp-bridge/src/spawnChannel.test.ts new file mode 100644 index 0000000000..aea279502e --- /dev/null +++ b/packages/acp-bridge/src/spawnChannel.test.ts @@ -0,0 +1,270 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Unit tests for `defaultSpawnChannelFactory`'s security-critical env + * scrubbing (wenshao #4319 Critical fold-in). The wider 174-test + * `httpAcpBridge.test.ts` suite uses mock channels and never spawns a + * real child, so none of those tests exercise `defaultSpawnChannelFactory` + * or `scrubChildEnv` directly. These tests close that gap. + * + * Why this matters: now that `defaultSpawnChannelFactory` is a public + * export of `@qwen-code/acp-bridge`, channels (`packages/channels/base/ + * AcpBridge.ts`) and the VSCode IDE companion will consume it directly + * and cannot rely on cli-package integration tests for env-scrubbing + * guarantees. The scrubbing logic protects against: + * + * - `QWEN_SERVER_TOKEN` (the daemon's own bearer token) leaking into + * the spawned agent's environment, where prompt-injection could + * turn the agent into an authenticated client of its own daemon. + * - An `overrides` map smuggling a scrubbed key BACK into the child + * env (defense-in-depth — operators / embedders can pass overrides, + * but the denylist still wins). + * - An `overrides` map with `undefined` value silently failing to + * delete a stale inherited var (PR 14 fix #4247 wenshao R5 — + * the `runQwenServe.ts:216` use case). + * + * Each branch listed below is now regression-guarded by an assertion. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + createStderrForwarder, + getAcpMemoryArgs, + scrubChildEnv, +} from './spawnChannel.js'; + +describe('createStderrForwarder', () => { + it('calls onDiagnosticLine for each complete line', () => { + const captured: Array<{ line: string; level?: string }> = []; + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const forwarder = createStderrForwarder({ + prefix: '[test] ', + onDiagnosticLine: (l, lvl) => captured.push({ line: l, level: lvl }), + }); + forwarder.onData('hello\nworld\n'); + expect(captured).toEqual([ + { line: '[test] hello', level: 'warn' }, + { line: '[test] world', level: 'warn' }, + ]); + // Also writes to process.stderr + expect(stderrSpy).toHaveBeenCalledWith('[test] hello\n'); + expect(stderrSpy).toHaveBeenCalledWith('[test] world\n'); + stderrSpy.mockRestore(); + }); + + it('buffers partial lines until newline arrives', () => { + const captured: Array<{ line: string; level?: string }> = []; + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const forwarder = createStderrForwarder({ + prefix: '[p] ', + onDiagnosticLine: (l, lvl) => captured.push({ line: l, level: lvl }), + }); + forwarder.onData('partial'); + expect(captured).toHaveLength(0); // no newline yet + forwarder.onData(' more\n'); + expect(captured).toEqual([{ line: '[p] partial more', level: 'warn' }]); + stderrSpy.mockRestore(); + }); + + it('flushes buffered content on end', () => { + const captured: Array<{ line: string; level?: string }> = []; + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const forwarder = createStderrForwarder({ + prefix: '[p] ', + onDiagnosticLine: (l, lvl) => captured.push({ line: l, level: lvl }), + }); + forwarder.onData('partial'); + expect(captured).toHaveLength(0); + forwarder.onEnd(); + expect(captured).toEqual([{ line: '[p] partial', level: 'warn' }]); + stderrSpy.mockRestore(); + }); + + it('does not call onDiagnosticLine for empty lines', () => { + const captured: Array<{ line: string; level?: string }> = []; + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const forwarder = createStderrForwarder({ + prefix: '[p] ', + onDiagnosticLine: (l, lvl) => captured.push({ line: l, level: lvl }), + }); + forwarder.onData('\n\n'); + expect(captured).toHaveLength(0); + stderrSpy.mockRestore(); + }); + + it('force-flushes with [truncated] when buffer exceeds 64 KiB cap', () => { + const captured: Array<{ line: string; level?: string }> = []; + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const forwarder = createStderrForwarder({ + prefix: '[x] ', + onDiagnosticLine: (l, lvl) => captured.push({ line: l, level: lvl }), + }); + // Write 65 KiB without a newline — exceeds the 64 KiB cap + const bigChunk = 'A'.repeat(65 * 1024); + forwarder.onData(bigChunk); + // Should have force-flushed the first 64 KiB with [truncated] + expect(captured.length).toBeGreaterThanOrEqual(1); + expect(captured[0]!.line).toContain('[truncated]'); + expect(captured[0]!.level).toBe('warn'); + // The flushed line should have the prefix + expect(captured[0]!.line).toMatch(/^\[x\] /); + stderrSpy.mockRestore(); + }); + + it('works without onDiagnosticLine (still writes to stderr)', () => { + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const forwarder = createStderrForwarder({ + prefix: '[no-cb] ', + }); + forwarder.onData('line1\n'); + expect(stderrSpy).toHaveBeenCalledWith('[no-cb] line1\n'); + stderrSpy.mockRestore(); + }); +}); + +// Decoupled canary: we deliberately hand-roll the test set instead of +// importing `SCRUBBED_CHILD_ENV_KEYS` from `spawnChannel.ts` so the +// helper's behavior (clone + scrub + override + denylist-wins ordering) +// is tested as a pure function with parameterized input, independent +// of any current production denylist. The multi-key test below +// forward-guards expansion when a future sandboxed-agent mode grows +// the production set per the WARNING on `SCRUBBED_CHILD_ENV_KEYS`. +const SCRUBBED = new Set(['QWEN_SERVER_TOKEN']); + +describe('scrubChildEnv (defaultSpawnChannelFactory env policy)', () => { + it('shallow-clones source — never aliases into the live process.env', () => { + const source = { FOO: 'bar' }; + const result = scrubChildEnv(source, SCRUBBED); + result['MUTATED'] = 'yes'; + expect(source).not.toHaveProperty('MUTATED'); + }); + + it('strips QWEN_SERVER_TOKEN from the child env', () => { + const source = { QWEN_SERVER_TOKEN: 'super-secret', PATH: '/usr/bin' }; + const result = scrubChildEnv(source, SCRUBBED); + expect(result).not.toHaveProperty('QWEN_SERVER_TOKEN'); + expect(result['PATH']).toBe('/usr/bin'); + }); + + it('passes through non-scrubbed env vars unchanged', () => { + const source = { + OPENAI_API_KEY: 'sk-test', + DASHSCOPE_API_KEY: 'ds-test', + HOME: '/home/user', + }; + const result = scrubChildEnv(source, SCRUBBED); + expect(result).toEqual(source); + }); + + it('overrides with a string value ADD the key', () => { + const source = { PATH: '/usr/bin' }; + const result = scrubChildEnv(source, SCRUBBED, { NEW_KEY: 'new-value' }); + expect(result['NEW_KEY']).toBe('new-value'); + }); + + it('overrides with a string value REPLACE an existing key', () => { + const source = { PATH: '/usr/bin' }; + const result = scrubChildEnv(source, SCRUBBED, { PATH: '/override/bin' }); + expect(result['PATH']).toBe('/override/bin'); + }); + + it('overrides with undefined value DELETE the key from the child env (PR 14 fix #4247 wenshao R5)', () => { + const source = { STALE_VAR: 'leftover', PATH: '/usr/bin' }; + const result = scrubChildEnv(source, SCRUBBED, { STALE_VAR: undefined }); + expect(result).not.toHaveProperty('STALE_VAR'); + expect(result['PATH']).toBe('/usr/bin'); + }); + + it('overrides CANNOT re-introduce a scrubbed key (defense in depth)', () => { + const source = { PATH: '/usr/bin' }; + const result = scrubChildEnv(source, SCRUBBED, { + QWEN_SERVER_TOKEN: 'sneaky-attempt-via-override', + }); + expect(result).not.toHaveProperty('QWEN_SERVER_TOKEN'); + }); + + it('overrides CANNOT undo the scrub by setting undefined for a scrubbed key', () => { + // Edge case: `undefined` value would normally delete; but for a + // scrubbed key, the `continue` in the loop short-circuits BEFORE + // the undefined-vs-string check. The key stays deleted (by the + // earlier scrub pass) regardless of what overrides says. + const source = { QWEN_SERVER_TOKEN: 'secret', PATH: '/usr/bin' }; + const result = scrubChildEnv(source, SCRUBBED, { + QWEN_SERVER_TOKEN: undefined, + }); + expect(result).not.toHaveProperty('QWEN_SERVER_TOKEN'); + }); + + it('overrides are applied AFTER scrub — the denylist always wins', () => { + // Verifies the documented ordering invariant: even if the scrub + // and override touch the same key in conflicting ways, scrub wins. + const source = { QWEN_SERVER_TOKEN: 'from-process-env' }; + const result = scrubChildEnv(source, SCRUBBED, { + QWEN_SERVER_TOKEN: 'from-override', + }); + expect(result).not.toHaveProperty('QWEN_SERVER_TOKEN'); + }); + + it('empty overrides leaves scrub-only behavior intact', () => { + const source = { QWEN_SERVER_TOKEN: 'secret', PATH: '/usr/bin' }; + const result = scrubChildEnv(source, SCRUBBED, {}); + expect(result).not.toHaveProperty('QWEN_SERVER_TOKEN'); + expect(result['PATH']).toBe('/usr/bin'); + }); + + it('no overrides arg works the same as empty overrides', () => { + const source = { QWEN_SERVER_TOKEN: 'secret', PATH: '/usr/bin' }; + const result = scrubChildEnv(source, SCRUBBED); + expect(result).not.toHaveProperty('QWEN_SERVER_TOKEN'); + expect(result['PATH']).toBe('/usr/bin'); + }); + + it('multi-key scrub set strips every listed key', () => { + // Forward-compat: if a future sandboxed-agent mode expands the + // denylist (as the WARNING comment on SCRUBBED_CHILD_ENV_KEYS + // anticipates), this verifies the loop handles multiple keys. + const sandboxScrub = new Set([ + 'QWEN_SERVER_TOKEN', + 'AWS_SECRET_ACCESS_KEY', + 'OPENAI_API_KEY', + ]); + const source = { + QWEN_SERVER_TOKEN: 't1', + AWS_SECRET_ACCESS_KEY: 't2', + OPENAI_API_KEY: 't3', + PATH: '/usr/bin', + }; + const result = scrubChildEnv(source, sandboxScrub); + expect(result).not.toHaveProperty('QWEN_SERVER_TOKEN'); + expect(result).not.toHaveProperty('AWS_SECRET_ACCESS_KEY'); + expect(result).not.toHaveProperty('OPENAI_API_KEY'); + expect(result['PATH']).toBe('/usr/bin'); + }); +}); + +describe('getAcpMemoryArgs', () => { + it('returns a valid --max-old-space-size flag or empty array', () => { + const args = getAcpMemoryArgs(); + if (args.length > 0) { + expect(args).toHaveLength(1); + expect(args[0]).toMatch(/^--max-old-space-size=\d+$/); + const sizeMB = Number(args[0]!.split('=')[1]); + expect(sizeMB).toBeGreaterThan(0); + expect(sizeMB).toBeLessThanOrEqual(16_384); + } else { + expect(args).toEqual([]); + } + }); + + it('respects the 16GB cap', () => { + const args = getAcpMemoryArgs(); + if (args.length > 0) { + const sizeMB = Number(args[0]!.split('=')[1]); + expect(sizeMB).toBeLessThanOrEqual(16_384); + } + }); +}); diff --git a/packages/acp-bridge/src/spawnChannel.ts b/packages/acp-bridge/src/spawnChannel.ts new file mode 100644 index 0000000000..f769576023 --- /dev/null +++ b/packages/acp-bridge/src/spawnChannel.ts @@ -0,0 +1,348 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import * as os from 'node:os'; +import { Readable, Writable } from 'node:stream'; +import { getHeapStatistics } from 'node:v8'; +import { ndJsonStream } from '@agentclientprotocol/sdk'; +import type { AcpChannelExitInfo, ChannelFactory } from './channel.js'; +import { MissingCliEntryError } from './status.js'; + +let cachedMemoryArgs: string[] | undefined; +export function getAcpMemoryArgs(): string[] { + if (cachedMemoryArgs) return cachedMemoryArgs; + const constrainedMemory = (process as { constrainedMemory?: () => number }) + .constrainedMemory; + const constrained = + typeof constrainedMemory === 'function' ? constrainedMemory() : 0; + const totalBytes = + constrained && constrained > 0 ? constrained : os.totalmem(); + const totalMB = Math.floor(totalBytes / (1024 * 1024)); + const targetMB = Math.min(Math.floor(totalMB * 0.5), 16_384); + const currentLimitMB = Math.floor( + getHeapStatistics().heap_size_limit / (1024 * 1024), + ); + cachedMemoryArgs = + targetMB > currentLimitMB ? [`--max-old-space-size=${targetMB}`] : []; + return cachedMemoryArgs; +} + +// ────────────────────────────────────────────────────────────────────── +// Stderr forwarder — extracted from the inline handler so it's testable +// in isolation without spawning a real child process. +// ────────────────────────────────────────────────────────────────────── + +export interface StderrForwarderOptions { + prefix: string; + onDiagnosticLine?: (line: string, level?: 'info' | 'warn' | 'error') => void; +} + +/** + * Creates a stateful forwarder that buffers incoming chunks, splits on + * newlines, writes each complete line to `process.stderr` with a prefix, + * and optionally invokes `onDiagnosticLine` for external consumers (e.g. + * the daemon log file writer). + * + * Cap behavior: if the unterminated buffer exceeds 64 KiB the excess is + * force-flushed with a `[truncated]` marker — same memory-bounding + * behavior as before the extraction. + */ +export function createStderrForwarder(opts: StderrForwarderOptions): { + onData: (chunk: string) => void; + onEnd: () => void; +} { + const { prefix, onDiagnosticLine } = opts; + const STDERR_LINE_CAP_CHARS = 64 * 1024; + let buf = ''; + + const flush = (line: string) => { + if (line.length > 0) { + process.stderr.write(prefix + line + '\n'); + if (onDiagnosticLine) onDiagnosticLine(prefix + line, 'warn'); + } + }; + + return { + onData(chunk: string) { + buf += chunk; + let nl = buf.indexOf('\n'); + while (nl !== -1) { + flush(buf.slice(0, nl)); + buf = buf.slice(nl + 1); + nl = buf.indexOf('\n'); + } + // Force-flush the unterminated tail if it's grown past the cap + // — keeps memory bounded against a `\n`-less stderr storm. + while (buf.length > STDERR_LINE_CAP_CHARS) { + const truncated = buf.slice(0, STDERR_LINE_CAP_CHARS) + ' [truncated]'; + process.stderr.write(prefix + truncated + '\n'); + if (onDiagnosticLine) onDiagnosticLine(prefix + truncated, 'warn'); + buf = buf.slice(STDERR_LINE_CAP_CHARS); + } + }, + onEnd() { + if (buf.length > 0) flush(buf); + }, + }; +} + +// ────────────────────────────────────────────────────────────────────── +// SpawnChannelFactory — configurable factory-of-factories +// ────────────────────────────────────────────────────────────────────── + +export interface SpawnChannelFactoryOptions { + onDiagnosticLine?: (line: string, level?: 'info' | 'warn' | 'error') => void; +} + +/** + * Creates a `ChannelFactory` that spawns `qwen --acp` child processes. + * Accepts an optional `onDiagnosticLine` callback that receives every + * child-stderr line (already prefixed) so callers can tee to a log file + * or structured logger without intercepting process.stderr globally. + * + * `defaultSpawnChannelFactory` below is `createSpawnChannelFactory()` — + * no options, same behavior as before this refactor. + */ +export function createSpawnChannelFactory( + options: SpawnChannelFactoryOptions = {}, +): ChannelFactory { + return async (workspaceCwd, childEnvOverrides) => { + const cliEntry = process.env['QWEN_CLI_ENTRY'] || process.argv[1]; + if (!cliEntry) { + throw new MissingCliEntryError(); + } + const childEnv = scrubChildEnv( + process.env, + SCRUBBED_CHILD_ENV_KEYS, + childEnvOverrides, + ); + childEnv['QWEN_CODE_NO_RELAUNCH'] = 'true'; + + const memoryArgs = getAcpMemoryArgs(); + const child = spawn(process.execPath, [...memoryArgs, cliEntry, '--acp'], { + cwd: workspaceCwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: childEnv, + }); + + // Forward child stderr to the daemon's stderr line-by-line, with a + // `[serve pid=… cwd=…]` prefix on each line so operators can + // correlate stack traces back to the spawning request. + if (child.stderr) { + const prefix = `[serve pid=${child.pid} cwd=${workspaceCwd}] `; + const forwarder = createStderrForwarder({ + prefix, + onDiagnosticLine: options.onDiagnosticLine, + }); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', forwarder.onData); + child.stderr.on('end', forwarder.onEnd); + child.stderr.on('error', () => { + // Don't crash the daemon if the pipe breaks; the child is + // already gone or about to be. + }); + } + + const exited = new Promise((resolve) => { + let resolved = false; + const finish = (info?: AcpChannelExitInfo) => { + if (resolved) return; + resolved = true; + resolve(info); + }; + child.once('exit', (code, signal) => + finish({ exitCode: code, signalCode: signal }), + ); + child.once('error', () => finish(undefined)); + }); + + if (!child.stdin || !child.stdout) { + child.kill('SIGKILL'); + throw new Error( + 'Spawned ACP child has no stdin/stdout — cannot establish NDJSON channel.', + ); + } + + const writable = Writable.toWeb(child.stdin) as WritableStream; + const readable = Readable.toWeb(child.stdout) as ReadableStream; + const stream = ndJsonStream(writable, readable); + + return { + stream, + kill: () => killChild(child), + killSync: () => { + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill('SIGKILL'); + } catch { + /* already dead / pid recycled — ignore */ + } + } + }, + exited, + }; + }; +} + +/** + * Default channel factory: spawn the current Node executable running this + * CLI's entry script in `--acp` mode. `process.argv[1]` resolves to the qwen + * entry script when launched via the `qwen` bin shim. + * + * Note on `cwd`: CodeQL flags the `workspaceCwd` flow into `spawn({cwd})` + * as an "uncontrolled data used in path expression" finding. That's the + * Stage 1 trust model speaking — the caller (a token-authenticated HTTP + * client) is treated as an extension of the operator. The agent already + * runs as the same UID with shell-tool access, so restricting the spawn + * cwd to a sandbox here would be theatre. Stage 4+ remote-sandbox swaps + * this factory for a sandbox-aware variant; see the remote-sandbox plan. + * + * Lifted from `cli/src/serve/httpAcpBridge.ts` to `@qwen-code/acp-bridge` + * so `channels/base/AcpBridge.ts` and the VSCode IDE + * companion can share one spawn implementation instead of each + * reimplementing the child lifecycle (the current divergence noted in + * `channel.ts`'s top-of-file comment). + * + * Preserved as `createSpawnChannelFactory()` (no options) for backward + * compat. Use `createSpawnChannelFactory({ onDiagnosticLine })` to also + * tee child stderr lines through an external callback. + */ +export const defaultSpawnChannelFactory: ChannelFactory = + createSpawnChannelFactory(); + +const KILL_HARD_DEADLINE_MS = 10_000; + +/** + * Environment variables stripped from the spawned `qwen --acp` child's + * environment. Everything else is passed through — see the + * threat-model rationale at the call site in `defaultSpawnChannelFactory`. + * + * Currently just `QWEN_SERVER_TOKEN`: the daemon's own bearer token, + * which the agent doesn't need (it speaks to the daemon over stdio, + * not HTTP). Leaving it in the child's env would let prompt injection + * turn the agent into an authenticated client of its own daemon — an + * escalation the agent doesn't otherwise have. + * + * **WARNING**: this denylist is correct *only because the agent + * already has unrestricted shell-tool access* — anything in the env + * is reachable via `~/.bashrc`/`~/.aws/credentials`/etc. anyway. + * Any future mode that **removes** shell-tool access (e.g. a + * sandbox-locked agent variant) MUST switch this back to an + * allowlist OR significantly expand the denylist to cover common + * provider/CI/cloud secret prefixes (`OPENAI_*`, `ANTHROPIC_*`, + * `AWS_*`, `GITHUB_TOKEN`, `CI_*`, `*_API_KEY`, `*_SECRET`, …). + * See the remote-sandbox plan for Stage 4+. + * + * Defined at module scope so the Set is allocated once at load. + */ +const SCRUBBED_CHILD_ENV_KEYS: ReadonlySet = new Set([ + 'QWEN_SERVER_TOKEN', +]); + +/** + * Build the env passed to the `qwen --acp` child. Pure function, exported + * for unit-test access (the surrounding `defaultSpawnChannelFactory` is + * unit-test-hostile because it actually spawns Node). Behavior: + * + * 1. Start from a shallow clone of `source` (no aliasing into the + * daemon's `process.env`). + * 2. Delete every key listed in `scrubbed` (the daemon-internal secret + * denylist — currently just `QWEN_SERVER_TOKEN`, see security + * rationale on the constant). + * 3. Apply `overrides` per-handle. `undefined` value deletes the key + * (lets an embedded caller scrub a stale inherited var without + * mutating the daemon's global `process.env`). Anything else + * assigns. **`overrides` CANNOT re-introduce a scrubbed key** — + * defense-in-depth so an operator passing + * `{ QWEN_SERVER_TOKEN: 'x' }` in overrides can't smuggle the + * daemon's bearer token back into the child. + * + * Used by `defaultSpawnChannelFactory` above. The split mirrors the + * "scrub" comment block's structure 1:1; behavior is byte-identical to + * the pre-extraction inline implementation. + */ +export function scrubChildEnv( + source: NodeJS.ProcessEnv, + scrubbed: ReadonlySet, + overrides?: Readonly>, +): NodeJS.ProcessEnv { + const childEnv: NodeJS.ProcessEnv = { ...source }; + for (const key of scrubbed) { + delete childEnv[key]; + } + if (overrides) { + for (const [key, value] of Object.entries(overrides)) { + if (scrubbed.has(key)) continue; + if (value === undefined) { + delete childEnv[key]; + } else { + childEnv[key] = value; + } + } + } + return childEnv; +} + +function killChild(child: ChildProcess): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + let resolved = false; + const finish = () => { + if (resolved) return; + resolved = true; + child.removeListener('exit', finish); + resolve(); + }; + child.once('exit', finish); + try { + child.kill('SIGTERM'); + } catch { + finish(); + return; + } + setTimeout(() => { + if (!resolved && child.exitCode === null && child.signalCode === null) { + try { + child.kill('SIGKILL'); + } catch { + /* swallow */ + } + } + }, 5_000).unref(); + // Even SIGKILL doesn't return if the child is in uninterruptible + // sleep (D-state, e.g. NFS read blocked on a dead server). Without + // this hard deadline, `bridge.shutdown()`'s `Promise.all` waits + // forever on that one wedged child and SHUTDOWN_FORCE_CLOSE_MS in + // `runQwenServe` only covers `server.close()`, not the bridge. + // After the deadline give up: the child is probably stuck in a + // kernel call we can't cancel, and `process.exit(0)` will reap it + // when the daemon returns to its caller. + // + // Emit a stderr line BEFORE we + // abandon the child so operators see a signal that a zombie + // exists. Without this, `shutdown()` returns "graceful" while a + // wedged `qwen --acp` process keeps holding FDs / memory / locks; + // under systemd/k8s supervision, the daemon respawn would then + // race the orphan for the same workspace. Single-line warning is + // intentionally noisy on the daemon's stderr so monitoring/log + // aggregators catch it. + setTimeout(() => { + if (!resolved) { + process.stderr.write( + `qwen serve: killChild hard deadline (${KILL_HARD_DEADLINE_MS}ms) ` + + `reached; child pid=${child.pid} still alive (uninterruptible sleep?) — ` + + `abandoning. Operator should check for zombie qwen --acp processes ` + + `holding workspace resources.\n`, + ); + finish(); + } + }, KILL_HARD_DEADLINE_MS).unref(); + }); +} diff --git a/packages/acp-bridge/src/status.test.ts b/packages/acp-bridge/src/status.test.ts index e2040400df..8d61d0ff5b 100644 --- a/packages/acp-bridge/src/status.test.ts +++ b/packages/acp-bridge/src/status.test.ts @@ -20,9 +20,9 @@ describe('SERVE_ERROR_KINDS', () => { // kinds; PR 14 added `'budget_exhausted'` for MCP guardrail // refusals (see #4175 PR 14); PR 16 added `'stat_failed'` for // non-ENOENT stat failures on workspace memory discovery (see - // #4175 PR 16). Future additions append to this list — the - // order is part of the contract so SDK consumers can pattern- - // match without per-kind lookups. + // #4175 PR 16). Issue #4514 T2.8 added three runtime-mutation + // error kinds; T2.9 appended prompt_deadline_exceeded and + // writer_idle_timeout. Future additions append to this list. expect(SERVE_ERROR_KINDS).toEqual([ 'missing_binary', 'blocked_egress', @@ -33,15 +33,26 @@ describe('SERVE_ERROR_KINDS', () => { 'parse_error', 'stat_failed', 'budget_exhausted', + 'mcp_budget_would_exceed', + 'mcp_server_spawn_failed', + 'invalid_config', + 'prompt_deadline_exceeded', + 'writer_idle_timeout', ]); }); + + it('exposes T2.8 error kinds in SERVE_ERROR_KINDS', () => { + expect(SERVE_ERROR_KINDS).toContain('mcp_budget_would_exceed'); + expect(SERVE_ERROR_KINDS).toContain('mcp_server_spawn_failed'); + expect(SERVE_ERROR_KINDS).toContain('invalid_config'); + }); }); describe('BridgeTimeoutError', () => { it('preserves the legacy message format and exposes label/timeoutMs', () => { const err = new BridgeTimeoutError('init', 250); expect(err.name).toBe('BridgeTimeoutError'); - expect(err.message).toBe('HttpAcpBridge init timed out after 250ms'); + expect(err.message).toBe('AcpSessionBridge init timed out after 250ms'); expect(err.label).toBe('init'); expect(err.timeoutMs).toBe(250); expect(err).toBeInstanceOf(Error); diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 7d78ffd48f..fc5294f8a9 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -5,6 +5,7 @@ */ import type { AvailableCommand } from '@agentclientprotocol/sdk'; +import type { HookEventName } from '@qwen-code/qwen-code-core'; import { SkillError } from '@qwen-code/qwen-code-core'; export const STATUS_SCHEMA_VERSION = 1 as const; @@ -24,10 +25,17 @@ export const SERVE_ERROR_KINDS = [ 'missing_file', 'parse_error', 'stat_failed', - // Issue #4175 PR 14: budget refusal under `--mcp-budget-mode=enforce`. + // Budget refusal under `--mcp-budget-mode=enforce`. // Surfaced on per-server `mcp_server` cells (refused at discovery) // and on the workspace-level `mcp_budget` cell (any refusal this pass). 'budget_exhausted', + // Runtime MCP mutation routes + 'mcp_budget_would_exceed', + 'mcp_server_spawn_failed', + 'invalid_config', + // Prompt deadline + writer idle timeout + 'prompt_deadline_exceeded', + 'writer_idle_timeout', ] as const; export type ServeErrorKind = (typeof SERVE_ERROR_KINDS)[number]; @@ -41,7 +49,7 @@ export class BridgeTimeoutError extends Error { readonly label: string; readonly timeoutMs: number; constructor(label: string, timeoutMs: number) { - super(`HttpAcpBridge ${label} timed out after ${timeoutMs}ms`); + super(`AcpSessionBridge ${label} timed out after ${timeoutMs}ms`); this.name = 'BridgeTimeoutError'; this.label = label; this.timeoutMs = timeoutMs; @@ -88,25 +96,49 @@ export class MissingCliEntryError extends Error { export const SERVE_STATUS_EXT_METHODS = { workspaceMcp: 'qwen/status/workspace/mcp', + workspaceMcpTools: 'qwen/status/workspace/mcp/tools', workspaceSkills: 'qwen/status/workspace/skills', + workspaceTools: 'qwen/status/workspace/tools', workspaceProviders: 'qwen/status/workspace/providers', workspaceMemory: 'qwen/status/workspace/memory', workspaceAgents: 'qwen/status/workspace/agents', workspacePreflight: 'qwen/status/workspace/preflight', sessionContext: 'qwen/status/session/context', + sessionContextUsage: 'qwen/status/session/context_usage', sessionSupportedCommands: 'qwen/status/session/supported_commands', + sessionTasks: 'qwen/status/session/tasks', + sessionStats: 'qwen/status/session/stats', + sessionRewindSnapshots: 'qwen/status/session/rewind_snapshots', + workspaceHooks: 'qwen/status/workspace/hooks', + sessionHooks: 'qwen/status/session/hooks', + workspaceExtensions: 'qwen/status/workspace/extensions', } as const; /** - * Control-plane (mutation) ACP extMethods introduced in #4175 Wave 4 PR 17. + * Control-plane (mutation) ACP extMethods introduced in Mutation control. * Distinct from `SERVE_STATUS_EXT_METHODS` so reviewers can grep mutation * surface independently from read-only diagnostics. Each route in * `server.ts` forwards through the matching extMethod into `acpAgent.ts` * which then mutates Config / ToolRegistry / McpClientManager state. */ export const SERVE_CONTROL_EXT_METHODS = { + sessionClose: 'qwen/control/session/close', sessionApprovalMode: 'qwen/control/session/approval_mode', + sessionBranch: 'qwen/control/session/branch', + sessionRecap: 'qwen/control/session/recap', + sessionBtw: 'qwen/control/session/btw', + sessionShellHistory: 'qwen/control/session/shell_history', + sessionLanguage: 'qwen/control/session/language', + sessionRewind: 'qwen/control/session/rewind', workspaceMcpRestart: 'qwen/control/workspace/mcp/restart', + workspaceMcpManage: 'qwen/control/workspace/mcp/manage', + workspaceAgentGenerate: 'qwen/control/workspace/agents/generate', + // Runtime MCP server mutation ext-methods + sessionTaskCancel: 'qwen/control/session/task/cancel', + sessionGoalClear: 'qwen/control/session/goal/clear', + workspaceMcpRuntimeAdd: 'qwen/control/workspace/mcp/runtime-add', + workspaceMcpRuntimeRemove: 'qwen/control/workspace/mcp/runtime-remove', + workspaceReload: 'qwen/control/workspace/reload', } as const; export type ServeStatus = @@ -149,26 +181,66 @@ export interface ServeWorkspaceMcpServerStatus extends ServeStatusCell { mcpStatus?: ServeMcpServerRuntimeStatus; transport: ServeMcpTransport; disabled: boolean; + hasOAuthTokens?: boolean; + source?: 'user' | 'project' | 'extension'; + config?: { + command?: string; + args?: string[]; + httpUrl?: string; + url?: string; + cwd?: string; + }; description?: string; extensionName?: string; /** * Why this server is not live, when known. Distinguishes * operator-disabled (`disabled: true` from `disabledMcpServers` - * config) from PR 14 budget-refused (`status: 'error', errorKind: + * config) from The budget feature budget-refused (`status: 'error', errorKind: * 'budget_exhausted'`). Operators dashboarding the workspace * shouldn't have to cross-reference the `errors[]` or `budgets[]` * arrays to render a per-server row correctly. */ disabledReason?: 'config' | 'budget'; + /** + * Pool-mode workspaces can hold multiple + * `PoolEntry` instances under the same `name` when sessions inject + * different fingerprints (e.g. per-session OAuth headers). Absent on + * older daemons and on daemons with `QWEN_SERVE_NO_MCP_POOL=1`; + * present (≥1) when the pool advertises `mcp_workspace_pool`. + * Operators use this to render an "N entries" badge or drill into + * `entrySummary` for the per-entry breakdown. + */ + entryCount?: number; + /** + * Per-entry breakdown for multi-entry server + * names. `entryIndex` is a stable opaque integer assigned at entry + * creation (V21-7) — NOT the raw fingerprint, which would leak + * OAuth/env rotation timing through snapshot diffs. `refs` is the + * count of sessions currently attached. `status` is the per-entry + * runtime status (`connected` / `connecting` / `disconnected`) so + * dashboards can show per-entry health when the aggregated + * `mcpStatus` rolls up to `connected` while one entry is still + * reconnecting. + * + * Old SDK clients ignore the field per the additive-only protocol + * contract; new clients gate UI on `entryCount > 1`. The pair + * (`entryCount`, `entrySummary`) is always present together when + * advertised — `mcp_workspace_pool` capability tag implies both. + */ + entrySummary?: ReadonlyArray<{ + entryIndex: number; + refs: number; + status: ServeMcpServerRuntimeStatus; + }>; } -/** Budget mode for the MCP client guardrails (issue #4175 PR 14). */ +/** Budget mode for the MCP client guardrails. */ export type ServeMcpBudgetMode = 'enforce' | 'warn' | 'off'; /** * Workspace-level budget status cell. Surfaced as one entry in * `ServeWorkspaceMcpStatus.budgets[]`. The list shape (vs a single - * `budget?` field) is forward-compat for Wave 5 PR 23, which will + * `budget?` field) is forward-compat for a future change that may * add a `scope: 'pool'` cell alongside without a schema bump. * * Consumers MUST tolerate additional entries with unrecognized @@ -179,16 +251,16 @@ export interface ServeMcpBudgetStatusCell extends ServeStatusCell { /** * Identifies which accounting scope this cell describes. * - * **PR 14 v1 emits `'session'`** because each ACP session creates + * **The budget feature v1 emits `'session'`** because each ACP session creates * its own `Config`/`McpClientManager` via `acpAgent.newSessionConfig()` * — so the budget caps live MCP clients **per session**, not * per-workspace. The snapshot reflects the bootstrap session's * view; concurrent sessions each enforce their own copy of the - * cap independently. See `qwen-serve-protocol.md` "PR 14 v1 + * cap independently. See `qwen-serve-protocol.md` "The budget feature v1 * scope: per-session" for the operator-facing rationale. * * Future PRs: - * - Wave 5 PR 23 (shared MCP pool) introduces a workspace-scoped + * - A future shared MCP pool may introduce a workspace-scoped * manager and will emit `'workspace'` (or `'pool'`) cells. * - The `string & {}` widening keeps IDE autocomplete + literal * narrowing for known scopes while allowing unknown scopes @@ -214,20 +286,40 @@ export interface ServeWorkspaceMcpStatus { discoveryState?: ServeMcpDiscoveryState; servers: ServeWorkspaceMcpServerStatus[]; errors?: ServeStatusCell[]; - /** PR 14: live MCP client count (sum across all transports). */ + /** The budget feature: live MCP client count (sum across all transports). */ clientCount?: number; - /** PR 14: configured budget. Absent when no cap was set. */ + /** The budget feature: configured budget. Absent when no cap was set. */ clientBudget?: number; - /** PR 14: active enforcement mode. Absent on pre-PR-14 daemons. */ + /** The budget feature: active enforcement mode. Absent on older daemons. */ budgetMode?: ServeMcpBudgetMode; /** - * PR 14: workspace-level status cells for budget enforcement. Always - * an array (possibly empty) on post-PR-14 daemons; absent on older - * daemons. PR 23 will add a `scope: 'pool'` cell alongside. + * The budget feature: workspace-level status cells for budget enforcement. Always + * an array (possibly empty) on newer daemons; absent on older + * daemons. A future version may add a `scope: 'pool'` cell alongside. */ budgets?: ServeMcpBudgetStatusCell[]; } +export interface ServeWorkspaceMcpToolStatus { + name: string; + serverToolName?: string; + description?: string; + schema?: Record; + annotations?: Record; + isValid: boolean; + invalidReason?: string; +} + +export interface ServeWorkspaceMcpToolsStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + serverName: string; + initialized: boolean; + acpChannelLive: boolean; + tools: ServeWorkspaceMcpToolStatus[]; + errors?: ServeStatusCell[]; +} + export type ServeSkillLevel = 'project' | 'user' | 'extension' | 'bundled'; export interface ServeWorkspaceSkillStatus extends ServeStatusCell { @@ -252,6 +344,8 @@ export interface ServeWorkspaceSkillsStatus { export interface ServeWorkspaceProviderCurrent { authType?: string; modelId?: string; + baseUrl?: string; + fastModelId?: string; } export interface ServeWorkspaceProviderModel { @@ -260,6 +354,14 @@ export interface ServeWorkspaceProviderModel { name: string; description?: string | null; contextLimit?: number; + modalities?: { + image?: boolean; + pdf?: boolean; + audio?: boolean; + video?: boolean; + }; + baseUrl?: string; + envKey?: string; isCurrent: boolean; isRuntime: boolean; } @@ -292,6 +394,55 @@ export interface ServeSessionContextStatus { }; } +export interface ServeContextCategoryBreakdown { + systemPrompt: number; + builtinTools: number; + mcpTools: number; + memoryFiles: number; + skills: number; + messages: number; + freeSpace: number; + autocompactBuffer: number; +} + +export interface ServeContextToolDetail { + name: string; + tokens: number; +} + +export interface ServeContextMemoryDetail { + path: string; + tokens: number; +} + +export interface ServeContextSkillDetail { + name: string; + tokens: number; + loaded?: boolean; + bodyTokens?: number; +} + +export interface ServeSessionContextUsage { + modelName: string; + totalTokens: number; + contextWindowSize: number; + breakdown: ServeContextCategoryBreakdown; + builtinTools: ServeContextToolDetail[]; + mcpTools: ServeContextToolDetail[]; + memoryFiles: ServeContextMemoryDetail[]; + skills: ServeContextSkillDetail[]; + isEstimated?: boolean; + showDetails?: boolean; +} + +export interface ServeSessionContextUsageStatus { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + workspaceCwd: string; + usage: ServeSessionContextUsage; + formattedText: string; +} + export interface ServeSessionSupportedCommandsStatus { v: typeof STATUS_SCHEMA_VERSION; sessionId: string; @@ -299,11 +450,140 @@ export interface ServeSessionSupportedCommandsStatus { availableSkills: string[]; } +export type ServeSessionTaskLifecycleStatus = + | 'running' + | 'paused' + | 'completed' + | 'failed' + | 'cancelled'; + +export type ServeSessionProcessTaskLifecycleStatus = + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface ServeSessionAgentTaskStatus { + kind: 'agent'; + id: string; + label: string; + description: string; + status: ServeSessionTaskLifecycleStatus; + startTime: number; + endTime?: number; + runtimeMs: number; + outputFile?: string; + subagentType?: string; + isBackgrounded: boolean; + error?: string; + resumeBlockedReason?: string; + stats?: { totalTokens: number; toolUses: number; durationMs: number }; + recentActivities?: Array<{ name: string; description: string; at: number }>; + prompt?: string; +} + +export interface ServeSessionShellTaskStatus { + kind: 'shell'; + id: string; + label: string; + description: string; + status: ServeSessionProcessTaskLifecycleStatus; + startTime: number; + endTime?: number; + runtimeMs: number; + outputFile?: string; + command: string; + cwd: string; + pid?: number; + exitCode?: number; + error?: string; +} + +export interface ServeSessionMonitorTaskStatus { + kind: 'monitor'; + id: string; + label: string; + description: string; + status: ServeSessionProcessTaskLifecycleStatus; + startTime: number; + endTime?: number; + runtimeMs: number; + command: string; + pid?: number; + eventCount: number; + lastEventTime: number; + droppedLines: number; + exitCode?: number; + error?: string; + ownerAgentId?: string; +} + +export type ServeSessionTaskStatus = + | ServeSessionAgentTaskStatus + | ServeSessionShellTaskStatus + | ServeSessionMonitorTaskStatus; + +export interface ServeSessionTasksStatus { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + now: number; + tasks: ServeSessionTaskStatus[]; +} + +export interface ServeSessionStatsModelMetrics { + api: { + totalRequests: number; + totalErrors: number; + totalLatencyMs: number; + }; + tokens: { + prompt: number; + candidates: number; + total: number; + cached: number; + thoughts: number; + }; +} + +export interface ServeSessionStatsToolByName { + count: number; + success: number; + fail: number; + durationMs: number; + decisions: { + accept: number; + reject: number; + modify: number; + auto_accept: number; + }; +} + +export interface ServeSessionStatsStatus { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + workspaceCwd: string; + sessionStartTimeMs: number; + durationMs: number; + promptCount: number; + models: Record; + tools: { + totalCalls: number; + totalSuccess: number; + totalFail: number; + totalDurationMs: number; + byName: Record; + }; + files: { + totalLinesAdded: number; + totalLinesRemoved: number; + }; +} + /** - * Issue #4175 PR 16: workspace memory + agents read surfaces. + * Workspace memory + agents read surfaces. * * Both shapes mirror the `kind / status / error? / errorKind? / hint?` - * cell pattern that PR 12's mcp/skills/providers status structures use, + * cell pattern that The mcp/skills/providers status structures use, * so the SDK reducer can render any of these with one pattern. */ @@ -389,6 +669,256 @@ export interface ServeWorkspaceAgentsStatus { errors?: ServeStatusCell[]; } +// --------------------------------------------------------------------------- +// Issue #4514 T3.9: workspace + session hooks diagnostic surfaces. +// --------------------------------------------------------------------------- + +export type ServeHookMatcherKind = + | 'toolName' + | 'agentType' + | 'trigger' + | 'sessionTrigger' + | 'error' + | 'notificationType' + | 'commandName' + | 'filePath'; + +export interface ServeHookEventMeta { + description: string; + matcherKind?: ServeHookMatcherKind; +} + +export interface ServeCommandHookConfig { + type: 'command'; + command: string; + name?: string; + description?: string; + timeout?: number; + env?: Record; + async?: boolean; + shell?: 'bash' | 'powershell'; + statusMessage?: string; +} + +export interface ServeHttpHookConfig { + type: 'http'; + url: string; + name?: string; + description?: string; + timeout?: number; + headers?: Record; + allowedEnvVars?: string[]; + if?: string; + statusMessage?: string; + once?: boolean; +} + +export interface ServeFunctionHookConfig { + type: 'function'; + id?: string; + name?: string; + description?: string; + timeout?: number; + errorMessage?: string; + statusMessage?: string; +} + +export interface ServePromptHookConfig { + type: 'prompt'; + prompt: string; + name?: string; + description?: string; + timeout?: number; + model?: string; + statusMessage?: string; +} + +export interface ServeUnknownHookConfig { + type: string; + name?: string; + description?: string; + timeout?: number; + statusMessage?: string; +} + +export type ServeHookConfig = + | ServeCommandHookConfig + | ServeHttpHookConfig + | ServeFunctionHookConfig + | ServePromptHookConfig + | ServeUnknownHookConfig; + +export type ServeHookSource = + | 'project' + | 'user' + | 'system' + | 'extensions' + | 'session'; + +export interface ServeHookEntry { + kind: 'hook'; + eventName: string; + config: ServeHookConfig; + source: ServeHookSource; + matcher?: string; + sequential?: boolean; + enabled: boolean; + hookId?: string; + skillRoot?: string; +} + +export interface ServeWorkspaceHooksStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + initialized: boolean; + disabled: boolean; + hooks: ServeHookEntry[]; + events: Record; + errors?: ServeStatusCell[]; +} + +export interface ServeSessionHooksStatus { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + workspaceCwd: string; + disabled: boolean; + hooks: ServeHookEntry[]; + errors?: ServeStatusCell[]; +} + +export const IDLE_HOOK_EVENTS: Record = { + PreToolUse: { description: 'Before tool execution', matcherKind: 'toolName' }, + PostToolUse: { description: 'After tool execution', matcherKind: 'toolName' }, + PostToolUseFailure: { + description: 'After tool execution fails', + matcherKind: 'toolName', + }, + PostToolBatch: { description: 'After a batch of tool calls resolves' }, + Notification: { + description: 'When notifications are sent', + matcherKind: 'notificationType', + }, + UserPromptSubmit: { description: 'When the user submits a prompt' }, + UserPromptExpansion: { + description: 'When a slash command expands into a prompt', + matcherKind: 'commandName', + }, + SessionStart: { + description: 'When a new session is started', + matcherKind: 'sessionTrigger', + }, + Stop: { description: 'Right before Qwen Code concludes its response' }, + SubagentStart: { + description: 'When a subagent is started', + matcherKind: 'agentType', + }, + SubagentStop: { + description: 'Right before a subagent concludes its response', + matcherKind: 'agentType', + }, + PreCompact: { + description: 'Before conversation compaction', + matcherKind: 'trigger', + }, + PostCompact: { + description: 'After conversation compaction', + matcherKind: 'trigger', + }, + SessionEnd: { + description: 'When a session is ending', + matcherKind: 'sessionTrigger', + }, + PermissionRequest: { + description: 'When a permission dialog is displayed', + matcherKind: 'toolName', + }, + PermissionDenied: { + description: 'When a tool call is denied', + matcherKind: 'toolName', + }, + StopFailure: { + description: 'When the turn ends due to an API error', + matcherKind: 'error', + }, + TodoCreated: { description: 'When a new todo item is created' }, + TodoCompleted: { description: 'When a todo item is marked as completed' }, + InstructionsLoaded: { + description: 'When an instruction or context file is loaded', + matcherKind: 'filePath', + }, +}; + +// --------------------------------------------------------------------------- +// Workspace extensions diagnostic surface. +// --------------------------------------------------------------------------- + +export type ServeExtensionInstallType = + | 'git' + | 'local' + | 'link' + | 'github-release' + | 'npm'; + +export type ServeExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini'; + +export interface ServeExtensionCapabilities { + mcpServerCount: number; + skillCount: number; + agentCount: number; + hookCount: number; + commandCount: number; + contextFileCount: number; + channelCount: number; + hasSettings: boolean; +} + +export interface ServeExtensionEntry { + kind: 'extension'; + id: string; + name: string; + version: string; + isActive: boolean; + path: string; + source?: string; + installType?: ServeExtensionInstallType; + originSource?: ServeExtensionOriginSource; + ref?: string; + autoUpdate?: boolean; + capabilities: ServeExtensionCapabilities; +} + +export interface ServeWorkspaceExtensionsStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + initialized: boolean; + extensions: ServeExtensionEntry[]; + errors?: ServeStatusCell[]; +} + +export function createIdleWorkspaceExtensionsStatus( + workspaceCwd: string, +): ServeWorkspaceExtensionsStatus { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: false, + extensions: [], + }; +} + +export function createIdleWorkspaceHooksStatus( + workspaceCwd: string, +): ServeWorkspaceHooksStatus { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: false, + disabled: false, + hooks: [], + events: IDLE_HOOK_EVENTS, + }; +} + export function createIdleWorkspaceMemoryStatus( workspaceCwd: string, ): ServeWorkspaceMemoryStatus { @@ -416,7 +946,7 @@ export function createIdleWorkspaceAgentsStatus( export function createIdleWorkspaceMcpStatus( workspaceCwd: string, ): ServeWorkspaceMcpStatus { - // PR 14: an idle workspace has zero live clients and no enforcement + // The budget feature: an idle workspace has zero live clients and no enforcement // pressure. `budgetMode` is `'off'` (regardless of how the operator // configured it) because no discovery has run, so no reservation // could have happened. `budgets` is an empty array, not absent — @@ -458,7 +988,7 @@ export function createIdleWorkspaceProvidersStatus( } /** - * #4175 PR 22b/2: idle envelope for `/workspace/env` when the bridge + * Idle envelope for `/workspace/env` when the bridge * has no `DaemonStatusProvider` injected (Mode A in-process consumers, * tests, embedded callers that don't need daemon-host cells). Single * construction site so future optional-field additions to @@ -495,7 +1025,8 @@ export type ServeEnvKind = | 'platform' | 'sandbox' | 'proxy' - | 'env_var'; + | 'env_var' + | 'memory'; export interface ServeEnvCell extends ServeStatusCell { kind: ServeEnvKind; @@ -517,6 +1048,22 @@ export interface ServeWorkspaceEnvStatus { errors?: ServeStatusCell[]; } +export interface ServeWorkspaceToolStatus { + name: string; + displayName?: string; + description?: string; + enabled: boolean; +} + +export interface ServeWorkspaceToolsStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + initialized: true; + acpChannelLive: boolean; + tools: ServeWorkspaceToolStatus[]; + errors?: ServeStatusCell[]; +} + /** * Discriminant for diagnostic cells emitted by `/workspace/preflight`. Cells * with `locality: 'daemon'` are answered by the bridge process directly and @@ -646,7 +1193,7 @@ export function mapDomainErrorToErrorKind( // dropping the skill `errorKind` classification on diagnostic cells. // The `OR .name === 'SkillError'` branch keeps classification working // regardless of which copy of the class the value carries. - // Wenshao review fold-in (#4298 thread r3262781757). + if ( err instanceof SkillError || (err as Error | undefined)?.name === 'SkillError' diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 1e099ce296..fa1398e9e9 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -270,6 +270,46 @@ export abstract class ChannelBase { this.config.cwd, ); + // 3.5. Bang (!) shell command — direct execution, no LLM + if (envelope.text.startsWith('!')) { + const cmd = envelope.text.slice(1).trim(); + const bridgeShellCommand = (this.bridge as unknown as Record)['shellCommand']; + if (cmd && typeof bridgeShellCommand === 'function') { + try { + const result = (await bridgeShellCommand(sessionId, cmd)) as { + exitCode: number | null; + output: string; + aborted: boolean; + }; + const longestRun = Math.max( + 0, + ...Array.from( + (result.output || '').matchAll(/`+/g), + (m) => m[0].length, + ), + ); + const fence = '`'.repeat(Math.max(3, longestRun + 1)); + const output = result.output + ? `${fence}\n${result.output}\n${fence}` + : '(no output)'; + const exitLine = + result.exitCode !== null && result.exitCode !== 0 + ? `\nExit code: ${result.exitCode}` + : ''; + await this.sendMessage( + envelope.chatId, + `$ ${cmd}\n${output}${exitLine}`, + ); + } catch (error) { + await this.sendMessage( + envelope.chatId, + `Shell command failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return; + } + } + // Prepend referenced (quoted) message text for reply context let promptText = envelope.text; if (envelope.referencedText) { diff --git a/packages/channels/base/src/DaemonChannelBridge.ts b/packages/channels/base/src/DaemonChannelBridge.ts index 04c04fe1e9..493c836968 100644 --- a/packages/channels/base/src/DaemonChannelBridge.ts +++ b/packages/channels/base/src/DaemonChannelBridge.ts @@ -37,6 +37,10 @@ export interface DaemonChannelSessionClient { requestId: string, response: RequestPermissionResponse, ): Promise; + shellCommand?( + command: string, + signal?: AbortSignal, + ): Promise<{ exitCode: number | null; output: string; aborted: boolean }>; } export interface DaemonChannelSessionFactoryRequest { @@ -313,6 +317,18 @@ export class DaemonChannelBridge extends EventEmitter { } } + async shellCommand( + sessionId: string, + command: string, + signal?: AbortSignal, + ): Promise<{ exitCode: number | null; output: string; aborted: boolean }> { + const session = this.ensureSession(sessionId); + if (!session.shellCommand) { + throw new Error('Shell command not supported by this session client'); + } + return session.shellCommand(command, signal); + } + async cancelSession(sessionId: string): Promise { const session = this.ensureSession(sessionId); await session.cancel(); @@ -459,13 +475,13 @@ export class DaemonChannelBridge extends EventEmitter { case 'client_evicted': this.dropSession( session.sessionId, - this.getReason(event.data, 'client_evicted'), + this.getStringField(event.data, 'reason', 'client_evicted'), ); break; case 'stream_error': this.dropSession( session.sessionId, - this.getError(event.data, 'stream_error'), + this.getStringField(event.data, 'error', 'stream_error'), ); break; default: @@ -643,7 +659,10 @@ export class DaemonChannelBridge extends EventEmitter { } private handleSessionDied(sessionId: string, data: unknown): void { - this.dropSession(sessionId, this.getReason(data, 'session_died')); + this.dropSession( + sessionId, + this.getStringField(data, 'reason', 'session_died'), + ); } private dropSession(sessionId: string, reason: string): void { @@ -674,15 +693,13 @@ export class DaemonChannelBridge extends EventEmitter { this.emit('sessionDied', { sessionId, reason }); } - private getReason(data: unknown, fallback: string): string { - return isRecord(data) && typeof data['reason'] === 'string' - ? data['reason'] - : fallback; - } - - private getError(data: unknown, fallback: string): string { - return isRecord(data) && typeof data['error'] === 'string' - ? data['error'] + private getStringField( + data: unknown, + field: string, + fallback: string, + ): string { + return isRecord(data) && typeof data[field] === 'string' + ? (data[field] as string) : fallback; } diff --git a/packages/cli/package.json b/packages/cli/package.json index 37a56fa51f..93f49359e2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -81,6 +81,7 @@ "undici": "^6.22.0", "update-notifier": "^7.3.1", "wrap-ansi": "^10.0.0", + "ws": "^8.18.0", "yargs": "^17.7.2", "zod": "^3.23.8" }, @@ -94,6 +95,7 @@ "@types/express": "^5.0.3", "@types/node": "^22.0.0", "@types/prompts": "^2.4.9", + "@types/ws": "^8.5.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/semver": "^7.7.0", diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 72dc131cf0..997ab0ef7e 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -56,6 +56,13 @@ vi.mock('@agentclientprotocol/sdk', () => ({ })), ndJsonStream: vi.fn().mockReturnValue({}), RequestError: class RequestError extends Error { + code: number; + data: unknown; + constructor(code: number, message: string, data?: unknown) { + super(message); + this.code = code; + this.data = data; + } static authRequired = vi .fn() .mockImplementation((data: unknown, msg: string) => { @@ -70,6 +77,18 @@ vi.mock('@agentclientprotocol/sdk', () => ({ Object.assign(err, data); return err; }); + static internalError = vi + .fn() + .mockImplementation((data: unknown, msg: string) => { + const err = new Error(msg); + Object.assign(err, { code: -32603, data }); + return err; + }); + static methodNotFound = vi.fn().mockImplementation((method: string) => { + const err = new Error(`Method not found: ${method}`); + Object.assign(err, { code: -32601 }); + return err; + }); static resourceNotFound = vi.fn().mockImplementation((uri: string) => { const err = new Error(`Resource not found: ${uri}`); Object.assign(err, { code: -32002, data: { uri } }); @@ -196,6 +215,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ applyProviderInstallPlan: vi.fn().mockResolvedValue({ updatedModelProviders: {}, }), + unregisterGoalHook: vi.fn(), clearCachedCredentialFile: vi.fn(), getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), getAutoMemoryRoot: vi.fn( @@ -213,6 +233,9 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ CONNECTING: 'connecting', CONNECTED: 'connected', }, + MCPOAuthTokenStorage: vi.fn().mockImplementation(() => ({ + getCredentials: vi.fn().mockResolvedValue(null), + })), // SkillError is referenced by status.ts's `mapDomainErrorToErrorKind` // helper for `instanceof` classification. The mock must surface it as // a real class so that `instanceof` works inside the helper. @@ -229,6 +252,25 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ MCPServerConfig: vi.fn().mockImplementation((...args: unknown[]) => ({ _args: args, })), + McpTransportPool: vi.fn().mockImplementation(() => ({ + drainAll: vi.fn().mockResolvedValue({ drained: 0, forced: 0, errors: [] }), + getSnapshot: vi.fn().mockReturnValue({ + total: 0, + subprocessCount: 0, + byName: {}, + }), + releaseSession: vi.fn(), + restartByName: vi.fn().mockResolvedValue([]), + getBudget: vi.fn().mockReturnValue(undefined), + })), + POOLED_TRANSPORTS_DEFAULT: new Set(['stdio', 'websocket']), + WorkspaceMcpBudget: vi.fn().mockImplementation(() => ({ + getReservedCount: vi.fn().mockReturnValue(0), + getBudget: vi.fn().mockReturnValue(undefined), + getMode: vi.fn().mockReturnValue('off'), + getRefusedServerNames: vi.fn().mockReturnValue([]), + })), + MCP_BUDGET_WARN_FRACTION: 0.75, SessionService: vi.fn(), Storage: { getGlobalQwenDir: vi.fn(() => '/tmp/qwen-global-test'), @@ -251,6 +293,12 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ ), SESSION_TITLE_MAX_LENGTH: 200, tokenLimit: vi.fn().mockReturnValue(128_000), + buildBackgroundEntryLabel: vi.fn( + (entry: { description: string; subagentType?: string }) => + entry.subagentType + ? `${entry.subagentType}: ${entry.description}` + : entry.description, + ), SessionStartSource: { Startup: 'startup', Resume: 'resume', @@ -262,6 +310,38 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ PromptInputExit: 'prompt_input_exit', Other: 'other', }, + // T2.8: error classes used by runtime MCP add/remove ext-method handlers + McpBudgetWouldExceedError: class McpBudgetWouldExceedError extends Error { + readonly code = 'mcp_budget_would_exceed' as const; + readonly serverName: string; + constructor(serverName: string) { + super(`Adding '${serverName}' would exceed workspace MCP budget`); + this.name = 'McpBudgetWouldExceedError'; + this.serverName = serverName; + } + }, + McpServerSpawnFailedError: class McpServerSpawnFailedError extends Error { + readonly code = 'mcp_server_spawn_failed' as const; + readonly serverName: string; + readonly details: Record; + constructor(serverName: string, details: Record) { + super(`Failed to spawn MCP server '${serverName}'`); + this.name = 'McpServerSpawnFailedError'; + this.serverName = serverName; + this.details = details; + } + }, + InvalidMcpConfigError: class InvalidMcpConfigError extends Error { + readonly code = 'invalid_config' as const; + readonly serverName: string; + readonly reason: string; + constructor(serverName: string, reason: string) { + super(`Invalid MCP server config for '${serverName}': ${reason}`); + this.name = 'InvalidMcpConfigError'; + this.serverName = serverName; + this.reason = reason; + } + }, })); const { mockHistoryReplay } = vi.hoisted(() => ({ @@ -311,6 +391,30 @@ vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn(), buildDisabledSkillNamesProvider: vi.fn(() => () => new Set()), })); +vi.mock('../ui/commands/contextCommand.js', () => ({ + collectContextData: vi.fn().mockResolvedValue({ + modelName: 'm', + showDetails: true, + contextWindowSize: 128000, + apiTotalTokens: 1000, + apiCachedTokens: 200, + systemPromptTokens: 500, + allToolsTokens: 300, + displayBuiltinToolsTokens: 100, + displayMcpToolsTokens: 200, + skillToolDefinitionTokens: 0, + loadedSkillBodiesTokens: 0, + memoryFilesTokens: 50, + categories: [], + builtinTools: [], + mcpTools: [], + memoryFiles: [], + skills: [], + }), + formatContextUsageText: vi + .fn() + .mockReturnValue('## Context Usage\nformatted'), +})); vi.mock('./session/Session.js', () => ({ Session: vi.fn(), buildAvailableCommandsSnapshot: vi.fn().mockResolvedValue({ @@ -328,7 +432,35 @@ vi.mock('../utils/acpModelUtils.js', () => ({ })); vi.mock('../utils/languageUtils.js', () => ({ updateOutputLanguageFile: vi.fn(), + writeOutputLanguageAndRegisterPath: vi.fn( + ( + _value: string, + config?: { + getOutputLanguageFilePath(): string | undefined; + setOutputLanguageFilePath(p: string): void; + } | null, + ) => { + const p = config?.getOutputLanguageFilePath(); + if (!p) { + config?.setOutputLanguageFilePath('/mock/.qwen/output-language.md'); + } + }, + ), + getOutputLanguageFilePath: vi + .fn() + .mockReturnValue('/mock/.qwen/output-language.md'), + resolveOutputLanguage: vi.fn((v: string) => v), + isAutoLanguage: vi.fn(() => false), + OUTPUT_LANGUAGE_AUTO: 'auto', })); +vi.mock('../i18n/index.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + setLanguageAsync: vi.fn().mockResolvedValue(undefined), + getCurrentLanguage: vi.fn().mockReturnValue('zh'), + }; +}); import { runAcpAgent, @@ -352,17 +484,25 @@ import { getMCPDiscoveryState, getMCPServerStatus, tokenLimit, + McpBudgetWouldExceedError, buildInstallPlan, applyProviderInstallPlan, Storage, + unregisterGoalHook, } from '@qwen-code/qwen-code-core'; import type { McpServer } from '@agentclientprotocol/sdk'; import { AgentSideConnection } from '@agentclientprotocol/sdk'; import { loadSettings } from '../config/settings.js'; import { loadCliConfig } from '../config/config.js'; import { Session, buildAvailableCommandsSnapshot } from './session/Session.js'; -import { SERVE_STATUS_EXT_METHODS } from '../serve/status.js'; -import { updateOutputLanguageFile } from '../utils/languageUtils.js'; +import { + SERVE_STATUS_EXT_METHODS, + SERVE_CONTROL_EXT_METHODS, +} from '../serve/status.js'; +import { + updateOutputLanguageFile, + writeOutputLanguageAndRegisterPath, +} from '../utils/languageUtils.js'; import { buildAuthMethods } from './authMethods.js'; describe('runAcpAgent shutdown cleanup', () => { @@ -916,7 +1056,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }), reloadModelProvidersConfig: vi.fn(), refreshAuth: vi.fn().mockResolvedValue(undefined), + getWorkspaceContext: vi.fn().mockReturnValue({}), + getDebugMode: vi.fn().mockReturnValue(false), + getToolRegistry: vi.fn().mockReturnValue(undefined), } as unknown as Config; + vi.mocked(loadSettings).mockReturnValue(makeSessionSettings()); processExitSpy = vi .spyOn(process, 'exit') @@ -1058,6 +1202,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { function makeSessionSettings() { return { merged: { mcpServers: {} }, + forScope: vi.fn().mockReturnValue({ settings: { mcpServers: {} } }), getUserHooks: vi.fn().mockReturnValue({}), getProjectHooks: vi.fn().mockReturnValue({}), } as unknown as LoadedSettings; @@ -1325,6 +1470,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { name: 'Qwen Plus', description: 'General coding model', contextLimit: 65_536, + baseUrl: 'https://secret.example.com', + envKey: 'DASHSCOPE_API_KEY', isCurrent: true, isRuntime: false, }, @@ -1332,8 +1479,6 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }, ], }); - expect(JSON.stringify(providers)).not.toContain('secret.example.com'); - expect(JSON.stringify(providers)).not.toContain('DASHSCOPE_API_KEY'); mockConnectionState.resolve(); await agentPromise; @@ -1678,6 +1823,76 @@ describe('QwenAgent MCP SSE/HTTP support', () => { it('status ext methods expose live session context and supported commands', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); + const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(5_000); + Object.assign(innerConfig, { + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([ + { + kind: 'agent', + id: 'agent-1', + agentId: 'agent-1', + description: 'Investigate streaming', + status: 'paused', + startTime: 1_000, + outputFile: '/tmp/agent-1.jsonl', + outputOffset: 12, + notified: false, + abortController: new AbortController(), + subagentType: 'reviewer', + isBackgrounded: true, + resumeBlockedReason: 'approval required', + pendingMessages: ['secret queue'], + }, + ]), + }), + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([ + { + kind: 'shell', + id: 'shell-1', + shellId: 'shell-1', + description: 'npm test', + status: 'completed', + startTime: 3_000, + endTime: 4_500, + outputFile: '/tmp/shell-1.log', + outputPath: '/tmp/shell-1.log', + outputOffset: 8, + notified: true, + abortController: new AbortController(), + command: 'npm test', + cwd: '/tmp', + pid: 123, + exitCode: 0, + }, + ]), + }), + getMonitorRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([ + { + kind: 'monitor', + id: 'monitor-1', + monitorId: 'monitor-1', + description: 'watch logs', + status: 'failed', + startTime: 2_000, + endTime: 2_500, + outputFile: '/tmp/monitor-1.log', + outputOffset: 0, + notified: false, + abortController: new AbortController(), + command: 'tail -f app.log', + pid: 456, + eventCount: 3, + lastEventTime: 2_400, + droppedLines: 1, + error: 'boom', + ownerAgentId: 'agent-1', + idleTimer: {}, + }, + ]), + }), + }); vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValueOnce({ availableCommands: [ { @@ -1711,6 +1926,13 @@ describe('QwenAgent MCP SSE/HTTP support', () => { SERVE_STATUS_EXT_METHODS.sessionSupportedCommands, { sessionId }, ); + const tasks = await agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionTasks, { + sessionId, + }); + const contextUsage = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.sessionContextUsage, + { sessionId, detail: true }, + ); expect(context).toMatchObject({ v: 1, @@ -1733,8 +1955,341 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ], availableSkills: ['review'], }); + expect(tasks).toEqual({ + v: 1, + sessionId, + now: 5_000, + tasks: [ + { + kind: 'agent', + id: 'agent-1', + label: 'reviewer: Investigate streaming', + description: 'Investigate streaming', + status: 'paused', + startTime: 1_000, + runtimeMs: 4_000, + outputFile: '/tmp/agent-1.jsonl', + subagentType: 'reviewer', + isBackgrounded: true, + resumeBlockedReason: 'approval required', + }, + { + kind: 'monitor', + id: 'monitor-1', + label: 'watch logs', + description: 'watch logs', + status: 'failed', + startTime: 2_000, + endTime: 2_500, + runtimeMs: 500, + command: 'tail -f app.log', + pid: 456, + eventCount: 3, + lastEventTime: 2_400, + droppedLines: 1, + error: 'boom', + ownerAgentId: 'agent-1', + }, + { + kind: 'shell', + id: 'shell-1', + label: 'npm test', + description: 'npm test', + status: 'completed', + startTime: 3_000, + endTime: 4_500, + runtimeMs: 1_500, + outputFile: '/tmp/shell-1.log', + command: 'npm test', + cwd: '/tmp', + pid: 123, + exitCode: 0, + }, + ], + }); + expect(JSON.stringify(tasks)).not.toContain('abortController'); + expect(JSON.stringify(tasks)).not.toContain('outputOffset'); + expect(JSON.stringify(tasks)).not.toContain('pendingMessages'); + expect(JSON.stringify(tasks)).not.toContain('idleTimer'); + expect(contextUsage).toMatchObject({ + v: 1, + sessionId, + workspaceCwd: '/tmp', + usage: { + modelName: 'm', + showDetails: true, + }, + formattedText: expect.stringContaining('## Context Usage'), + }); expect(buildAvailableCommandsSnapshot).toHaveBeenCalledWith(innerConfig); + dateNowSpy.mockRestore(); + mockConnectionState.resolve(); + await agentPromise; + }); + + it('allows cancelling paused agent tasks', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const cancel = vi.fn(); + const abandon = vi.fn(); + Object.assign(innerConfig, { + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + id: 'agent-1', + kind: 'agent', + status: 'paused', + }), + cancel, + abandon, + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'agent-1', + taskKind: 'agent', + }), + ).resolves.toEqual({ cancelled: true, status: 'paused' }); + expect(abandon).toHaveBeenCalledWith('agent-1'); + expect(cancel).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('rejects sessionTaskCancel with invalid params', async () => { + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId: 'session-1', + taskId: 'task-1', + taskKind: 'invalid', + }), + ).rejects.toThrow('taskKind must be "agent", "shell", or "monitor"'); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('cancels running shell tasks', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const requestCancel = vi.fn(); + Object.assign(innerConfig, { + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + id: 'shell-1', + kind: 'shell', + status: 'running', + }), + requestCancel, + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'shell-1', + taskKind: 'shell', + }), + ).resolves.toEqual({ cancelled: true, status: 'running' }); + expect(requestCancel).toHaveBeenCalledWith('shell-1'); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('cancels running monitor tasks', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const cancel = vi.fn(); + Object.assign(innerConfig, { + getMonitorRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + id: 'monitor-1', + kind: 'monitor', + status: 'running', + }), + cancel, + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'monitor-1', + taskKind: 'monitor', + }), + ).resolves.toEqual({ cancelled: true, status: 'running' }); + expect(cancel).toHaveBeenCalledWith('monitor-1'); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('returns not_running for stopped task cancellation', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const requestCancel = vi.fn(); + Object.assign(innerConfig, { + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + id: 'shell-1', + kind: 'shell', + status: 'completed', + }), + requestCancel, + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'shell-1', + taskKind: 'shell', + }), + ).resolves.toEqual({ + cancelled: false, + reason: 'not_running', + status: 'completed', + }); + expect(requestCancel).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('clears an active session goal', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + vi.mocked(unregisterGoalHook).mockReturnValue({ + condition: 'ship it', + iterations: 1, + setAt: 123, + tokensAtStart: 456, + hookId: 'goal-hook', + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionGoalClear, { + sessionId, + }), + ).resolves.toEqual({ cleared: true, condition: 'ship it' }); + expect(unregisterGoalHook).toHaveBeenCalledWith(innerConfig, sessionId); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('returns cleared false when no session goal is active', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + await setupSessionMocks(sessionId); + vi.mocked(unregisterGoalHook).mockReturnValue(undefined); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionGoalClear, { + sessionId, + }), + ).resolves.toEqual({ cleared: false, condition: undefined }); + mockConnectionState.resolve(); await agentPromise; }); @@ -3415,6 +3970,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect(mockConfig.initialize).toHaveBeenCalledWith({ skipGeminiInitialization: true, + // F2 (#4175 commit 6 review fix — claude-opus-4-7 W119): also + // pins that the bootstrap path opts out of MCP discovery (so + // bootstrap + per-session don't double-spawn N stdio servers). + skipMcpDiscovery: true, }); mockConnectionState.resolve(); @@ -3458,6 +4017,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect(mockConfig.initialize).toHaveBeenCalledWith({ skipGeminiInitialization: true, + // F2 (#4175 commit 6 review fix — claude-opus-4-7 W119): also + // pins that the bootstrap path opts out of MCP discovery (so + // bootstrap + per-session don't double-spawn N stdio servers). + skipMcpDiscovery: true, }); expect(initialize).toHaveBeenCalledTimes(1); expect(fireSessionStartEvent).toHaveBeenCalledTimes(1); @@ -3657,6 +4220,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { historyBeforeRewind: [{ role: 'user', parts: [{ text: 'before' }] }], targetTurnIndex: 1, apiTruncateIndex: 2, + filesChanged: [], + filesFailed: [], }); mockConnectionState.resolve(); @@ -3707,6 +4272,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }, }) as AgentLike; + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( agent.extMethod('rewindSession', { sessionId, @@ -4231,6 +4798,8 @@ describe('QwenAgent extMethod renameSession routing', () => { getCurrentAuthType: vi.fn().mockReturnValue('api-key'), }), refreshAuth: vi.fn().mockResolvedValue(undefined), + getWorkspaceContext: vi.fn().mockReturnValue({}), + getDebugMode: vi.fn().mockReturnValue(false), } as unknown as Config; }); @@ -4460,6 +5029,8 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { getCurrentAuthType: vi.fn().mockReturnValue('api-key'), }), refreshAuth: vi.fn().mockResolvedValue(undefined), + getWorkspaceContext: vi.fn().mockReturnValue({}), + getDebugMode: vi.fn().mockReturnValue(false), } as unknown as Config; processExitSpy = vi @@ -4731,6 +5302,197 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { }); }); +// --------------------------------------------------------------------------- +// T2.8 (#4514): extMethod runtime-add / runtime-remove +// --------------------------------------------------------------------------- + +describe('QwenAgent extMethod runtime MCP add/remove (T2.8)', () => { + let capturedAgentFactory: + | ((conn: { closed: Promise }) => { + initialize: (args: Record) => Promise; + extMethod: ( + method: string, + args: Record, + ) => Promise>; + }) + | undefined; + + let mockConfig: Config; + let processExitSpy: MockInstance; + let stdinDestroySpy: MockInstance; + let stdoutDestroySpy: MockInstance; + + const mockArgv = {} as CliArgs; + const mockSettings = { + merged: { mcpServers: {} }, + } as unknown as LoadedSettings; + + let mockManager: { + addRuntimeMcpServer: ReturnType; + removeRuntimeMcpServer: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockConnectionState.reset(); + capturedAgentFactory = undefined; + + mockManager = { + addRuntimeMcpServer: vi.fn(), + removeRuntimeMcpServer: vi.fn(), + }; + + vi.mocked(AgentSideConnection).mockImplementation((factory: unknown) => { + capturedAgentFactory = factory as typeof capturedAgentFactory; + return { + get closed() { + return mockConnectionState.promise; + }, + } as unknown as InstanceType; + }); + + mockConfig = { + initialize: vi.fn().mockResolvedValue(undefined), + waitForMcpReady: vi.fn().mockResolvedValue(undefined), + getHookSystem: vi.fn().mockReturnValue(undefined), + getDisableAllHooks: vi.fn().mockReturnValue(false), + hasHooksForEvent: vi.fn().mockReturnValue(false), + getModel: vi.fn().mockReturnValue('test-model'), + getModelsConfig: vi.fn().mockReturnValue({ + getCurrentAuthType: vi.fn().mockReturnValue('api-key'), + }), + refreshAuth: vi.fn().mockResolvedValue(undefined), + getWorkspaceContext: vi.fn().mockReturnValue({}), + getDebugMode: vi.fn().mockReturnValue(false), + getToolRegistry: vi.fn().mockReturnValue({ + getMcpClientManager: vi.fn().mockReturnValue(mockManager), + }), + } as unknown as Config; + + processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as unknown as typeof process.exit); + stdinDestroySpy = vi + .spyOn(process.stdin, 'destroy') + .mockImplementation(() => process.stdin); + stdoutDestroySpy = vi + .spyOn(process.stdout, 'destroy') + .mockImplementation(() => process.stdout); + }); + + afterEach(() => { + processExitSpy.mockRestore(); + stdinDestroySpy.mockRestore(); + stdoutDestroySpy.mockRestore(); + }); + + async function getAgent() { + const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }); + return { agent, agentPromise }; + } + + it('runtime-add forwards to manager and returns success result', async () => { + mockManager.addRuntimeMcpServer.mockResolvedValue({ + name: 'my-srv', + transport: 'stdio', + replaced: false, + shadowedSettings: false, + toolCount: 3, + originatorClientId: 'client-1', + }); + + const { agent, agentPromise } = await getAgent(); + const result = await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd, + { + name: 'my-srv', + config: { command: 'node', args: ['server.js'] }, + originatorClientId: 'client-1', + }, + ); + + expect(result).toEqual({ + name: 'my-srv', + transport: 'stdio', + replaced: false, + shadowedSettings: false, + toolCount: 3, + originatorClientId: 'client-1', + }); + expect(mockManager.addRuntimeMcpServer).toHaveBeenCalledWith( + 'my-srv', + { command: 'node', args: ['server.js'] }, + 'client-1', + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('runtime-remove forwards to manager and returns success result', async () => { + mockManager.removeRuntimeMcpServer.mockResolvedValue({ + name: 'my-srv', + removed: true, + wasShadowingSettings: false, + originatorClientId: 'client-2', + }); + + const { agent, agentPromise } = await getAgent(); + const result = await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeRemove, + { + name: 'my-srv', + originatorClientId: 'client-2', + }, + ); + + expect(result).toEqual({ + name: 'my-srv', + removed: true, + wasShadowingSettings: false, + originatorClientId: 'client-2', + }); + expect(mockManager.removeRuntimeMcpServer).toHaveBeenCalledWith( + 'my-srv', + 'client-2', + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('runtime-add propagates McpBudgetWouldExceedError with code field', async () => { + // Use the actual mocked class so instanceof checks pass + const budgetError = new McpBudgetWouldExceedError('my-srv'); + mockManager.addRuntimeMcpServer.mockRejectedValue(budgetError); + + const { agent, agentPromise } = await getAgent(); + const err = await agent + .extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd, { + name: 'my-srv', + config: { command: 'node', args: ['server.js'] }, + originatorClientId: 'client-1', + }) + .catch((e: unknown) => e); + + // The error should be a RequestError with data.errorKind preserving + // the typed code for the bridge's sendBridgeError mapping + expect(err).toBeInstanceOf(Error); + const data = (err as { data?: Record }).data; + expect(data?.['errorKind']).toBe('mcp_budget_would_exceed'); + expect(data?.['serverName']).toBe('my-srv'); + + mockConnectionState.resolve(); + await agentPromise; + }); +}); + describe('normalizeCoreSettingValue', () => { it('accepts a valid boolean and rejects a non-boolean', () => { expect(normalizeCoreSettingValue('general.vimMode', true)).toBe(true); @@ -4933,3 +5695,293 @@ describe('fetchAllowedGitHub', () => { ); }); }); + +// --------------------------------------------------------------------------- +// Multi-session language propagation +// --------------------------------------------------------------------------- + +describe('sessionLanguage multi-session propagation', () => { + let capturedAgentFactory: + | ((conn: { closed: Promise }) => { + initialize: (args: Record) => Promise; + newSession: (args: Record) => Promise; + extMethod: ( + method: string, + args: Record, + ) => Promise>; + }) + | undefined; + + let processExitSpy: MockInstance; + let stdinDestroySpy: MockInstance; + let stdoutDestroySpy: MockInstance; + + const mockArgv = {} as CliArgs; + const mockConnectionState = { + promise: undefined as unknown as Promise, + resolve: undefined as unknown as () => void, + reset() { + this.promise = new Promise((r) => { + this.resolve = r; + }); + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockConnectionState.reset(); + capturedAgentFactory = undefined; + + vi.mocked(AgentSideConnection).mockImplementation((factory: unknown) => { + capturedAgentFactory = factory as typeof capturedAgentFactory; + return { + get closed() { + return mockConnectionState.promise; + }, + } as unknown as InstanceType; + }); + + processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as unknown as typeof process.exit); + stdinDestroySpy = vi + .spyOn(process.stdin, 'destroy') + .mockImplementation(() => process.stdin); + stdoutDestroySpy = vi + .spyOn(process.stdout, 'destroy') + .mockImplementation(() => process.stdout); + }); + + afterEach(() => { + processExitSpy.mockRestore(); + stdinDestroySpy.mockRestore(); + stdoutDestroySpy.mockRestore(); + }); + + function makeConfig(overrides: Record = {}) { + return { + initialize: vi.fn().mockResolvedValue(undefined), + waitForMcpReady: vi.fn().mockResolvedValue(undefined), + getModel: vi.fn().mockReturnValue('m'), + getModelsConfig: vi.fn().mockReturnValue({ + getCurrentAuthType: vi.fn().mockReturnValue('api-key'), + syncAfterAuthRefresh: vi.fn(), + }), + reloadModelProvidersConfig: vi.fn(), + refreshAuth: vi.fn().mockResolvedValue(undefined), + getTargetDir: vi.fn().mockReturnValue('/tmp'), + getContentGeneratorConfig: vi.fn().mockReturnValue({}), + getAvailableModels: vi.fn().mockReturnValue([]), + getModes: vi.fn().mockReturnValue([]), + getApprovalMode: vi.fn().mockReturnValue('default'), + getSessionId: vi.fn().mockReturnValue('sid'), + getAuthType: vi.fn().mockReturnValue('api-key'), + getAllConfiguredModels: vi.fn().mockReturnValue([]), + getGeminiClient: vi.fn().mockReturnValue({ + isInitialized: vi.fn().mockReturnValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + waitForMcpReady: vi.fn().mockResolvedValue(undefined), + refreshSystemInstruction: vi.fn().mockResolvedValue(undefined), + }), + getFileSystemService: vi.fn().mockReturnValue(undefined), + setFileSystemService: vi.fn(), + getHookSystem: vi.fn().mockReturnValue(undefined), + getDisableAllHooks: vi.fn().mockReturnValue(true), + hasHooksForEvent: vi.fn().mockReturnValue(false), + getOutputLanguageFilePath: vi.fn().mockReturnValue(undefined), + setOutputLanguageFilePath: vi.fn(), + refreshHierarchicalMemory: vi.fn().mockResolvedValue(undefined), + getWorkspaceContext: vi.fn().mockReturnValue({}), + getDebugMode: vi.fn().mockReturnValue(false), + ...overrides, + }; + } + + it('propagates language write and refresh to all sessions with varying paths', async () => { + const cfgA = makeConfig({ + getSessionId: vi.fn().mockReturnValue('s-a'), + getOutputLanguageFilePath: vi + .fn() + .mockReturnValue('/proj-a/.qwen/output-language.md'), + }); + const cfgB = makeConfig({ + getSessionId: vi.fn().mockReturnValue('s-b'), + getOutputLanguageFilePath: vi + .fn() + .mockReturnValue('/proj-b/.qwen/output-language.md'), + }); + const cfgC = makeConfig({ + getSessionId: vi.fn().mockReturnValue('s-c'), + getOutputLanguageFilePath: vi.fn().mockReturnValue(undefined), + }); + + const sessionConfigs = [cfgA, cfgB, cfgC]; + let sessionIdx = 0; + + vi.mocked(loadSettings).mockReturnValue({ + merged: { mcpServers: {} }, + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings); + + vi.mocked(loadCliConfig).mockImplementation( + async () => sessionConfigs[sessionIdx]! as unknown as Config, + ); + + vi.mocked(Session).mockImplementation(() => { + const cfg = sessionConfigs[sessionIdx]!; + const id = (cfg.getSessionId as ReturnType)(); + const mock = { + getId: vi.fn().mockReturnValue(id), + getConfig: vi.fn().mockReturnValue(cfg), + sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), + installRewriter: vi.fn(), + dispose: vi.fn(), + }; + sessionIdx++; + return mock as unknown as InstanceType; + }); + + vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValue({ + availableCommands: [], + availableSkills: [], + }); + + const bootConfig = makeConfig(); + const agentPromise = runAcpAgent( + bootConfig as unknown as Config, + { merged: { mcpServers: {} } } as unknown as LoadedSettings, + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }); + + await agent.newSession({ cwd: '/proj-a', mcpServers: [] }); + await agent.newSession({ cwd: '/proj-b', mcpServers: [] }); + await agent.newSession({ cwd: '/proj-c', mcpServers: [] }); + + vi.mocked(updateOutputLanguageFile).mockClear(); + vi.mocked(writeOutputLanguageAndRegisterPath).mockClear(); + + await agent.extMethod('qwen/control/session/language', { + sessionId: 's-a', + language: 'zh', + syncOutputLanguage: true, + }); + + // Session A (initiator): writeOutputLanguageAndRegisterPath called + expect(writeOutputLanguageAndRegisterPath).toHaveBeenCalledWith('zh', cfgA); + + // Session B (different project path): updateOutputLanguageFile called + expect(updateOutputLanguageFile).toHaveBeenCalledWith( + 'zh', + '/proj-b/.qwen/output-language.md', + ); + + // Session C (no path): writeOutputLanguageAndRegisterPath called + expect(writeOutputLanguageAndRegisterPath).toHaveBeenCalledWith('zh', cfgC); + + // All sessions refreshed + expect(cfgA.refreshHierarchicalMemory).toHaveBeenCalled(); + expect(cfgB.refreshHierarchicalMemory).toHaveBeenCalled(); + expect(cfgC.refreshHierarchicalMemory).toHaveBeenCalled(); + + // All sessions' system instruction refreshed + expect(cfgA.getGeminiClient().refreshSystemInstruction).toHaveBeenCalled(); + expect(cfgB.getGeminiClient().refreshSystemInstruction).toHaveBeenCalled(); + expect(cfgC.getGeminiClient().refreshSystemInstruction).toHaveBeenCalled(); + + // Session C registered the global path + expect(cfgC.setOutputLanguageFilePath).toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('still refreshes sessions when a file write fails', async () => { + const cfgOk = makeConfig({ + getSessionId: vi.fn().mockReturnValue('s-ok'), + getOutputLanguageFilePath: vi.fn().mockReturnValue(undefined), + }); + const cfgFail = makeConfig({ + getSessionId: vi.fn().mockReturnValue('s-fail'), + getOutputLanguageFilePath: vi + .fn() + .mockReturnValue('/readonly/.qwen/output-language.md'), + }); + + const sessionConfigs = [cfgOk, cfgFail]; + let sessionIdx = 0; + + vi.mocked(loadSettings).mockReturnValue({ + merged: { mcpServers: {} }, + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings); + vi.mocked(loadCliConfig).mockImplementation( + async () => sessionConfigs[sessionIdx]! as unknown as Config, + ); + vi.mocked(Session).mockImplementation(() => { + const cfg = sessionConfigs[sessionIdx]!; + const id = (cfg.getSessionId as ReturnType)(); + sessionIdx++; + return { + getId: vi.fn().mockReturnValue(id), + getConfig: vi.fn().mockReturnValue(cfg), + sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), + installRewriter: vi.fn(), + dispose: vi.fn(), + } as unknown as InstanceType; + }); + vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValue({ + availableCommands: [], + availableSkills: [], + }); + + const bootConfig = makeConfig(); + const agentPromise = runAcpAgent( + bootConfig as unknown as Config, + { merged: { mcpServers: {} } } as unknown as LoadedSettings, + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }); + + await agent.newSession({ cwd: '/ok', mcpServers: [] }); + await agent.newSession({ cwd: '/readonly', mcpServers: [] }); + + // Make writes for cfgFail's path throw + vi.mocked(updateOutputLanguageFile).mockImplementation( + (_value: string, path?: string) => { + if (path === '/readonly/.qwen/output-language.md') { + throw new Error('EACCES'); + } + }, + ); + + await agent.extMethod('qwen/control/session/language', { + sessionId: 's-ok', + language: 'zh', + syncOutputLanguage: true, + }); + + // Both sessions still refreshed despite cfgFail's write failure + expect(cfgOk.refreshHierarchicalMemory).toHaveBeenCalled(); + expect(cfgFail.refreshHierarchicalMemory).toHaveBeenCalled(); + expect( + cfgFail.getGeminiClient().refreshSystemInstruction, + ).toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); +}); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 179b33537f..fff5a2d2b9 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -8,11 +8,15 @@ import { APPROVAL_MODE_INFO, APPROVAL_MODES, AuthType, + BTW_MAX_INPUT_LENGTH, + buildBtwCacheSafeParams, + buildBtwPrompt, ALL_PROVIDERS, applyProviderInstallPlan, buildInstallPlan, clearCachedCredentialFile, createDebugLogger, + generateSessionRecap, findProviderById, getAllGeminiMdFilenames, getAutoMemoryRoot, @@ -24,6 +28,7 @@ import { resolveBaseUrl, MCP_BUDGET_WARN_FRACTION, MCPServerConfig, + runForkedAgent, SessionService, SESSION_TITLE_MAX_LENGTH, Storage, @@ -32,19 +37,38 @@ import { getMCPServerStatus, MCPDiscoveryState, MCPServerStatus, + McpTransportPool, + POOLED_TRANSPORTS_DEFAULT, resolveOwnsModel, ExtensionManager, ExtensionSettingScope, HookEventName, updateSetting, SessionEndReason, + WorkspaceMcpBudget, + DiscoveredMCPTool, restoreWorktreeContext, + uiTelemetryService, + McpBudgetWouldExceedError, + McpServerSpawnFailedError, + InvalidMcpConfigError, + MCPOAuthProvider, + MCPOAuthTokenStorage, + subagentGenerator, + redactUrlCredentials, + computeUniqueBranchTitle, + unregisterGoalHook, } from '@qwen-code/qwen-code-core'; +import { randomUUID } from 'node:crypto'; import type { ApprovalMode, Config, ConversationRecord, DeviceAuthorizationData, + HookConfig, + McpBudgetEvent, + McpBudgetMode, + McpTransportKind, ProviderConfig, ProviderModelConfig, ProviderSetupInputs, @@ -94,12 +118,17 @@ import { } from './authMethods.js'; import { AcpFileSystemService } from './service/filesystem.js'; import { Readable, Writable } from 'node:stream'; +import { normalizeDisabledToolList } from '../config/normalizeDisabledTools.js'; import { pipeline } from 'node:stream/promises'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { createGunzip } from 'node:zlib'; import type { LoadedSettings } from '../config/settings.js'; -import { loadSettings, SettingScope } from '../config/settings.js'; +import { + loadSettings, + reloadEnvironment, + SettingScope, +} from '../config/settings.js'; import { createLoadedSettingsAdapter } from '../config/loadedSettingsAdapter.js'; import type { ApprovalModeValue, SessionContext } from './session/types.js'; import { z } from 'zod'; @@ -109,14 +138,28 @@ import { loadCliConfig, } from '../config/config.js'; import { Session, buildAvailableCommandsSnapshot } from './session/Session.js'; +import { buildSessionTasksStatus } from './session/tasksSnapshot.js'; import { HistoryReplayer } from './session/HistoryReplayer.js'; import { formatAcpModelId, parseAcpBaseModelId, } from '../utils/acpModelUtils.js'; -import { updateOutputLanguageFile } from '../utils/languageUtils.js'; +import { + updateOutputLanguageFile, + resolveOutputLanguage, + isAutoLanguage, + OUTPUT_LANGUAGE_AUTO, + + getOutputLanguageFilePath, + writeOutputLanguageAndRegisterPath} from '../utils/languageUtils.js'; import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js'; import { runExitCleanup } from '../utils/cleanup.js'; +import { appEvents, AppEvent } from '../utils/events.js'; +import { + setLanguageAsync, + getCurrentLanguage, + SUPPORTED_LANGUAGES, +} from '../i18n/index.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; import { ACP_PREFLIGHT_KINDS, @@ -131,10 +174,13 @@ import { type ServeMcpDiscoveryState, type ServeMcpServerRuntimeStatus, type ServeMcpTransport, + type ServeWorkspaceMcpToolStatus, + type ServeWorkspaceMcpToolsStatus, type ServePreflightCell, type ServePreflightKind, type ServeSessionContextStatus, type ServeSessionSupportedCommandsStatus, + type ServeSessionTasksStatus, type ServeStatus, type ServeStatusCell, type ServeWorkspaceMcpServerStatus, @@ -144,9 +190,30 @@ import { type ServeWorkspaceProvidersStatus, type ServeWorkspaceSkillStatus, type ServeWorkspaceSkillsStatus, + type ServeWorkspaceToolStatus, + type ServeWorkspaceToolsStatus, + type ServeSessionContextUsageStatus, + type ServeSessionStatsStatus, + type ServeHookConfig, + type ServeHookEntry, + type ServeHookSource, + type ServeSessionHooksStatus, + type ServeWorkspaceHooksStatus, + type ServeExtensionEntry, + type ServeExtensionCapabilities, + type ServeWorkspaceExtensionsStatus, + IDLE_HOOK_EVENTS, } from '../serve/status.js'; +import { + collectContextData, + formatContextUsageText, +} from '../ui/commands/contextCommand.js'; +import type { HistoryItemContextUsage } from '../ui/types.js'; const debugLogger = createDebugLogger('ACP_AGENT'); +// Must be less than SESSION_BTW_TIMEOUT_MS (60s) in bridge.ts so the child +// aborts before the bridge's backstop timer fires. +const BTW_CHILD_TIMEOUT_MS = 55_000; /** * Env-var candidates per auth method, used by `buildAuthPreflightCell` for @@ -2174,30 +2241,29 @@ export async function runAcpAgent( settings: LoadedSettings, argv: CliArgs, ) { - // Initialize config to set up ACP bootstrap services (hooks, tools, MCP) - // without creating a chat session. The real per-session Config will own - // GeminiClient.initialize() and any SessionStart hook execution. - await config.initialize({ skipGeminiInitialization: true }); - // ACP forwards session messages straight to the model; under progressive - // MCP availability `initialize()` returns before MCP servers settle, so - // we wait here to keep the first session's tool surface consistent with - // the legacy synchronous behavior. - await config.waitForMcpReady(); - // Surface MCP failures to stderr. ACP's stdout is the protocol channel - // so info/log writes are already redirected to stderr below, but we - // emit this BEFORE that redirection takes effect to keep the message - // visible regardless of how the host process is wired. - // Defensive against tests that pass a stubbed Config without - // `getFailedMcpServerNames`. - const failedMcpServers = - typeof config.getFailedMcpServerNames === 'function' - ? config.getFailedMcpServerNames() - : []; - if (failedMcpServers.length > 0) { - process.stderr.write( - `Warning: MCP server(s) failed to start: ${failedMcpServers.join(', ')}. ` + - `Continuing with built-in tools and any servers that did connect.\n`, - ); + // Skip MCP discovery in the BOOTSTRAP config. Bootstrap MCP clients + // are never used to serve a session (each session runs its own + // discovery), so skipping here avoids spawning every server twice. + const bootstrapSkipsMcpDiscovery = true; + await config.initialize({ + skipGeminiInitialization: true, + skipMcpDiscovery: bootstrapSkipsMcpDiscovery, + }); + // Skip the MCP failure warning when discovery was intentionally + // bypassed — per-session paths surface real failures through their + // own status routes / events. + if (!bootstrapSkipsMcpDiscovery) { + await config.waitForMcpReady(); + const failedMcpServers = + typeof config.getFailedMcpServerNames === 'function' + ? config.getFailedMcpServerNames() + : []; + if (failedMcpServers.length > 0) { + process.stderr.write( + `Warning: MCP server(s) failed to start: ${failedMcpServers.join(', ')}. ` + + `Continuing with built-in tools and any servers that did connect.\n`, + ); + } } const stdout = Writable.toWeb(process.stdout) as WritableStream; @@ -2216,6 +2282,18 @@ export async function runAcpAgent( return agentInstance; }, stream); + // Both the SIGTERM handler and the IDE-initiated close path need + // to drain the MCP pool before runExitCleanup. Single helper + // closure keeps the timeout + log labels consistent. + const drainPoolBeforeExit = async (label: string): Promise => { + if (!agentInstance) return; + try { + await agentInstance.shutdownMcpPool(8_000); + } catch (err) { + debugLogger.error(`[ACP] MCP pool drain (${label}) error:`, err); + } + }; + // Handle SIGTERM/SIGINT for graceful shutdown. // Without this, signal handlers registered elsewhere in the CLI // (e.g., stdin raw mode restoration) override the default exit behavior, @@ -2279,6 +2357,9 @@ export async function runAcpAgent( } catch { // stdout may already be closed } + // Drain the workspace MCP pool BEFORE runExitCleanup so the + // descendant pid sweep can SIGTERM wrapper grandchildren. + await drainPoolBeforeExit('signal'); // Clean up child processes (MCP servers, etc.) and force exit. // Without this, orphan subprocesses keep the Node.js event loop alive // and the CLI process never terminates after the IDE disconnects. @@ -2296,6 +2377,9 @@ export async function runAcpAgent( await connection.closed; // Connection closed by IDE - fire SessionEnd hook (aligned with core path) await fireSessionEndOnce(SessionEndReason.PromptInputExit); + // Mirror the SIGTERM handler's pool drain on the IDE-initiated + // normal close path to avoid leaking shared MCP entries. + await drainPoolBeforeExit('ide_close'); agentInstance?.disposeSessions(); process.off('SIGTERM', shutdownHandler); @@ -2327,14 +2411,176 @@ export function toHttpServer( return undefined; } +/** + * Parse `QWEN_SERVE_MCP_POOL_TRANSPORTS` env var. Comma-separated list + * e.g. "stdio,websocket,http". Falls back to `POOLED_TRANSPORTS_DEFAULT` + * on missing / malformed input. Unknown transport names are silently dropped. + */ +function parsePooledTransports( + envValue: string | undefined, +): ReadonlySet { + if (!envValue || !envValue.trim()) return POOLED_TRANSPORTS_DEFAULT; + const KNOWN: ReadonlySet = new Set([ + 'stdio', + 'websocket', + 'http', + 'sse', + ]); + const out = new Set(); + for (const raw of envValue.split(',')) { + const trimmed = raw.trim().toLowerCase(); + if (KNOWN.has(trimmed as McpTransportKind)) { + out.add(trimmed as McpTransportKind); + } + } + // Empty after parsing (all unknown) → fall back to defaults so an + // operator typo doesn't silently disable the pool entirely. + return out.size > 0 ? out : POOLED_TRANSPORTS_DEFAULT; +} + +/** + * Parse `QWEN_SERVE_MCP_POOL_DRAIN_MS` env var. Default 30000ms. + * Bounded to [1000, 600000] (1s-10min). + */ +function parsePoolDrainMs(envValue: string | undefined): number { + if (!envValue) return 30_000; + // Reject input that contains anything other than digits. A unit + // suffix or typo would silently truncate; strict regex prevents this. + const trimmed = envValue.trim(); + if (!/^\d+$/.test(trimmed)) { + process.stderr.write( + `qwen serve: QWEN_SERVE_MCP_POOL_DRAIN_MS=${JSON.stringify(envValue)} ` + + `is not a valid integer; using default 30000ms.\n`, + ); + return 30_000; + } + const n = Number.parseInt(trimmed, 10); + if (!Number.isFinite(n)) return 30_000; + return Math.min(600_000, Math.max(1_000, n)); +} + +/** + * Construct the workspace-scoped MCP budget controller from env vars. + * Returns `undefined` when budget is unset or `off` mode. The pool + * invokes `tryReserve`/`release`; this helper produces the controller + * and wires the event callback. + */ +function createWorkspaceMcpBudget( + onEvent: (event: McpBudgetEvent) => void, +): WorkspaceMcpBudget | undefined { + const rawBudget = process.env['QWEN_SERVE_MCP_CLIENT_BUDGET']; + const rawMode = process.env['QWEN_SERVE_MCP_BUDGET_MODE']; + // Match `McpClientManager.readBudgetFromEnv`'s parsing exactly. + // Use `Number(...)` + `Number.isInteger` so the pool and the manager + // honor the same env values. + const budget = + rawBudget !== undefined && rawBudget !== '' ? Number(rawBudget) : undefined; + const mode: McpBudgetMode = (() => { + if (rawMode === 'enforce' || rawMode === 'warn' || rawMode === 'off') { + return rawMode; + } + return budget !== undefined && + Number.isFinite(budget) && + Number.isInteger(budget) && + budget > 0 + ? 'warn' + : 'off'; + })(); + if ( + mode === 'off' || + budget === undefined || + !Number.isFinite(budget) || + !Number.isInteger(budget) || + budget <= 0 + ) { + return undefined; + } + return new WorkspaceMcpBudget({ + clientBudget: budget, + mode, + onEvent, + }); +} + class QwenAgent implements Agent { private sessions: Map = new Map(); private clientCapabilities: ClientCapabilities | undefined; + /** + * Workspace-shared MCP transport pool. Eagerly constructed; lazy + * w.r.t. actual MCP work — spawns nothing until `pool.acquire`. + * + * `undefined` when `QWEN_SERVE_NO_MCP_POOL=1` (kill switch); sessions + * then fall back to per-session McpClient spawn. + */ + private readonly mcpPool?: McpTransportPool; + + /** + * Workspace-scoped MCP budget controller. Constructed alongside + * `mcpPool` when `--mcp-client-budget=N` is configured. `undefined` + * when no budget is configured or pool kill switch is on. + */ + private readonly workspaceMcpBudget?: WorkspaceMcpBudget; + getActiveSessions(): Session[] { return [...this.sessions.values()]; } + /** + * Drain the workspace MCP transport pool. Called on shutdown so all + * pool entries get a coordinated SIGTERM before process.exit. No-op + * when pool is undefined (kill-switch mode). + */ + async shutdownMcpPool(timeoutMs = 10_000): Promise { + if (!this.mcpPool) return; + try { + const result = await this.mcpPool.drainAll({ force: true, timeoutMs }); + if (result.forced > 0 || result.errors.length > 0) { + debugLogger.warn( + `MCP pool drain: ${result.drained} clean, ${result.forced} timed out, ` + + `${result.errors.length} errors`, + ); + } + } catch (err) { + debugLogger.error( + `MCP pool drainAll failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + private async closeStoredSession(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + this.mcpPool?.releaseSession(sessionId); + return; + } + + try { + await session.cancelPendingPrompt(); + } catch (err) { + debugLogger.debug( + `Session ${sessionId} cancel during close failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + + try { + await session.getConfig().getToolRegistry()?.stop(); + } catch (err) { + debugLogger.debug( + `Session ${sessionId} tool registry stop during close failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + + unregisterGoalHook(session.getConfig(), sessionId); + this.mcpPool?.releaseSession(sessionId); + uiTelemetryService.removeSession(sessionId); + this.sessions.delete(sessionId); + } + disposeSessions(): void { for (const session of this.sessions.values()) { session.dispose(); @@ -2347,7 +2593,79 @@ class QwenAgent implements Agent { private settings: LoadedSettings, private argv: CliArgs, private connection: AgentSideConnection, - ) {} + ) { + // Pool kill switch via env var so operators can A/B compare or + // roll back without rebuilding. `runQwenServe.ts` sets this when + // `--no-mcp-pool` is passed at daemon startup. + if (process.env['QWEN_SERVE_NO_MCP_POOL'] === '1') { + this.mcpPool = undefined; + this.workspaceMcpBudget = undefined; + } else { + // Construct the workspace-scoped budget controller when + // `--mcp-client-budget=N` was set at boot. With the pool active, + // this controller's accounting REPLACES per-session copies. + this.workspaceMcpBudget = createWorkspaceMcpBudget((event) => { + this.broadcastBudgetEvent(event); + }); + this.mcpPool = new McpTransportPool(this.config, { + workspaceContext: this.config.getWorkspaceContext(), + debugMode: this.config.getDebugMode(), + // sendSdkMcpMessage left undefined: SDK MCP servers always + // bypass the pool via createUnpooledConnection (per-session + // routing through ACP control plane). The legacy + // McpClientManager path retains its own per-session SDK + // wiring; pool-mode discoverAllMcpToolsViaPool delegates SDK + // MCP to that bypass. + pooledTransports: parsePooledTransports( + process.env['QWEN_SERVE_MCP_POOL_TRANSPORTS'], + ), + drainDelayMs: parsePoolDrainMs( + process.env['QWEN_SERVE_MCP_POOL_DRAIN_MS'], + ), + budget: this.workspaceMcpBudget, + }); + } + } + + /** Expose the pool's workspace-scoped budget controller for snapshot builders. */ + getWorkspaceMcpBudget(): WorkspaceMcpBudget | undefined { + return this.workspaceMcpBudget; + } + + /** + * Fan-out a workspace-scoped MCP budget event to every active + * session's SSE bus. Each notification is independently + * fire-and-forget. + */ + private broadcastBudgetEvent(event: McpBudgetEvent): void { + // The QwenAgent's `this.connection` is the single ACP channel to + // the daemon. The daemon's bridge `bridgeClient.extNotification` + // resolves the per-session SSE bus from the `sessionId` field of + // each notification — so we send N notifications (one per active + // session id) over the same connection. Each notification is + // independently fire-and-forget; a mid-flight ACP disconnect + // shouldn't sink delivery to siblings. + // + // Snapshot the session id list before the async fan-out so a + // concurrent `killSession` can't corrupt the iterator. + const sessionIds = Array.from(this.sessions.keys()); + for (const sid of sessionIds) { + void this.connection + .extNotification('qwen/notify/session/mcp-budget-event', { + v: 1, + sessionId: sid, + // Tag workspace-scoped events so SDK reducers can branch. + scope: 'workspace' as const, + ...event, + }) + .catch((err: unknown) => { + debugLogger.debug( + `MCP workspace budget event delivery to session ${sid} failed ` + + `(kind=${event.kind}): ${err instanceof Error ? err.message : String(err)}`, + ); + }); + } + } async initialize(args: InitializeRequest): Promise { this.clientCapabilities = args.clientCapabilities; @@ -2518,14 +2836,9 @@ class QwenAgent implements Agent { /** * Shared worktree restore for both ACP entry points (`loadSession` and - * `unstable_resumeSession`). Reads the WorktreeSession sidecar, cleans - * up stale ones, and queues the context reminder on the Session so the - * next `#executePrompt` prepends it to the user's first prompt. - * - * Best-effort: failures don't block session load — worktree context - * is a hint to the model, not a load-time correctness requirement. - * (PR #4174 review #3259975... — parity between the two ACP entry - * points.) + * `unstable_resumeSession`). Best-effort: failures don't block session + * load — worktree context is a hint to the model, not a correctness + * requirement. */ async #restoreWorktreeOnResume( config: Config, @@ -2885,42 +3198,13 @@ class QwenAgent implements Agent { } private mcpTransport(server: unknown): ServeMcpTransport { - if ( - server && - typeof server === 'object' && - 'type' in server && - (server as { type?: unknown }).type === 'sdk' - ) { - return 'sdk'; - } - if ( - server && - typeof server === 'object' && - typeof (server as { httpUrl?: unknown }).httpUrl === 'string' - ) { - return 'http'; - } - if ( - server && - typeof server === 'object' && - typeof (server as { url?: unknown }).url === 'string' - ) { - return 'sse'; - } - if ( - server && - typeof server === 'object' && - typeof (server as { tcp?: unknown }).tcp === 'string' - ) { - return 'websocket'; - } - if ( - server && - typeof server === 'object' && - typeof (server as { command?: unknown }).command === 'string' - ) { - return 'stdio'; - } + if (!server || typeof server !== 'object') return 'unknown'; + const s = server as Record; + if (s['type'] === 'sdk') return 'sdk'; + if (typeof s['httpUrl'] === 'string') return 'http'; + if (typeof s['url'] === 'string') return 'sse'; + if (typeof s['tcp'] === 'string') return 'websocket'; + if (typeof s['command'] === 'string') return 'stdio'; return 'unknown'; } @@ -2965,117 +3249,198 @@ class QwenAgent implements Agent { } } - private buildWorkspaceMcpStatus(config: Config): ServeWorkspaceMcpStatus { + private async buildWorkspaceMcpStatus( + config: Config, + ): Promise { try { const workspaceCwd = this.workspaceCwd(config); + const settings = loadSettings(config.getTargetDir()); + const workspaceSettings = settings.forScope( + SettingScope.Workspace, + ).settings; const servers = config.getMcpServers() ?? {}; - // PR 14: pull live accounting + budget config from the child's - // McpClientManager so the daemon's read-only route reflects the - // single source of truth (not a daemon-side polled cache). - // `getToolRegistry()` and `getMcpClientManager()` are best-effort - // — older test stubs or partially-initialized configs may not - // expose them; in that case we fall back to "no budget surface". + // Pool snapshot for per-server `entryCount` + `entrySummary`. + // Captured once outside the per-server loop. Absent when the + // pool is disabled. + let poolByName: Record< + string, + { + entryCount: number; + entrySummary: ReadonlyArray<{ + entryIndex: number; + refs: number; + status: MCPServerStatus; + }>; + } + > = {}; + try { + const snap = this.mcpPool?.getSnapshot(); + if (snap) poolByName = snap.byName; + } catch (err) { + // Pool snapshot failures must not crash the wider status — + // surface to stderr so silent regressions are visible without + // depending on `debugLogger.debug` operator opt-in (matches + // the budget-accounting fail-loud pattern below). + process.stderr.write( + `qwen serve: pool snapshot for workspace MCP status failed: ` + + `${err instanceof Error ? err.message : String(err)}\n`, + ); + } + + // Pull live accounting + budget config. When the workspace-scoped + // budget controller is active, prefer its accounting. Manager + // fall-back keeps the legacy per-session cell shape. let clientCount: number | undefined; let clientBudget: number | undefined; let budgetMode: ServeMcpBudgetMode | undefined; let refusedSet: ReadonlySet = new Set(); - try { - const manager = config.getToolRegistry()?.getMcpClientManager(); - if (manager) { - const accounting = manager.getMcpClientAccounting(); - clientCount = accounting.total; - clientBudget = manager.getMcpClientBudget(); - budgetMode = manager.getMcpBudgetMode(); - refusedSet = new Set(accounting.refusedServerNames); + let budgetCellScope: 'workspace' | 'session' = 'session'; + const wsBudget = this.workspaceMcpBudget; + if (wsBudget !== undefined) { + budgetCellScope = 'workspace'; + clientCount = wsBudget.getReservedCount(); + clientBudget = wsBudget.getBudget(); + budgetMode = this.coerceBudgetMode(wsBudget.getMode()); + refusedSet = new Set(wsBudget.getRefusedServerNames()); + } else { + try { + const manager = config.getToolRegistry()?.getMcpClientManager(); + if (manager) { + const accounting = manager.getMcpClientAccounting(); + clientCount = accounting.total; + clientBudget = manager.getMcpClientBudget(); + budgetMode = manager.getMcpBudgetMode(); + refusedSet = new Set(accounting.refusedServerNames); + } + } catch (err) { + // Accounting failure must not crash the snapshot — the per- + // server data is still useful even without budget overlay. + process.stderr.write( + `qwen serve: getMcpClientAccounting failed: ` + + `${err instanceof Error ? err.message : String(err)}\n`, + ); } - } catch (err) { - // Accounting failure must not crash the snapshot — the per- - // server data is still useful even without budget overlay. - // PR 14 fix (review #4247 wenshao S7a): bumped from - // `debugLogger.debug` to stderr `process.stderr.write` so a - // production daemon emits a visible warning when accounting - // breaks. `debugLogger.debug` is gated on the operator - // having set debug=true, which makes silent slot-leak / type- - // mismatch failures invisible in real deployments. - process.stderr.write( - `qwen serve: getMcpClientAccounting failed: ` + - `${err instanceof Error ? err.message : String(err)}\n`, - ); } + const sharedTokenStorage = new MCPOAuthTokenStorage(); + return { v: STATUS_SCHEMA_VERSION, workspaceCwd, initialized: true, discoveryState: this.discoveryState(), - servers: Object.entries(servers).map(([name, server]) => { - const disabled = config.isMcpServerDisabled(name); - const rawStatus = getMCPServerStatus(name); - const refusedByBudget = refusedSet.has(name); - // PR 14 fix (review #4247): config-disable takes precedence - // over budget-refusal. `lastRefusedServerNames` is a - // per-discovery-pass snapshot; if an operator runs - // `/mcp disable ` against a server that was refused - // last pass, the entry stays in the refused list until the - // next discovery pass clears it (`McpClientManager.removeServer` - // now drops the entry too — see sibling fix). Either way, - // a `disabled` cell should NEVER show `budget_exhausted` — - // the operator's deliberate disable wins. - const effectivelyRefused = refusedByBudget && !disabled; - const out: ServeWorkspaceMcpServerStatus = { - kind: 'mcp_server', - // Refused-by-budget shadows the raw status: the rawStatus - // is `DISCONNECTED` (we never tried to connect), but the - // operator-facing severity is `error` with an explanatory - // errorKind rather than the generic disconnected `error`. - status: effectivelyRefused - ? 'error' - : this.mcpCellStatus(rawStatus, disabled), - name, - mcpStatus: this.mcpStatus(rawStatus), - transport: this.mcpTransport(server), - disabled, - }; - if (effectivelyRefused) { - out.errorKind = 'budget_exhausted'; - out.disabledReason = 'budget'; - out.hint = - 'Raise --mcp-client-budget or remove servers from mcpServers config.'; - } else if (disabled) { - out.disabledReason = 'config'; - } - const description = - server && typeof server === 'object' - ? (server as { description?: unknown }).description - : undefined; - const extensionName = - server && typeof server === 'object' - ? (server as { extensionName?: unknown }).extensionName - : undefined; - if (typeof description === 'string') { - out.description = description; - } - if (typeof extensionName === 'string') { - out.extensionName = extensionName; - } - return out; - }), + servers: await Promise.all( + Object.entries(servers).map(async ([name, server]) => { + const disabled = config.isMcpServerDisabled(name); + let hasOAuthTokens = false; + try { + const credentials = await sharedTokenStorage.getCredentials(name); + hasOAuthTokens = credentials !== null; + } catch { + // Match CLI: token lookup errors should not break /mcp status. + } + const rawStatus = getMCPServerStatus(name); + const refusedByBudget = refusedSet.has(name); + // Config-disable takes precedence over budget-refusal. + const effectivelyRefused = refusedByBudget && !disabled; + const out: ServeWorkspaceMcpServerStatus = { + kind: 'mcp_server', + // Refused-by-budget shadows the raw status: the rawStatus + // is `DISCONNECTED` (we never tried to connect), but the + // operator-facing severity is `error` with an explanatory + // errorKind rather than the generic disconnected `error`. + status: effectivelyRefused + ? 'error' + : this.mcpCellStatus(rawStatus, disabled), + name, + mcpStatus: this.mcpStatus(rawStatus), + transport: this.mcpTransport(server), + disabled, + hasOAuthTokens, + }; + if (effectivelyRefused) { + out.errorKind = 'budget_exhausted'; + out.disabledReason = 'budget'; + out.hint = + 'Raise --mcp-client-budget or remove servers from mcpServers config.'; + } else if (disabled) { + out.disabledReason = 'config'; + } + const description = + server && typeof server === 'object' + ? (server as { description?: unknown }).description + : undefined; + const extensionName = + server && typeof server === 'object' + ? (server as { extensionName?: unknown }).extensionName + : undefined; + if (typeof description === 'string') { + out.description = description; + } + if (typeof extensionName === 'string') { + out.extensionName = extensionName; + } + out.source = out.extensionName + ? 'extension' + : workspaceSettings.mcpServers?.[name] + ? 'project' + : 'user'; + if (server && typeof server === 'object') { + const candidate = server as { + command?: unknown; + args?: unknown; + httpUrl?: unknown; + url?: unknown; + cwd?: unknown; + }; + const serverConfig: NonNullable< + ServeWorkspaceMcpServerStatus['config'] + > = {}; + if (typeof candidate.command === 'string') { + serverConfig.command = candidate.command; + } + if (Array.isArray(candidate.args)) { + const args = candidate.args.filter( + (arg): arg is string => typeof arg === 'string', + ); + if (args.length > 0) { + serverConfig.args = args; + } + } + if (typeof candidate.httpUrl === 'string') { + serverConfig.httpUrl = candidate.httpUrl; + } + if (typeof candidate.url === 'string') { + serverConfig.url = candidate.url; + } + if (typeof candidate.cwd === 'string') { + serverConfig.cwd = candidate.cwd; + } + if (Object.keys(serverConfig).length > 0) { + out.config = serverConfig; + } + } + // Pool entries enrichment. + const poolRow = poolByName[name]; + if (poolRow) { + out.entryCount = poolRow.entryCount; + out.entrySummary = poolRow.entrySummary.map((e) => ({ + entryIndex: e.entryIndex, + refs: e.refs, + status: this.mcpStatus(e.status), + })); + } + return out; + }), + ), ...(clientCount !== undefined ? { clientCount } : {}), ...(clientBudget !== undefined ? { clientBudget } : {}), ...(budgetMode !== undefined ? { budgetMode } : {}), ...(budgetMode !== undefined ? { - // PR 14 fix (review #4247 wenshao R2-#6): filter out - // servers that are now config-disabled so the - // workspace cell matches the per-server cell - // precedence (`effectivelyRefused = refusedByBudget - // && !disabled` above). Pre-fix a server disabled - // after being refused would render `disabled` on its - // per-server row but `error: budget_exhausted` on the - // workspace row — confusing for dashboards. Use - // `Array.from(refusedSet).filter(...)` to apply the - // same disabled gate the per-server loop applies. + // Filter out config-disabled servers so the workspace + // cell matches the per-server cell precedence. budgets: this.buildBudgetCells( clientCount ?? 0, clientBudget, @@ -3083,6 +3448,7 @@ class QwenAgent implements Agent { Array.from(refusedSet).filter( (n) => !config.isMcpServerDisabled(n), ).length, + budgetCellScope, ), } : {}), @@ -3098,50 +3464,128 @@ class QwenAgent implements Agent { } } + private buildWorkspaceMcpToolsStatus( + config: Config, + serverName: string, + ): ServeWorkspaceMcpToolsStatus { + const workspaceCwd = this.safeWorkspaceCwd(config); + try { + const servers = config.getMcpServers() ?? {}; + if (!Object.prototype.hasOwnProperty.call(servers, serverName)) { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + serverName, + initialized: true, + acpChannelLive: true, + tools: [], + errors: [ + { + kind: 'mcp_tools', + status: 'error', + error: `MCP server not configured: ${serverName}`, + }, + ], + }; + } + + let registry = config.getToolRegistry(); + let allTools = registry?.getAllTools() ?? []; + if ( + allTools.filter( + (t) => t instanceof DiscoveredMCPTool && t.serverName === serverName, + ).length === 0 + ) { + for (const session of this.getActiveSessions()) { + const sessionRegistry = session.getConfig().getToolRegistry(); + const sessionTools = sessionRegistry?.getAllTools() ?? []; + if ( + sessionTools.some( + (t) => + t instanceof DiscoveredMCPTool && t.serverName === serverName, + ) + ) { + registry = sessionRegistry; + allTools = sessionTools; + break; + } + } + } + const tools: ServeWorkspaceMcpToolStatus[] = allTools + .filter( + (tool): tool is DiscoveredMCPTool => + tool instanceof DiscoveredMCPTool && tool.serverName === serverName, + ) + .map((tool) => { + const invalidReasons: string[] = []; + if (!tool.name) invalidReasons.push('missing name'); + if (!tool.description) invalidReasons.push('missing description'); + const schema = + tool.parameterSchema && + typeof tool.parameterSchema === 'object' && + !Array.isArray(tool.parameterSchema) + ? (tool.parameterSchema as Record) + : undefined; + const annotations = + tool.annotations && + typeof tool.annotations === 'object' && + !Array.isArray(tool.annotations) + ? (tool.annotations as Record) + : undefined; + return { + name: tool.name || '(unnamed)', + serverToolName: tool.serverToolName, + description: tool.description, + ...(schema ? { schema } : {}), + ...(annotations ? { annotations } : {}), + isValid: invalidReasons.length === 0, + ...(invalidReasons.length > 0 + ? { invalidReason: invalidReasons.join(', ') } + : {}), + }; + }); + + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + serverName, + initialized: true, + acpChannelLive: true, + tools, + }; + } catch (error) { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + serverName, + initialized: true, + acpChannelLive: true, + tools: [], + errors: [this.errorCell('mcp_tools', error)], + }; + } + } + /** - * Build the MCP budget status cells exposed on `GET /workspace/mcp` - * (PR 14). v1 emits one cell with `scope: 'session'` — each ACP - * session has its own `McpClientManager`, so the budget enforces - * per-session (snapshot reflects the bootstrap session's view). - * Wave 5 PR 23 (shared MCP pool) will add `scope: 'workspace'` - * for true per-workspace aggregation. Consumers MUST tolerate - * additional entries with unrecognized scope values (drop, don't - * fail). + * Build the MCP budget status cells exposed on `GET /workspace/mcp`. * * Cell `status` semantics: - * - `error` — refusals happened this pass (only possible in enforce mode) - * - `warning` — live count crossed 75% of budget (warn or enforce mode) + * - `error` — refusals happened this pass (enforce mode only) + * - `warning` — live count crossed 75% of budget * - `ok` — under threshold (or `off` mode) * - * **`liveCount` vs `reservedSlots.size` (PR 14 review #4247 R9 #5)**: - * `liveCount` here is `accounting.total` — only `MCPServerStatus.CONNECTED` - * clients. Enforcement (`tryReserveSlot`) on the other hand uses - * `reservedSlots.size` — all reserved names, including in-flight - * connects and never-connected stale entries. The two diverge when - * servers hold a slot during the connect handshake or after a - * connect failure that didn't release (e.g. `'already_held'` - * reconnect timeouts). The snapshot intentionally uses the live - * count for **operator observability** — "how many MCP clients - * are actually serving requests right now" — while enforcement - * uses the reservation count to prevent capacity races across - * `Promise.all` microtask boundaries. PR 14b's typed events - * should consider exposing both for real-time pressure signals. + * `liveCount` is the connected-client count (for operator + * observability), while enforcement uses `reservedSlots.size` to + * prevent capacity races. */ private buildBudgetCells( liveCount: number, budget: number | undefined, mode: ServeMcpBudgetMode, refusedCount: number, + scope: 'workspace' | 'session' = 'session', ): ServeMcpBudgetStatusCell[] { - // PR 14 fix (review #4247): when no `--mcp-client-budget` is - // configured the manager resolves to `mode: 'off'`. The protocol - // docs and SDK type comments promise `budgets: []` for that case; - // a synthetic `mcp_budget` cell carrying nothing actionable was - // (a) protocol-noncompliant, (b) clutter — clients iterating - // `budgets[]` to render rows would draw an "ok" budget row for - // uncapped workspaces. Always return empty so the top-level - // `budgetMode: 'off'` field is the sole signal that guardrails - // are inactive. + // When mode is 'off', return empty — no budget surface to show. if (mode === 'off') return []; let status: ServeStatus = 'ok'; let errorKind: ServeErrorKind | undefined; @@ -3163,12 +3607,9 @@ class QwenAgent implements Agent { } const cell: ServeMcpBudgetStatusCell = { kind: 'mcp_budget', - // PR 14 v1: per-session, not per-workspace. Each ACP session has - // its own `Config`/`McpClientManager` (via `newSessionConfig`) - // and reads `QWEN_SERVE_MCP_CLIENT_BUDGET` independently. - // Snapshot shows the bootstrap session's view. Wave 5 PR 23 - // shared MCP pool will graduate this to `'workspace'`. - scope: 'session', + // `scope` is 'workspace' when the workspace budget controller is + // active, otherwise 'session' for legacy per-session caps. + scope, status, liveCount, mode, @@ -3180,6 +3621,11 @@ class QwenAgent implements Agent { return [cell]; } + /** Map core `McpBudgetMode` to protocol `ServeMcpBudgetMode`. */ + private coerceBudgetMode(mode: McpBudgetMode): ServeMcpBudgetMode { + return mode; + } + private errorCell( kind: string, error: unknown, @@ -3292,6 +3738,11 @@ class QwenAgent implements Agent { ? { description: model.description } : {}), contextLimit: model.contextWindowSize ?? tokenLimit(effectiveModelId), + ...(model.modalities !== undefined + ? { modalities: model.modalities } + : {}), + ...(model.baseUrl !== undefined ? { baseUrl: model.baseUrl } : {}), + ...(model.envKey !== undefined ? { envKey: model.envKey } : {}), isCurrent, isRuntime: model.isRuntimeModel === true, }; @@ -3299,6 +3750,10 @@ class QwenAgent implements Agent { if (isCurrent) provider.current = true; } + const cgConfig = config.getContentGeneratorConfig?.(); + const baseUrl = cgConfig?.baseUrl || undefined; + const fastModelId = this.settings.merged?.fastModel || undefined; + return { v: STATUS_SCHEMA_VERSION, workspaceCwd, @@ -3308,6 +3763,8 @@ class QwenAgent implements Agent { current: { ...(currentAuth ? { authType: String(currentAuth) } : {}), ...(currentAcpModelId ? { modelId: currentAcpModelId } : {}), + ...(baseUrl ? { baseUrl } : {}), + ...(fastModelId ? { fastModelId } : {}), }, } : {}), @@ -3346,7 +3803,7 @@ class QwenAgent implements Agent { kind: 'egress', status: 'not_started', locality: 'acp', - hint: 'egress probing lands in PR 14 (#4175)', + hint: 'egress probing not yet implemented', }), }; const cells: ServePreflightCell[] = []; @@ -3616,6 +4073,66 @@ class QwenAgent implements Agent { } } + private buildWorkspaceToolsStatus(config: Config): ServeWorkspaceToolsStatus { + const workspaceCwd = this.safeWorkspaceCwd(config); + try { + const registry = config.getToolRegistry(); + if (!registry) { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: true, + acpChannelLive: true, + tools: [], + errors: [ + { + kind: 'tools', + status: 'error', + errorKind: 'protocol_error', + error: 'Tool registry is not initialized.', + }, + ], + }; + } + + const disabled = config.getDisabledTools(); + const tools: ServeWorkspaceToolStatus[] = registry + .getAllTools() + .filter((tool) => !('serverName' in tool)) + .map((tool) => ({ + name: tool.name, + displayName: tool.displayName, + description: tool.description, + enabled: !disabled.has(tool.name), + })); + + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: true, + acpChannelLive: true, + tools, + }; + } catch (err) { + const errorKind = mapDomainErrorToErrorKind(err) ?? 'protocol_error'; + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: true, + acpChannelLive: true, + tools: [], + errors: [ + { + kind: 'tools', + status: 'error', + error: err instanceof Error ? err.message : String(err), + errorKind, + }, + ], + }; + } + } + private sessionOrThrow(sessionId: string): Session { const session = this.sessions.get(sessionId); if (!session) { @@ -3644,6 +4161,65 @@ class QwenAgent implements Agent { }; } + private async buildSessionContextUsageStatus( + sessionId: string, + showDetails: boolean, + ): Promise { + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + let usage; + try { + usage = await collectContextData(config, showDetails); + } catch (err) { + console.warn('[context-usage] collectContextData failed:', err); + usage = { + type: 'context_usage' as const, + modelName: config.getModel() || 'unknown', + totalTokens: 0, + contextWindowSize: 0, + breakdown: { + systemPrompt: 0, + builtinTools: 0, + mcpTools: 0, + memoryFiles: 0, + skills: 0, + messages: 0, + freeSpace: 0, + autocompactBuffer: 0, + }, + builtinTools: [] as Array<{ name: string; tokens: number }>, + mcpTools: [] as Array<{ name: string; tokens: number }>, + memoryFiles: [] as Array<{ path: string; tokens: number }>, + skills: [] as Array<{ + name: string; + tokens: number; + loaded?: boolean; + bodyTokens?: number; + }>, + isEstimated: true, + showDetails, + }; + } + return { + v: STATUS_SCHEMA_VERSION, + sessionId, + workspaceCwd: this.workspaceCwd(config), + usage: { + modelName: usage.modelName, + totalTokens: usage.totalTokens, + contextWindowSize: usage.contextWindowSize, + breakdown: usage.breakdown, + builtinTools: usage.builtinTools, + mcpTools: usage.mcpTools, + memoryFiles: usage.memoryFiles, + skills: usage.skills, + isEstimated: usage.isEstimated, + showDetails: usage.showDetails, + }, + formattedText: formatContextUsageText(usage as HistoryItemContextUsage), + }; + } + private async buildSessionSupportedCommandsStatus( sessionId: string, ): Promise { @@ -3658,6 +4234,319 @@ class QwenAgent implements Agent { }; } + private buildSessionTasksStatus(sessionId: string): ServeSessionTasksStatus { + const session = this.sessionOrThrow(sessionId); + return buildSessionTasksStatus(sessionId, session.getConfig()); + } + + private buildSessionStatsStatus(sessionId: string): ServeSessionStatsStatus { + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + const metrics = uiTelemetryService.getMetricsForSession(sessionId); + const now = Date.now(); + const createdAt = session.getCreatedAt(); + + const models: ServeSessionStatsStatus['models'] = {}; + for (const [name, m] of Object.entries(metrics.models)) { + models[name] = { + api: { ...m.api }, + tokens: { ...m.tokens }, + }; + } + + const byName: ServeSessionStatsStatus['tools']['byName'] = {}; + for (const [name, t] of Object.entries(metrics.tools.byName)) { + byName[name] = { + count: t.count, + success: t.success, + fail: t.fail, + durationMs: t.durationMs, + decisions: { + accept: t.decisions.accept, + reject: t.decisions.reject, + modify: t.decisions.modify, + auto_accept: t.decisions.auto_accept, + }, + }; + } + + return { + v: STATUS_SCHEMA_VERSION, + sessionId, + workspaceCwd: this.workspaceCwd(config), + sessionStartTimeMs: createdAt, + durationMs: now - createdAt, + promptCount: session.getTurnCount(), + models, + tools: { + totalCalls: metrics.tools.totalCalls, + totalSuccess: metrics.tools.totalSuccess, + totalFail: metrics.tools.totalFail, + totalDurationMs: metrics.tools.totalDurationMs, + byName, + }, + files: { + totalLinesAdded: metrics.files.totalLinesAdded, + totalLinesRemoved: metrics.files.totalLinesRemoved, + }, + }; + } + + private serializeHookConfig(config: HookConfig): ServeHookConfig { + switch (config.type) { + case 'command': + return { + type: 'command', + command: config.command, + ...(config.name !== undefined ? { name: config.name } : {}), + ...(config.description !== undefined + ? { description: config.description } + : {}), + ...(config.timeout !== undefined ? { timeout: config.timeout } : {}), + ...(config.env ? { env: config.env } : {}), + ...(config.async !== undefined ? { async: config.async } : {}), + ...(config.shell ? { shell: config.shell } : {}), + ...(config.statusMessage !== undefined + ? { statusMessage: config.statusMessage } + : {}), + }; + case 'http': + return { + type: 'http', + url: config.url, + ...(config.name !== undefined ? { name: config.name } : {}), + ...(config.description !== undefined + ? { description: config.description } + : {}), + ...(config.timeout !== undefined ? { timeout: config.timeout } : {}), + ...(config.headers ? { headers: config.headers } : {}), + ...(config.allowedEnvVars + ? { allowedEnvVars: config.allowedEnvVars } + : {}), + ...(config.if !== undefined ? { if: config.if } : {}), + ...(config.statusMessage !== undefined + ? { statusMessage: config.statusMessage } + : {}), + ...(config.once !== undefined ? { once: config.once } : {}), + }; + case 'function': + return { + type: 'function', + ...(config.id !== undefined ? { id: config.id } : {}), + ...(config.name !== undefined ? { name: config.name } : {}), + ...(config.description !== undefined + ? { description: config.description } + : {}), + ...(config.timeout !== undefined ? { timeout: config.timeout } : {}), + ...(config.errorMessage !== undefined + ? { errorMessage: config.errorMessage } + : {}), + ...(config.statusMessage !== undefined + ? { statusMessage: config.statusMessage } + : {}), + }; + case 'prompt': + return { + type: 'prompt', + prompt: config.prompt, + ...(config.name !== undefined ? { name: config.name } : {}), + ...(config.description !== undefined + ? { description: config.description } + : {}), + ...(config.timeout !== undefined ? { timeout: config.timeout } : {}), + ...(config.model ? { model: config.model } : {}), + ...(config.statusMessage !== undefined + ? { statusMessage: config.statusMessage } + : {}), + }; + default: + return { type: (config as { type: string }).type }; + } + } + + private buildWorkspaceHooksStatus(config: Config): ServeWorkspaceHooksStatus { + try { + const workspaceCwd = this.workspaceCwd(config); + const disabled = config.getDisableAllHooks(); + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: true, + disabled, + hooks: [], + events: IDLE_HOOK_EVENTS, + }; + } + const registryEntries = hookSystem.getAllHooks(); + const hooks: ServeHookEntry[] = registryEntries.map( + (entry): ServeHookEntry => ({ + kind: 'hook', + eventName: entry.eventName, + config: this.serializeHookConfig(entry.config), + source: entry.source as ServeHookSource, + ...(entry.matcher ? { matcher: entry.matcher } : {}), + ...(entry.sequential !== undefined + ? { sequential: entry.sequential } + : {}), + enabled: entry.enabled, + }), + ); + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: true, + disabled, + hooks, + events: IDLE_HOOK_EVENTS, + }; + } catch (error) { + let disabled = false; + try { + disabled = config.getDisableAllHooks(); + } catch { + // config may be in a broken state; fall back to false + } + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: this.safeWorkspaceCwd(config), + initialized: false, + disabled, + hooks: [], + events: IDLE_HOOK_EVENTS, + errors: [this.errorCell('hooks', error)], + }; + } + } + + private buildSessionHooksStatus(sessionId: string): ServeSessionHooksStatus { + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + try { + const workspaceCwd = this.workspaceCwd(config); + const disabled = config.getDisableAllHooks(); + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + v: STATUS_SCHEMA_VERSION, + sessionId, + workspaceCwd, + disabled, + hooks: [], + }; + } + const sessionHooks = hookSystem + .getSessionHooksManager() + .getAllSessionHooks(sessionId); + const hooks: ServeHookEntry[] = sessionHooks.map( + (entry): ServeHookEntry => ({ + kind: 'hook', + eventName: entry.eventName, + config: this.serializeHookConfig(entry.config), + source: 'session', + ...(entry.matcher ? { matcher: entry.matcher } : {}), + ...(entry.sequential !== undefined + ? { sequential: entry.sequential } + : {}), + enabled: true, + hookId: entry.hookId, + ...(entry.skillRoot ? { skillRoot: entry.skillRoot } : {}), + }), + ); + return { + v: STATUS_SCHEMA_VERSION, + sessionId, + workspaceCwd, + disabled, + hooks, + }; + } catch (error) { + let disabled = false; + try { + disabled = config.getDisableAllHooks(); + } catch { + // config may be in a broken state; fall back to false + } + return { + v: STATUS_SCHEMA_VERSION, + sessionId, + workspaceCwd: this.safeWorkspaceCwd(config), + disabled, + hooks: [], + errors: [this.errorCell('session_hooks', error)], + }; + } + } + + private buildWorkspaceExtensionsStatus( + config: Config, + ): ServeWorkspaceExtensionsStatus { + try { + const workspaceCwd = this.workspaceCwd(config); + const extensions = config.getExtensions(); + const entries: ServeExtensionEntry[] = extensions.map( + (ext): ServeExtensionEntry => { + const capabilities: ServeExtensionCapabilities = { + mcpServerCount: ext.mcpServers + ? Object.keys(ext.mcpServers).length + : 0, + skillCount: ext.skills?.length ?? 0, + agentCount: ext.agents?.length ?? 0, + hookCount: ext.hooks + ? Object.values(ext.hooks).reduce( + (sum, defs) => sum + (defs?.length ?? 0), + 0, + ) + : 0, + commandCount: ext.commands?.length ?? 0, + contextFileCount: ext.contextFiles.length, + channelCount: ext.channels ? Object.keys(ext.channels).length : 0, + hasSettings: (ext.settings?.length ?? 0) > 0, + }; + return { + kind: 'extension', + id: ext.id, + name: ext.name, + version: ext.version, + isActive: ext.isActive, + path: ext.path, + ...(ext.installMetadata?.source + ? { source: redactUrlCredentials(ext.installMetadata.source) } + : {}), + ...(ext.installMetadata?.type + ? { installType: ext.installMetadata.type } + : {}), + ...(ext.installMetadata?.originSource + ? { originSource: ext.installMetadata.originSource } + : {}), + ...(ext.installMetadata?.ref + ? { ref: ext.installMetadata.ref } + : {}), + ...(ext.installMetadata?.autoUpdate !== undefined + ? { autoUpdate: ext.installMetadata.autoUpdate } + : {}), + capabilities, + }; + }, + ); + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: true, + extensions: entries, + }; + } catch (error) { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: this.safeWorkspaceCwd(config), + initialized: false, + extensions: [], + errors: [this.errorCell('extensions', error)], + }; + } + } + private async installSkillFromUrl( request: QwenSkillInstallRequest, ): Promise> { @@ -4018,14 +4907,31 @@ class QwenAgent implements Agent { }; } case SERVE_STATUS_EXT_METHODS.workspaceMcp: - return this.buildWorkspaceMcpStatus(this.config) as unknown as Record< - string, - unknown - >; + return (await this.buildWorkspaceMcpStatus( + this.config, + )) as unknown as Record; + case SERVE_STATUS_EXT_METHODS.workspaceMcpTools: { + const serverName = params['serverName']; + if (typeof serverName !== 'string' || serverName.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing serverName', + ); + } + return this.buildWorkspaceMcpToolsStatus( + this.config, + serverName, + ) as unknown as Record; + } case SERVE_STATUS_EXT_METHODS.workspaceSkills: return (await this.buildWorkspaceSkillsStatus( this.config, )) as unknown as Record; + case SERVE_STATUS_EXT_METHODS.workspaceTools: + return this.buildWorkspaceToolsStatus(this.config) as unknown as Record< + string, + unknown + >; case SERVE_STATUS_EXT_METHODS.workspaceProviders: return this.buildWorkspaceProvidersStatus( this.config, @@ -4047,6 +4953,19 @@ class QwenAgent implements Agent { unknown >; } + case SERVE_STATUS_EXT_METHODS.sessionContextUsage: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + return (await this.buildSessionContextUsageStatus( + sessionId, + params['detail'] === true, + )) as unknown as Record; + } case SERVE_STATUS_EXT_METHODS.sessionSupportedCommands: { const sessionId = params['sessionId']; if (typeof sessionId !== 'string' || sessionId.length === 0) { @@ -4059,14 +4978,100 @@ class QwenAgent implements Agent { sessionId, )) as unknown as Record; } + case SERVE_STATUS_EXT_METHODS.sessionTasks: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + return this.buildSessionTasksStatus(sessionId) as unknown as Record< + string, + unknown + >; + } + case SERVE_STATUS_EXT_METHODS.sessionStats: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + return this.buildSessionStatsStatus(sessionId) as unknown as Record< + string, + unknown + >; + } + case SERVE_STATUS_EXT_METHODS.sessionRewindSnapshots: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const session = this.sessions.get(sessionId as string); + if (!session) { + throw RequestError.invalidParams( + undefined, + `Session not found for id: ${sessionId}`, + ); + } + const fhs = session.getConfig().getFileHistoryService(); + const snapshots = fhs.getSnapshots(); + const prefix = (sessionId as string) + '########'; + const results = await Promise.all( + snapshots + .map((s, idx) => ({ s, idx })) + .filter( + ({ s }) => + s.promptId.startsWith(prefix) && + /^\d+$/.test(s.promptId.slice(prefix.length)), + ) + .map(async ({ s, idx }) => { + const stats = await fhs.getDiffStats(s.promptId); + return { + promptId: s.promptId, + turnIndex: idx, + timestamp: s.timestamp.toISOString(), + diffStats: { + filesChanged: stats?.filesChanged?.length ?? 0, + insertions: stats?.insertions ?? 0, + deletions: stats?.deletions ?? 0, + }, + }; + }), + ); + return { snapshots: results } as unknown as Record; + } + case SERVE_STATUS_EXT_METHODS.workspaceHooks: + return this.buildWorkspaceHooksStatus(this.config) as unknown as Record< + string, + unknown + >; + case SERVE_STATUS_EXT_METHODS.sessionHooks: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + return this.buildSessionHooksStatus(sessionId) as unknown as Record< + string, + unknown + >; + } + case SERVE_STATUS_EXT_METHODS.workspaceExtensions: + return this.buildWorkspaceExtensionsStatus( + this.config, + ) as unknown as Record; case SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart: { - // #4175 Wave 4 PR 17. Single-server MCP restart with budget - // pre-check from PR 14 v1's accounting snapshot. Soft skips - // (in_flight, disabled, budget_would_exceed) come back as - // structured 200 responses; hard errors (server not in - // config, manager unavailable, post-discover not connected) - // propagate as JSON-RPC errors with structured `data` that - // the bridge translates to typed HTTP responses. + // Single-server MCP restart with budget pre-check. Soft skips + // return structured 200 responses; hard errors propagate as + // JSON-RPC errors. Pool-mode routing when available. const serverName = params['serverName']; if (typeof serverName !== 'string' || serverName.length === 0) { throw RequestError.invalidParams( @@ -4074,14 +5079,26 @@ class QwenAgent implements Agent { 'Invalid or missing serverName', ); } + // Optional `entryIndex` selector for pool-mode targeted restarts. + let entryIndex: number | undefined; + const rawEntryIndex = params['entryIndex']; + if (rawEntryIndex !== undefined && rawEntryIndex !== '*') { + if ( + typeof rawEntryIndex !== 'number' || + !Number.isInteger(rawEntryIndex) || + rawEntryIndex < 0 + ) { + throw RequestError.invalidParams( + undefined, + 'entryIndex must be a non-negative integer or "*"', + ); + } + entryIndex = rawEntryIndex; + } const servers = this.config.getMcpServers() ?? {}; if (!Object.prototype.hasOwnProperty.call(servers, serverName)) { - // #4282 gpt-5.5 C5 fold-in: the bridge looks for - // `data.errorKind: 'mcp_server_not_found'` to map this back - // to a typed `McpServerNotFoundError` and a stable HTTP 404 - // — without the structured payload the bridge can't - // distinguish this from a generic JSON-RPC error and the - // route falls through to 500. + // Structured payload so the bridge can map to a typed + // `McpServerNotFoundError` and HTTP 404. throw new RequestError( -32004, `MCP server not configured: ${JSON.stringify(serverName)}`, @@ -4114,16 +5131,8 @@ class QwenAgent implements Agent { const accounting = manager.getMcpClientAccounting(); const budget = manager.getMcpClientBudget(); const mode = manager.getMcpBudgetMode(); - // #4282 gpt-5.5 C3 fold-in: enforce-mode capacity is reserved - // by `tryReserveSlot` via `reservedSlots` (which counts - // configured + in-flight + disconnected slot holders), not by - // `total` (which only counts CONNECTED clients). Comparing - // `total` to budget under-counted reservations and let a - // restart proceed past capacity; the manager would then - // refuse internally and return void, while this handler - // reported `restarted: true`. Mirror the manager's policy - // by checking `reservedSlots.length` for servers that don't - // already hold a reservation. + // Check `reservedSlots.length` (not `total`) to mirror the + // manager's enforce-mode capacity policy. if ( mode === 'enforce' && budget !== undefined && @@ -4137,14 +5146,78 @@ class QwenAgent implements Agent { reason: 'budget_would_exceed' as const, }; } + // Re-read MERGED settings to pick up any `tools.disabled` + // toggles applied since this ACP child booted. Reads need the + // union (User + System + Workspace); writes target Workspace only. + try { + const fresh = loadSettings(this.config.getTargetDir()); + const mergedDisabled = fresh.merged.tools?.disabled; + // Detect and stderr-log malformed `tools.disabled` before + // clearing so a misconfigured settings file is loud. + if (mergedDisabled !== undefined && !Array.isArray(mergedDisabled)) { + process.stderr.write( + `qwen serve: MCP restart for ${JSON.stringify(serverName)}: ` + + `tools.disabled has unexpected type ${typeof mergedDisabled}; ` + + `clearing disabled set — check settings.json. ` + + `Expected an array of strings.\n`, + ); + } + // Use the shared `normalizeDisabledToolList` helper so + // boot and restart paths agree on what counts as "disabled". + const disabledList = normalizeDisabledToolList(mergedDisabled); + this.config.setDisabledTools(new Set(disabledList)); + } catch (err) { + // Settings load failures are non-fatal — fall through with + // the existing in-memory snapshot. + process.stderr.write( + `qwen serve: MCP restart for ${JSON.stringify(serverName)} ` + + `could not refresh disabledTools from merged settings ` + + `(${err instanceof Error ? err.message : String(err)}); ` + + `proceeding with the bootstrap snapshot — recently toggled ` + + `tools may not take effect until daemon restart.\n`, + ); + } + // Pool-mode routing: when the pool holds entries for this name, + // route through the pool. Legacy path stays as fallback. + const poolSnapshot = this.mcpPool?.getSnapshot(); + const poolHasEntries = + poolSnapshot !== undefined && + (poolSnapshot.byName[serverName]?.entryCount ?? 0) > 0; + if (this.mcpPool && poolHasEntries) { + const restartResults = await this.mcpPool.restartByName(serverName, { + ...(entryIndex !== undefined ? { entryIndex } : {}), + }); + // When `entryIndex` doesn't match any current pool entry, + // return an empty `entries` array (soft signal). + return { + serverName, + entries: restartResults, + }; + } + // Route through `ToolRegistry.discoverToolsForServer` (not the + // manager directly) so existing tools are purged before + // rediscovery — ensures toggle-disable-then-restart works. + // An explicit `entryIndex` against the legacy (no-pool) path + // is invalid unless it's 0. + if (entryIndex !== undefined && entryIndex !== 0) { + throw RequestError.invalidParams( + undefined, + `entryIndex=${entryIndex} requested but pool not active for ` + + `${JSON.stringify(serverName)} — legacy single-entry path ` + + `only supports entryIndex=0 or undefined`, + ); + } const start = Date.now(); - await manager.discoverMcpToolsForServer(serverName, this.config); - // #4282 gpt-5.5 C4 fold-in: `discoverMcpToolsForServer` - // catches reconnect/discovery errors internally (logs and - // resolves void) so a broken MCP server would otherwise - // surface as `restarted: true`. Verify the live status from - // the per-server status map; anything other than CONNECTED - // means the restart didn't take effect. + const toolRegistry = this.config.getToolRegistry(); + if (!toolRegistry) { + throw RequestError.internalError( + undefined, + 'ToolRegistry unavailable on this Config', + ); + } + await toolRegistry.discoverToolsForServer(serverName); + // Verify the live status after restart; anything other than + // CONNECTED means the restart didn't take effect. const postStatus = getMCPServerStatus(serverName); if (postStatus !== MCPServerStatus.CONNECTED) { throw new RequestError( @@ -4164,13 +5237,193 @@ class QwenAgent implements Agent { durationMs: Date.now() - start, }; } + case SERVE_CONTROL_EXT_METHODS.workspaceMcpManage: { + const serverName = params['serverName']; + const action = params['action']; + if (typeof serverName !== 'string' || serverName.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing serverName', + ); + } + if ( + action !== 'enable' && + action !== 'disable' && + action !== 'authenticate' && + action !== 'clear-auth' + ) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing MCP manage action', + ); + } + const servers = this.config.getMcpServers() ?? {}; + const server = servers[serverName]; + if (!server) { + throw new RequestError( + -32004, + `MCP server not configured: ${JSON.stringify(serverName)}`, + { errorKind: 'mcp_server_not_found', serverName }, + ); + } + const toolRegistry = this.config.getToolRegistry(); + if (!toolRegistry) { + throw RequestError.internalError( + undefined, + 'ToolRegistry unavailable on this Config', + ); + } + + if (action === 'enable') { + const settings = loadSettings(this.config.getTargetDir()); + for (const scope of [SettingScope.User, SettingScope.Workspace]) { + const scopeSettings = settings.forScope(scope).settings; + const currentExcluded = scopeSettings.mcp?.excluded || []; + if (currentExcluded.includes(serverName)) { + settings.setValue( + scope, + 'mcp.excluded', + currentExcluded.filter((name: string) => name !== serverName), + ); + } + } + const currentExcluded = this.config.getExcludedMcpServers() || []; + this.config.setExcludedMcpServers( + currentExcluded.filter((name: string) => name !== serverName), + ); + await toolRegistry.discoverToolsForServer(serverName); + return { serverName, action, ok: true, changed: true }; + } + + if (action === 'disable') { + const settings = loadSettings(this.config.getTargetDir()); + const userSettings = settings.forScope(SettingScope.User).settings; + const workspaceSettings = settings.forScope( + SettingScope.Workspace, + ).settings; + let targetScope = SettingScope.User; + if (server.extensionName) { + throw RequestError.invalidParams( + undefined, + `Cannot disable extension MCP server: ${serverName}`, + ); + } + if (workspaceSettings.mcpServers?.[serverName]) { + targetScope = SettingScope.Workspace; + } else if (userSettings.mcpServers?.[serverName]) { + targetScope = SettingScope.User; + } + const scopeSettings = settings.forScope(targetScope).settings; + const currentExcluded = scopeSettings.mcp?.excluded || []; + if (!currentExcluded.includes(serverName)) { + settings.setValue(targetScope, 'mcp.excluded', [ + ...currentExcluded, + serverName, + ]); + } + const runtimeExcluded = this.config.getExcludedMcpServers() || []; + if (!runtimeExcluded.includes(serverName)) { + this.config.setExcludedMcpServers([...runtimeExcluded, serverName]); + } + await toolRegistry.disableMcpServer(serverName); + return { serverName, action, ok: true, changed: true }; + } + + if (action === 'clear-auth') { + const tokenStorage = new MCPOAuthTokenStorage(); + await tokenStorage.deleteCredentials(serverName); + await toolRegistry.disconnectServer(serverName); + return { serverName, action, ok: true, changed: true }; + } + + const messages: string[] = []; + let authUrl: string | undefined; + const displayListener = (message: unknown) => { + if (typeof message === 'string') { + messages.push(message); + } else if (message && typeof message === 'object') { + const key = (message as { key?: unknown }).key; + if (typeof key === 'string') { + messages.push(key); + } + } + }; + const authUrlListener = (url: unknown) => { + if (typeof url === 'string') { + authUrl = url; + } + }; + appEvents.on(AppEvent.OauthDisplayMessage, displayListener); + appEvents.on(AppEvent.OauthAuthUrl, authUrlListener); + try { + const oauthConfig = server.oauth ?? { enabled: false }; + const mcpServerUrl = server.httpUrl || server.url; + const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); + await authProvider.authenticate( + serverName, + oauthConfig, + mcpServerUrl, + appEvents, + ); + messages.push( + `Successfully authenticated and refreshed tools for '${serverName}'.`, + ); + await toolRegistry.discoverToolsForServer(serverName); + const geminiClient = this.config.getGeminiClient(); + if (geminiClient) { + await geminiClient.setTools(); + } + return { + serverName, + action, + ok: true, + changed: true, + messages, + ...(authUrl ? { authUrl } : {}), + }; + } finally { + appEvents.removeListener( + AppEvent.OauthDisplayMessage, + displayListener, + ); + appEvents.removeListener(AppEvent.OauthAuthUrl, authUrlListener); + } + } + case SERVE_CONTROL_EXT_METHODS.workspaceAgentGenerate: { + const description = params['description']; + if ( + typeof description !== 'string' || + !description.trim() || + description.length > 4096 + ) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing description (max 4096 chars)', + ); + } + // No end-to-end AbortSignal from the bridge ext-method yet. + // The bridge may time out via Promise.race, but that only + // rejects the caller — this generator keeps running until it + // finishes naturally. A real fix requires wiring an abort + // signal through the ext-method protocol. + return (await subagentGenerator( + description.trim(), + this.config, + AbortSignal.timeout(5 * 60_000), + )) as unknown as Record; + } + case SERVE_CONTROL_EXT_METHODS.sessionClose: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + await this.closeStoredSession(sessionId); + return { sessionId, closed: true }; + } case SERVE_CONTROL_EXT_METHODS.sessionApprovalMode: { - // #4175 Wave 4 PR 17: remote callers change a live session's - // approval mode via this ACP extMethod. `Config.setApprovalMode` - // throws `TrustGateError` for privileged modes in an untrusted - // folder; we let it propagate — the bridge's mapping helper - // converts the name to `errorKind: 'auth_env_error'` on the - // wire so the SDK consumer gets a structured failure. const sessionId = params['sessionId']; const mode = params['mode']; if (typeof sessionId !== 'string' || sessionId.length === 0) { @@ -4210,6 +5463,479 @@ class QwenAgent implements Agent { const current = config.getApprovalMode(); return { previous, current }; } + case SERVE_CONTROL_EXT_METHODS.sessionLanguage: { + const sessionId = params['sessionId']; + const language = params['language']; + const syncOutputLanguage = params['syncOutputLanguage'] === true; + + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const allowedLanguages = [ + ...SUPPORTED_LANGUAGES.map((l) => l.code), + 'auto', + ]; + if ( + typeof language !== 'string' || + !allowedLanguages.includes(language) + ) { + throw RequestError.invalidParams( + undefined, + `Invalid language; must be one of: ${allowedLanguages.join(', ')}`, + ); + } + + const session = this.sessionOrThrow(sessionId); + + try { + await setLanguageAsync(language); + } catch (err) { + debugLogger.warn('setLanguageAsync failed:', err); + throw new RequestError( + -32603, + `Failed to switch UI language: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const resolvedLanguage = getCurrentLanguage(); + + try { + this.settings.setValue( + SettingScope.User, + 'general.language', + language, + ); + } catch (err) { + debugLogger.warn('Failed to persist UI language setting:', err); + } + + let outputLanguage: string | null = null; + let refreshed = false; + + if (syncOutputLanguage) { + const resolved = resolveOutputLanguage(language); + const settingValue = isAutoLanguage(language) + ? OUTPUT_LANGUAGE_AUTO + : resolved; + + let fileWriteOk = false; + try { + writeOutputLanguageAndRegisterPath( + settingValue, + session.getConfig(), + ); + fileWriteOk = true; + } catch (err) { + debugLogger.warn('Failed to write output-language.md:', err); + } + + if (fileWriteOk) { + try { + this.settings.setValue( + SettingScope.User, + 'general.outputLanguage', + settingValue, + ); + } catch (err) { + debugLogger.warn( + 'Failed to persist output language setting:', + err, + ); + } + const writtenPath = + session.getConfig().getOutputLanguageFilePath() ?? + getOutputLanguageFilePath(); + const allSessions = [...this.sessions.values()]; + const results = await Promise.allSettled( + allSessions.map(async (s) => { + const cfg = s.getConfig(); + let sessionPath: string | undefined; + try { + sessionPath = cfg.getOutputLanguageFilePath(); + if (sessionPath && sessionPath !== writtenPath) { + updateOutputLanguageFile(settingValue, sessionPath); + } + if (!sessionPath) { + writeOutputLanguageAndRegisterPath(settingValue, cfg); + } + } catch (err) { + debugLogger.warn( + `Failed to write output-language.md for session ${s.getId()} (path=${sessionPath ?? 'global-default'}):`, + err, + ); + } + await cfg.refreshHierarchicalMemory(); + await cfg.getGeminiClient()?.refreshSystemInstruction(); + }), + ); + const failedCount = results.filter( + (r) => r.status === 'rejected', + ).length; + if (failedCount > 0) { + debugLogger.warn( + `Language refresh failed for ${failedCount}/${results.length} session(s)`, + ); + } + refreshed = results.length === 0 || failedCount === 0; + } + outputLanguage = fileWriteOk ? resolved : null; + } + + return { language: resolvedLanguage, outputLanguage, refreshed }; + } + case SERVE_CONTROL_EXT_METHODS.sessionRecap: { + // Generate a one-sentence "where did I leave off" summary. + // Best-effort: returns `null` on short history or model failure. + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + debugLogger.debug(`recap ext-method received for session=${sessionId}`); + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + // v1: no cross-process abort plumbing. The bridge does not listen + // for HTTP client disconnect and no AbortSignal is threaded through + // the ext-method, so the LLM call in this child always runs to + // completion. The only ceilings are the bridge's 60s + // `SESSION_RECAP_TIMEOUT_MS` backstop and the transport-closed race + // against ACP channel death. Acceptable because recap is short + // (single-attempt side-query, `maxOutputTokens: 300`). A future + // request-id-based cancel ext-method can plumb a real signal + // end-to-end if the bandwidth cost ever becomes an issue. + const recap = await generateSessionRecap( + config, + new AbortController().signal, + ); + debugLogger.debug( + `recap ext-method completed for session=${sessionId} result=${recap ? `len=${recap.length}` : 'null'}`, + ); + return { sessionId, recap }; + } + case SERVE_CONTROL_EXT_METHODS.sessionBtw: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const question = params['question']; + if ( + typeof question !== 'string' || + !question.trim() || + question.length > BTW_MAX_INPUT_LENGTH + ) { + throw RequestError.invalidParams( + undefined, + `Invalid or missing question (max ${BTW_MAX_INPUT_LENGTH} chars)`, + ); + } + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + const cacheSafeParams = buildBtwCacheSafeParams(config); + if (!cacheSafeParams) { + debugLogger.debug(`btw: no cacheSafeParams for session=${sessionId}`); + return { sessionId, answer: null }; + } + const childSignal = AbortSignal.timeout(BTW_CHILD_TIMEOUT_MS); + let result; + try { + result = await runForkedAgent({ + config, + userMessage: buildBtwPrompt(question.trim()), + cacheSafeParams, + abortSignal: childSignal, + }); + } catch (err) { + if (childSignal.aborted) { + throw RequestError.internalError( + undefined, + 'Side question timed out after 55s', + ); + } + throw err; + } + return { sessionId, answer: result.text || null }; + } + case SERVE_CONTROL_EXT_METHODS.sessionShellHistory: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const command = params['command']; + if (typeof command !== 'string') { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing command', + ); + } + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + const geminiClient = config.getGeminiClient()!; + const outputText = + typeof params['output'] === 'string' ? params['output'] : ''; + geminiClient.addHistory({ + role: 'user', + parts: [ + { + text: `I ran the following shell command:\n\`\`\`sh\n${command}\n\`\`\`\n\nThis produced the following result:\n\`\`\`\n${outputText}\n\`\`\``, + }, + ], + }); + return { sessionId, injected: true }; + } + case SERVE_CONTROL_EXT_METHODS.sessionTaskCancel: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const taskId = params['taskId']; + if (typeof taskId !== 'string' || taskId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing taskId', + ); + } + const taskKind = params['taskKind']; + if ( + taskKind !== 'agent' && + taskKind !== 'shell' && + taskKind !== 'monitor' + ) { + throw RequestError.invalidParams( + undefined, + 'taskKind must be "agent", "shell", or "monitor"', + ); + } + debugLogger.info( + `sessionTaskCancel requested sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind}`, + ); + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + switch (taskKind) { + case 'agent': { + const task = config.getBackgroundTaskRegistry().get(taskId); + if ( + !task || + (task.status !== 'running' && task.status !== 'paused') + ) { + const reason = task ? 'not_running' : 'not_found'; + debugLogger.info( + `sessionTaskCancel skipped sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} reason=${reason} status=${task?.status ?? 'missing'}`, + ); + return { cancelled: false, reason, status: task?.status }; + } + if (task.status === 'paused') { + config.getBackgroundTaskRegistry().abandon(taskId); + } else { + config.getBackgroundTaskRegistry().cancel(taskId); + } + debugLogger.info( + `sessionTaskCancel completed sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} status=${task.status}`, + ); + return { cancelled: true, status: task.status }; + } + case 'shell': { + const task = config.getBackgroundShellRegistry().get(taskId); + if (!task || task.status !== 'running') { + const reason = task ? 'not_running' : 'not_found'; + debugLogger.info( + `sessionTaskCancel skipped sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} reason=${reason} status=${task?.status ?? 'missing'}`, + ); + return { cancelled: false, reason, status: task?.status }; + } + config.getBackgroundShellRegistry().requestCancel(taskId); + debugLogger.info( + `sessionTaskCancel completed sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} status=${task.status}`, + ); + return { cancelled: true, status: task.status }; + } + case 'monitor': { + const task = config.getMonitorRegistry().get(taskId); + if (!task || task.status !== 'running') { + const reason = task ? 'not_running' : 'not_found'; + debugLogger.info( + `sessionTaskCancel skipped sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} reason=${reason} status=${task?.status ?? 'missing'}`, + ); + return { cancelled: false, reason, status: task?.status }; + } + config.getMonitorRegistry().cancel(taskId); + debugLogger.info( + `sessionTaskCancel completed sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} status=${task.status}`, + ); + return { cancelled: true, status: task.status }; + } + default: { + const exhaustive: never = taskKind; + throw new Error(`Unhandled task kind: ${exhaustive}`); + } + } + } + case SERVE_CONTROL_EXT_METHODS.sessionGoalClear: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + const cleared = unregisterGoalHook(config, sessionId); + debugLogger.info( + `sessionGoalClear sessionId=${sessionId} cleared=${!!cleared} condition=${cleared?.condition ?? '(none)'}`, + ); + return { + cleared: !!cleared, + condition: cleared?.condition, + }; + } + case SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd: { + const name = params['name']; + const config = params['config']; + const originatorClientId = params['originatorClientId']; + if (typeof name !== 'string' || name.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing name', + ); + } + if ( + name.length > 256 || + !/^[A-Za-z0-9_-]+$/.test(name) || + name === '__proto__' || + name === 'constructor' || + name === 'prototype' + ) { + throw RequestError.invalidParams( + undefined, + 'Server name must be ≤256 chars, alphanumeric + underscore/hyphen, and not a reserved JS property name', + ); + } + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing config', + ); + } + if ( + typeof originatorClientId !== 'string' || + originatorClientId.length === 0 + ) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing originatorClientId', + ); + } + const manager = this.config.getToolRegistry()?.getMcpClientManager(); + if (!manager) { + throw RequestError.internalError( + undefined, + 'McpClientManager unavailable on this Config', + ); + } + try { + // Strip security-sensitive fields — runtime-added servers must + // not bypass permission gates via trust:true, leak cloud creds + // via authProviderType, manipulate tool filtering, or spawn in + // arbitrary directories + const { + trust: _trust, + authProviderType: _auth, + includeTools: _inc, + excludeTools: _exc, + cwd: _cwd, + env: _env, + oauth: _oauth, + headers: _headers, + type: _type, + ...safeConfig + } = config as Record; + const result = await manager.addRuntimeMcpServer( + name, + safeConfig as MCPServerConfig, + originatorClientId, + ); + return result as unknown as Record; + } catch (err) { + if (err instanceof McpBudgetWouldExceedError) { + throw new RequestError(-32099, err.message, { + errorKind: err.code, + serverName: err.serverName, + }); + } + if (err instanceof McpServerSpawnFailedError) { + throw new RequestError(-32099, err.message, { + errorKind: err.code, + serverName: err.serverName, + ...err.details, + }); + } + if (err instanceof InvalidMcpConfigError) { + throw new RequestError(-32099, err.message, { + errorKind: err.code, + serverName: err.serverName, + reason: err.reason, + }); + } + throw err; + } + } + case SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeRemove: { + const name = params['name']; + const originatorClientId = params['originatorClientId']; + if (typeof name !== 'string' || name.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing name', + ); + } + if ( + name.length > 256 || + !/^[A-Za-z0-9_-]+$/.test(name) || + name === '__proto__' || + name === 'constructor' || + name === 'prototype' + ) { + throw RequestError.invalidParams( + undefined, + 'Server name must be ≤256 chars, alphanumeric + underscore/hyphen, and not a reserved JS property name', + ); + } + if ( + typeof originatorClientId !== 'string' || + originatorClientId.length === 0 + ) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing originatorClientId', + ); + } + const manager = this.config.getToolRegistry()?.getMcpClientManager(); + if (!manager) { + throw RequestError.internalError( + undefined, + 'McpClientManager unavailable on this Config', + ); + } + const result = await manager.removeRuntimeMcpServer( + name, + originatorClientId, + ); + return result as unknown as Record; + } case 'deleteSession': { const sessionId = params['sessionId'] as string; if (!sessionId || !SESSION_ID_RE.test(sessionId)) { @@ -4276,24 +6002,15 @@ class QwenAgent implements Agent { ); return { success }; } - case 'rewindSession': { + case 'rewindSession': + case SERVE_CONTROL_EXT_METHODS.sessionRewind: { const sessionId = params['sessionId'] as string; - const targetTurnIndex = params['targetTurnIndex']; if (!sessionId || !SESSION_ID_RE.test(sessionId)) { throw RequestError.invalidParams( undefined, 'Invalid or missing sessionId', ); } - if ( - !Number.isInteger(targetTurnIndex) || - (targetTurnIndex as number) < 0 - ) { - throw RequestError.invalidParams( - undefined, - 'Invalid or missing targetTurnIndex', - ); - } const session = this.sessions.get(sessionId); if (!session) { throw RequestError.invalidParams( @@ -4302,11 +6019,97 @@ class QwenAgent implements Agent { ); } + let turnIndex: number | undefined = params['targetTurnIndex'] as + | number + | undefined; + const promptId = params['promptId'] as string | undefined; + + if (promptId && (turnIndex === undefined || turnIndex === null)) { + const prefix = sessionId + '########'; + if (!promptId.startsWith(prefix)) { + throw new RequestError(-32602, 'Invalid promptId format', { + errorKind: 'invalid_rewind_target', + }); + } + const suffix = promptId.slice(prefix.length); + if (!/^\d+$/.test(suffix)) { + throw new RequestError( + -32602, + 'Invalid promptId: non-numeric turn suffix', + { errorKind: 'invalid_rewind_target' }, + ); + } + // Derive turnIndex from the snapshot's position in the array, + // NOT from the promptId suffix. Session.turn is monotonic and + // does not reset on rewind, so after a rewind cycle the suffix + // no longer matches the turn's position in the current history. + const fhs = session.getConfig().getFileHistoryService(); + const snapshots = fhs.getSnapshots(); + const snapshotIdx = snapshots.findIndex( + (s) => s.promptId === promptId, + ); + if (snapshotIdx < 0) { + throw new RequestError( + -32602, + 'Snapshot not found for the given promptId', + { errorKind: 'invalid_rewind_target' }, + ); + } + turnIndex = snapshotIdx; + } + + if (!Number.isInteger(turnIndex) || (turnIndex as number) < 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing targetTurnIndex', + ); + } + const historyBeforeRewind = session.captureHistorySnapshot(); + let rewindResult; + try { + rewindResult = session.rewindToTurn(turnIndex as number); + } catch (err) { + if (err instanceof RequestError) { + const msg = err.message; + if (msg.includes('Cannot rewind while a prompt is running')) { + throw new RequestError(err.code, msg, { + errorKind: 'session_busy', + }); + } + if (msg.includes('compressed or does not exist')) { + throw new RequestError(err.code, msg, { + errorKind: 'invalid_rewind_target', + }); + } + } + throw err; + } + + let filesChanged: string[] = []; + let filesFailed: string[] = []; + const rewindFiles = params['rewindFiles'] !== false; + if (rewindFiles && promptId) { + const fhs = session.getConfig().getFileHistoryService(); + try { + const fileResult = await fhs.rewind(promptId, true); + filesChanged = fileResult.filesChanged; + filesFailed = fileResult.filesFailed; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + debugLogger.error( + `[ACP] File-history rewind failed for session=${sessionId} promptId=${promptId}: ${reason}`, + ); + filesFailed = [`file-history-rewind: ${reason}`]; + } + } + return { success: true, historyBeforeRewind, - ...session.rewindToTurn(targetTurnIndex as number), + ...rewindResult, + filesChanged, + filesFailed, }; } case 'qwen/session/loadUpdates': { @@ -4411,6 +6214,80 @@ class QwenAgent implements Agent { apiKeyEnvKey: cfg?.apiKeyEnvKey ?? null, }; } + case SERVE_CONTROL_EXT_METHODS.sessionBranch: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const name = params['name']; + + const sourceSession = this.sessions.get(sessionId); + if (!sourceSession) { + throw new RequestError(-32004, `Session not found: ${sessionId}`, { + errorKind: 'session_not_found', + sessionId, + }); + } + + const recording = sourceSession.getConfig().getChatRecordingService(); + if (recording) { + await recording.flush(); + } + + const newSessionId = randomUUID(); + return await runWithAcpRuntimeOutputDir( + this.settings, + cwd, + async () => { + const sessionService = new SessionService(cwd); + await sessionService.forkSession(sessionId, newSessionId); + + let title: string; + try { + let baseName: string; + if (typeof name === 'string' && name.trim().length > 0) { + baseName = name.trim(); + } else { + const existingTitle = recording?.getCurrentCustomTitle(); + const stripped = existingTitle + ?.replace(/\s*\(Branch(?:\s+\d+)?\)\s*$/, '') + .trim(); + if (stripped && stripped.length > 0) { + baseName = stripped; + } else { + baseName = sessionId.slice(0, 8); + } + } + + title = await computeUniqueBranchTitle(baseName, sessionService); + const renamed = await sessionService.renameSession( + newSessionId, + title, + 'manual', + ); + if (!renamed) { + throw new RequestError( + -32603, + `Failed to set title on forked session ${newSessionId}`, + { errorKind: 'internal', sessionId: newSessionId }, + ); + } + } catch (err) { + sessionService.removeSession(newSessionId).catch((rmErr) => { + process.stderr.write( + `qwen serve: failed to clean up orphan session ${newSessionId}: ${rmErr instanceof Error ? rmErr.message : rmErr}\n`, + ); + }); + throw err; + } + + return { newSessionId, title }; + }, + ); + } case 'qwen/settings/getCore': { const settings = loadSettings(cwd); this.settings = settings; @@ -4672,6 +6549,126 @@ class QwenAgent implements Agent { unknown >; } + case SERVE_CONTROL_EXT_METHODS.workspaceReload: { + const oldMerged = structuredClone(this.settings.merged); + + this.settings.reloadScopeFromDisk(SettingScope.User); + this.settings.reloadScopeFromDisk(SettingScope.Workspace); + const newMerged = this.settings.merged; + + const envResult = reloadEnvironment(newMerged, cwd); + + const changed = diffSettingsKeys(oldMerged, newMerged); + const envChanged = + envResult.updatedKeys.length > 0 || envResult.removedKeys.length > 0; + + const sessions = [...this.sessions.entries()]; + const refreshed: string[] = []; + const skipped: string[] = []; + + const results = await Promise.allSettled( + sessions.map(async ([id, session]) => { + if (!session.isIdle()) { + skipped.push(id); + return; + } + const config = session.getConfig(); + const authType = config.getAuthType(); + + if (changed.has('modelProviders')) { + try { + config.reloadModelProvidersConfig(newMerged.modelProviders); + } catch (err) { + debugLogger.warn( + `reload: reloadModelProvidersConfig failed for session ${id}: ${err}`, + ); + } + } + + const newModelName = newMerged.model?.name; + if ( + changed.has('model') && + newModelName && + newModelName !== config.getModel() && + authType + ) { + try { + await config.switchModel(authType, newModelName); + } catch (err) { + debugLogger.warn( + `reload: switchModel failed for session ${id}: ${err}`, + ); + } + } else if ( + (changed.has('modelProviders') || envChanged) && + authType + ) { + try { + await config.refreshAuth(authType); + } catch (err) { + debugLogger.warn( + `reload: refreshAuth failed for session ${id}: ${err}`, + ); + } + } + + if (changed.has('tools')) { + const disabled = normalizeDisabledToolList( + newMerged.tools?.disabled, + ); + config.setDisabledTools(new Set(disabled)); + + const newMode = newMerged.tools?.approvalMode; + if ( + newMode && + APPROVAL_MODES.includes(newMode as ApprovalMode) && + newMode !== config.getApprovalMode() + ) { + try { + config.setApprovalMode(newMode as ApprovalMode); + } catch (err) { + debugLogger.warn( + `reload: setApprovalMode failed for session ${id}: ${err}`, + ); + } + } + } + + try { + await config.refreshHierarchicalMemory(); + } catch (err) { + debugLogger.warn( + `reload: refreshHierarchicalMemory failed for session ${id}: ${err}`, + ); + } + try { + await config.getGeminiClient()?.refreshSystemInstruction(); + } catch (err) { + debugLogger.warn( + `reload: refreshSystemInstruction failed for session ${id}: ${err}`, + ); + } + + refreshed.push(id); + }), + ); + for (let i = 0; i < results.length; i++) { + if (results[i]!.status === 'rejected') { + const reason = (results[i] as PromiseRejectedResult).reason; + debugLogger.warn( + `Session ${sessions[i]![0]} reload failed: ${reason}`, + ); + skipped.push(sessions[i]![0]); + } + } + + return { + env: envResult, + changedKeys: [...changed], + sessionsRefreshed: refreshed, + sessionsSkipped: skipped, + }; + } default: throw RequestError.methodNotFound(method); } @@ -4766,51 +6763,33 @@ class QwenAgent implements Agent { // at cold start. buildDisabledSkillNamesProvider(this.settings), ); - // PR 14b fix #2 (codex review round 1): register the MCP guardrail - // budget-event callback BEFORE `config.initialize()`. Pre-fix the - // registration ran AFTER initialize, which (a) missed end-of-pass - // events under `QWEN_CODE_LEGACY_MCP_BLOCKING=1` (synchronous - // discovery completes inside initialize, before our setter runs) - // and (b) raced against background-discovery completion under the - // default progressive mode. `Config.setMcpBudgetEventCallback` - // stashes the callback and `createToolRegistry` applies it to the - // manager BEFORE `discoverAllTools` / `startMcpDiscoveryInBackground` - // fires, closing both windows. - // - // sessionId source: `config.getSessionId()` reads the Config's own - // session id (auto-assigned via `randomUUID()` in the Config - // constructor when no override is passed — see `config.ts:849`), - // so the value is available immediately after `loadCliConfig` - // returns. The closure pins it for the manager's whole lifetime. - // - // Defensive `typeof` checks tolerate stub Configs / ToolRegistries - // in older tests (older fixtures may omit `setMcpBudgetEventCallback` - // or `getSessionId`). + // Inject the workspace-shared MCP transport pool BEFORE + // `config.initialize()` so the ToolRegistry picks it up. + if ( + this.mcpPool !== undefined && + typeof config.setMcpTransportPool === 'function' + ) { + config.setMcpTransportPool(this.mcpPool); + } + // Register the MCP budget-event callback BEFORE `config.initialize()` + // so it catches events from both synchronous and background discovery. const wiredSessionId = typeof config.getSessionId === 'function' ? config.getSessionId() : undefined; + // When the workspace-scoped budget controller is active, skip the + // per-session callback to prevent double-firing. Daemons without + // a configured budget keep the per-session callback. + const skipPerSessionBudgetCallback = this.workspaceMcpBudget !== undefined; if ( + !skipPerSessionBudgetCallback && typeof config.setMcpBudgetEventCallback === 'function' && wiredSessionId !== undefined ) { const sid = wiredSessionId; config.setMcpBudgetEventCallback((event) => { - // Fire-and-forget: `extNotification` returns Promise but - // the manager's call site doesn't await. `.catch` suppresses - // unhandled rejections — a mid-flight ACP disconnect would - // otherwise crash the child. Snapshot still carries the state - // for clients that reconnect. - // - // PR 14b fix (codex round 3 — DeepSeek): pre-fix the catch - // handler was `() => {}`, silently dropping every error - // including "real" ones (serialization bugs, protocol - // violations) — operators had no debug trail. Now logs at - // `debug` level: ACP channel closure during shutdown is the - // expected case and would spam at higher levels, but `debug` - // is opt-in so when an oncall engineer DOES turn it on for - // an MCP guardrail incident, they see exactly which event - // dropped and why. + // Fire-and-forget. `.catch` suppresses unhandled rejections + // and logs at debug level for operator visibility. void this.connection .extNotification('qwen/notify/session/mcp-budget-event', { v: 1, @@ -5037,3 +7016,20 @@ class QwenAgent implements Agent { return authType ? formatAcpModelId(baseModelId, authType) : baseModelId; } } + +function diffSettingsKeys( + oldMerged: Record, + newMerged: Record, +): Set { + const changed = new Set(); + const allKeys = new Set([ + ...Object.keys(oldMerged), + ...Object.keys(newMerged), + ]); + for (const key of allKeys) { + if (JSON.stringify(oldMerged[key]) !== JSON.stringify(newMerged[key])) { + changed.add(key); + } + } + return changed; +} diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index 0511a5c6f5..46b6947333 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -109,18 +109,55 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ YOLO: 'yolo', PLAN: 'plan', }, + Kind: { + Read: 'read', + Edit: 'edit', + Delete: 'delete', + Move: 'move', + Search: 'search', + Execute: 'execute', + Think: 'think', + Fetch: 'fetch', + Other: 'other', + }, AuthType: {}, clearCachedCredentialFile: vi.fn(), QwenOAuth2Event: {}, qwenOAuth2Events: { on: vi.fn(), off: vi.fn() }, + MCP_BUDGET_WARN_FRACTION: 0.75, MCPServerConfig: vi.fn().mockImplementation((...args: unknown[]) => ({ _args: args, })), SessionService: vi.fn(), SESSION_TITLE_MAX_LENGTH: 200, tokenLimit: vi.fn(), + getMCPDiscoveryState: vi.fn(() => 'not_started'), + getMCPServerStatus: vi.fn(() => 'disconnected'), + MCPDiscoveryState: { + NOT_STARTED: 'not_started', + IN_PROGRESS: 'in_progress', + COMPLETED: 'completed', + }, + MCPServerStatus: { + DISCONNECTED: 'disconnected', + CONNECTING: 'connecting', + CONNECTED: 'connected', + }, + McpTransportPool: vi.fn().mockImplementation(() => ({ + acquire: vi.fn(), + release: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + off: vi.fn(), + })), + POOLED_TRANSPORTS_DEFAULT: new Set(), SessionStartSource: { Startup: 'startup', Resume: 'resume' }, SessionEndReason: { PromptInputExit: 'prompt_input_exit', Other: 'other' }, + WorkspaceMcpBudget: vi.fn().mockImplementation(() => ({ + register: vi.fn(), + unregister: vi.fn(), + snapshot: vi.fn(() => ({})), + })), restoreWorktreeContext: mockRestoreWorktreeContext, HookEventName: { PreToolUse: 'PreToolUse', @@ -268,6 +305,13 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => { hasHooksForEvent: vi.fn().mockReturnValue(false), getResumedSessionData: vi.fn().mockReturnValue(undefined), getSessionService: vi.fn().mockReturnValue(mockSessionService), + getWorkspaceContext: vi.fn().mockReturnValue({ + getDirectories: vi.fn().mockReturnValue([]), + addDirectory: vi.fn(), + }), + getDebugMode: vi.fn().mockReturnValue(false), + getMcpServers: vi.fn().mockReturnValue({}), + setMcpBudgetEventCallback: vi.fn(), }; } @@ -305,6 +349,13 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => { getCurrentAuthType: vi.fn().mockReturnValue('api-key'), }), refreshAuth: vi.fn().mockResolvedValue(undefined), + getWorkspaceContext: vi.fn().mockReturnValue({ + getDirectories: vi.fn().mockReturnValue([]), + addDirectory: vi.fn(), + }), + getDebugMode: vi.fn().mockReturnValue(false), + getMcpServers: vi.fn().mockReturnValue({}), + setMcpBudgetEventCallback: vi.fn(), } as unknown as Config; processExitSpy = vi diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts index 0188fdaa41..cf15142437 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts @@ -259,6 +259,11 @@ describe('HistoryReplayer', () => { rawInput: { path: '/test.ts' }, _meta: { toolName: 'read_file', + // #4175 F4 prereq — ToolCallEmitter now stamps provenance + // on every tool_call / tool_call_update event so the UI can + // dispatch on builtin / mcp / subagent without string- + // matching toolName. + provenance: 'builtin', timestamp: toEpochMs(record.timestamp), }, }), @@ -314,6 +319,8 @@ describe('HistoryReplayer', () => { rawOutput: 'File contents here', _meta: { toolName: 'read_file', + // #4175 F4 prereq — provenance stamped on update events too. + provenance: 'builtin', timestamp: toEpochMs(record.timestamp), }, }); diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 92b4652f75..52e8e85331 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -45,6 +45,8 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { warn: debugLoggerWarnSpy, error: vi.fn(), }), + generatePromptSuggestion: vi.fn(), + logPromptSuggestion: vi.fn(), }; }); @@ -204,6 +206,7 @@ describe('Session', () => { }; let mockBackgroundTaskRegistry: { setNotificationCallback: ReturnType; + hasUnfinalizedTasks: ReturnType; }; let mockMonitorRegistry: { setNotificationCallback: ReturnType; @@ -245,6 +248,7 @@ describe('Session', () => { }; mockBackgroundTaskRegistry = { setNotificationCallback: vi.fn(), + hasUnfinalizedTasks: vi.fn().mockReturnValue(false), }; mockMonitorRegistry = { setNotificationCallback: vi.fn(), @@ -362,6 +366,68 @@ describe('Session', () => { expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected); }); + + it('emits a current_mode_update extNotification after switching (A2)', async () => { + await session.setMode({ + sessionId: 'test-session-id', + modeId: 'auto-edit', + }); + + expect(mockClient.extNotification).toHaveBeenCalledWith( + 'qwen/notify/session/mode-update', + expect.objectContaining({ + v: 1, + sessionId: 'test-session-id', + currentModeId: 'auto-edit', + }), + ); + }); + + it('rejects an unknown modeId and does NOT touch approval mode (A2)', async () => { + await expect( + session.setMode({ + sessionId: 'test-session-id', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + modeId: 'totally-bogus' as any, + }), + ).rejects.toThrow(/Unknown approval mode/); + + expect(mockConfig.setApprovalMode).not.toHaveBeenCalled(); + expect(mockClient.extNotification).not.toHaveBeenCalledWith( + 'qwen/notify/session/mode-update', + expect.anything(), + ); + }); + }); + + describe('sendCurrentModeUpdateNotification', () => { + // The exit_plan_mode / edit-ProceedAlways path publishes the legacy + // `session_update{current_mode_update}` frame itself (via sendUpdate), + // so its extNotification must carry `legacyFrameSent: true` to stop the + // bridge demux from emitting a second, duplicate legacy frame. Unlike + // `setMode` (which omits the flag), a regression dropping it here would + // double-publish to the IDE companion. (A2) + it('marks the extNotification legacyFrameSent so the demux skips its dual-emit', async () => { + await ( + session as unknown as { + sendCurrentModeUpdateNotification: ( + outcome: core.ToolConfirmationOutcome, + ) => Promise; + } + ).sendCurrentModeUpdateNotification( + core.ToolConfirmationOutcome.ProceedAlways, + ); + + expect(mockClient.extNotification).toHaveBeenCalledWith( + 'qwen/notify/session/mode-update', + expect.objectContaining({ + v: 1, + sessionId: 'test-session-id', + currentModeId: 'auto-edit', + legacyFrameSent: true, + }), + ); + }); }); describe('rewindToTurn', () => { @@ -607,6 +673,36 @@ describe('Session', () => { ); }); + it('emits a current_model_update extNotification after switching (A1)', async () => { + await session.setModel({ + sessionId: 'test-session-id', + modelId: `qwen3-coder-plus(${AuthType.USE_OPENAI})`, + }); + + expect(mockClient.extNotification).toHaveBeenCalledWith( + 'qwen/notify/session/model-update', + expect.objectContaining({ + v: 1, + sessionId: 'test-session-id', + currentModelId: 'qwen3-coder-plus', + }), + ); + }); + + it('does NOT emit the model-update notification when the switch fails (A1)', async () => { + switchModelSpy.mockRejectedValueOnce(new Error('switch boom')); + await expect( + session.setModel({ + sessionId: 'test-session-id', + modelId: `qwen3-coder-plus(${AuthType.USE_OPENAI})`, + }), + ).rejects.toThrow(); + expect(mockClient.extNotification).not.toHaveBeenCalledWith( + 'qwen/notify/session/model-update', + expect.anything(), + ); + }); + it('rejects empty/whitespace model IDs', async () => { await expect( session.setModel({ @@ -1611,6 +1707,149 @@ describe('Session', () => { ); }); + it('degrades an oversized inline image to a text placeholder before sending to the model', async () => { + const ENV_KEY = 'QWEN_CODE_MAX_INLINE_MEDIA_BYTES'; + const original = process.env[ENV_KEY]; + process.env[ENV_KEY] = '8'; + try { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [ + { type: 'text', text: 'look at this' }, + { + type: 'image', + mimeType: 'image/png', + data: 'QUJDREVGR0hJSktMTU5PUFFSU1Q=', // ~20 decoded bytes, over the 8-byte cap + }, + ], + }); + + const sendMessageStream = mockChat.sendMessageStream as ReturnType< + typeof vi.fn + >; + const request = sendMessageStream.mock.calls[0]?.[1] as { + message: Array>; + }; + const parts = request.message; + expect(parts.some((p) => 'inlineData' in p)).toBe(false); + expect( + parts.some( + (p) => + typeof p['text'] === 'string' && + (p['text'] as string).includes('image/png') && + (p['text'] as string).toLowerCase().includes('omitted'), + ), + ).toBe(true); + } finally { + if (original === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = original; + } + }); + + describe('conversation_finished telemetry (#4602 review)', () => { + it('emits conversation_finished once when a turn completes normally', async () => { + const finishedSpy = vi + .spyOn(core, 'logConversationFinishedEvent') + .mockImplementation(() => {}); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await expect( + session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + + expect(finishedSpy).toHaveBeenCalledTimes(1); + }); + + it('still emits conversation_finished when the turn throws (telemetry not lost on the error path)', async () => { + const finishedSpy = vi + .spyOn(core, 'logConversationFinishedEvent') + .mockImplementation(() => {}); + mockChat.sendMessageStream = vi + .fn() + .mockRejectedValue(new Error('stream boom')); + + await session + .prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }) + .catch(() => undefined); + + expect(finishedSpy).toHaveBeenCalled(); + }); + }); + + describe('tool outcome telemetry (#4602 review)', () => { + it('records a soft tool failure (toolResult.error) as error, not success', async () => { + const logToolCallSpy = vi + .spyOn(core, 'logToolCall') + .mockImplementation(() => {}); + mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO); + + const tool = { + name: 'read_file', + kind: core.Kind.Read, + build: vi.fn().mockReturnValue({ + params: { path: '/tmp/test.txt' }, + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + execute: vi.fn().mockResolvedValue({ + llmContent: 'nope', + returnDisplay: 'failed', + error: { message: 'tool blew up' }, + }), + }), + }; + mockToolRegistry.getTool.mockReturnValue(tool); + + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'call-1', + name: 'read_file', + args: { path: '/tmp/test.txt' }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'read the file' }], + }); + + const toolEvent = logToolCallSpy.mock.calls + .map( + ([, ev]) => + ev as { + function_name?: string; + status?: string; + success?: boolean; + }, + ) + .find((ev) => ev.function_name === 'read_file'); + expect(toolEvent?.status).toBe('error'); + expect(toolEvent?.success).toBe(false); + }); + }); + describe('auto-compress', () => { it('runs automatic compression before sending an ACP prompt', async () => { mockChat.sendMessageStream = vi @@ -2051,6 +2290,7 @@ describe('Session', () => { }); mockClient.sessionUpdate = vi .fn() + .mockResolvedValueOnce(undefined) // emitUserMessage .mockRejectedValueOnce(new Error('client disconnected')); mockChat.sendMessageStream = vi .fn() @@ -2120,6 +2360,7 @@ describe('Session', () => { }); mockClient.sessionUpdate = vi .fn() + .mockResolvedValueOnce(undefined) // emitUserMessage .mockRejectedValueOnce(new Error('client disconnected')); mockChat.sendMessageStream = vi .fn() @@ -4885,4 +5126,195 @@ describe('Session', () => { expect(internals.disposed).toBe(true); }); }); + + describe('follow-up suggestion (daemon assist push)', () => { + let generateMock: ReturnType; + let logMock: ReturnType; + + beforeEach(() => { + generateMock = vi.mocked(core.generatePromptSuggestion); + logMock = vi.mocked(core.logPromptSuggestion); + generateMock.mockReset(); + logMock.mockReset(); + // Enable the feature by default in this describe block; individual + // tests override `mockSettings.merged.ui` to exercise the disabled + // path. + (mockSettings as unknown as { merged: { ui: unknown } }).merged.ui = { + enableFollowupSuggestions: true, + }; + vi.mocked(mockChat.getHistory).mockReturnValue([ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'hi back' }] }, + ]); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + }); + + it('fires prompt-suggestion extNotification after end_turn when enabled', async () => { + generateMock.mockResolvedValue({ suggestion: 'Run the tests next?' }); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await vi.waitFor(() => { + expect(mockClient.extNotification).toHaveBeenCalledWith( + 'qwen/notify/session/prompt-suggestion', + { + v: 1, + sessionId: 'test-session-id', + suggestion: 'Run the tests next?', + promptId: 'test-session-id########1', + }, + ); + }); + + // The generator received an AbortSignal so the daemon can cancel + // mid-flight if the next prompt arrives first. + expect(generateMock).toHaveBeenCalledWith( + mockConfig, + expect.any(Array), + expect.any(AbortSignal), + expect.objectContaining({ enableCacheSharing: expect.any(Boolean) }), + ); + }); + + it('does not emit when the feature is disabled', async () => { + (mockSettings as unknown as { merged: { ui: unknown } }).merged.ui = { + enableFollowupSuggestions: false, + }; + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + // Give the (skipped) IIFE a chance to run. + await new Promise((r) => setTimeout(r, 10)); + expect(generateMock).not.toHaveBeenCalled(); + expect( + ( + mockClient.extNotification as ReturnType + ).mock.calls.find( + ([method]) => method === 'qwen/notify/session/prompt-suggestion', + ), + ).toBeUndefined(); + }); + + it('does not emit in PLAN approval mode', async () => { + mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.PLAN); + generateMock.mockResolvedValue({ suggestion: 'something' }); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await new Promise((r) => setTimeout(r, 10)); + expect(generateMock).not.toHaveBeenCalled(); + }); + + it('logs filterReason via PromptSuggestionEvent when generation is suppressed', async () => { + generateMock.mockResolvedValue({ + suggestion: null, + filterReason: 'meta', + }); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await vi.waitFor(() => { + expect(logMock).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ outcome: 'suppressed', reason: 'meta' }), + ); + }); + // No extNotification when suggestion is filtered. + expect( + ( + mockClient.extNotification as ReturnType + ).mock.calls.find( + ([method]) => method === 'qwen/notify/session/prompt-suggestion', + ), + ).toBeUndefined(); + }); + + it('aborts the in-flight generator when a new prompt arrives', async () => { + let capturedSignal: AbortSignal | undefined; + generateMock + .mockImplementationOnce( + async ( + _config: unknown, + _history: unknown, + signal: AbortSignal, + ): Promise<{ suggestion: string | null }> => { + capturedSignal = signal; + return new Promise((resolve) => { + signal.addEventListener('abort', () => + resolve({ suggestion: null }), + ); + }); + }, + ) + .mockResolvedValue({ suggestion: null }); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'first' }], + }); + // Wait for the IIFE to actually call generateMock and capture the + // signal — without this, the second prompt can race past the + // first IIFE's microtask. + await vi.waitFor(() => expect(capturedSignal).toBeDefined()); + expect(capturedSignal!.aborted).toBe(false); + + // Send a second prompt. The followupAbort on the first turn + // should fire synchronously at the top of `prompt()`. + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'second' }], + }); + + expect(capturedSignal!.aborted).toBe(true); + }); + + it('aborts the in-flight generator when cancelPendingPrompt is called', async () => { + let capturedSignal: AbortSignal | undefined; + generateMock + .mockImplementationOnce( + async ( + _config: unknown, + _history: unknown, + signal: AbortSignal, + ): Promise<{ suggestion: string | null }> => { + capturedSignal = signal; + return new Promise((resolve) => { + signal.addEventListener('abort', () => + resolve({ suggestion: null }), + ); + }); + }, + ) + .mockResolvedValue({ suggestion: null }); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'go' }], + }); + await vi.waitFor(() => expect(capturedSignal).toBeDefined()); + + // followupAbort cleanup now runs unconditionally before the + // prompt/cron guard — inject a fake pendingPrompt so the call + // doesn't throw, but the real assertion is the signal abort. + (session as unknown as { pendingPrompt: AbortController }).pendingPrompt = + new AbortController(); + + await session.cancelPendingPrompt(); + expect(capturedSignal!.aborted).toBe(true); + }); + }); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 46f7f798f2..bb5c8562f3 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -25,6 +25,7 @@ import type { ChatCompressionInfo, AutoModeDecision, AutoModeOutcome, + GoalTerminalEvent, } from '@qwen-code/qwen-code-core'; import { AuthType, @@ -35,11 +36,15 @@ import { DiscoveredMCPTool, StreamEventType, ToolConfirmationOutcome, + generatePromptSuggestion, + logPromptSuggestion, logToolCall, logUserPrompt, + PromptSuggestionEvent, getErrorStatus, UserPromptEvent, readManyFiles, + clampInlineMediaPart, Storage, ToolNames, fireNotificationHook, @@ -65,19 +70,32 @@ import { formatStopHookBlockingCapWarning, applyAutoModeDecision, evaluateAutoMode, - formatDenialStateLog, getAutoModePermissionDeniedReason, isApproveOutcome, isDenialFallbackReason, MAX_TRANSCRIPT_MESSAGES, + formatDenialStateLog, recordAllow, recordFallbackApprove, shouldFallback, shouldForceAutoModeReviewForAllow, shouldFirePermissionDeniedForAutoMode, shouldRunAutoModeForCall, + extractDaemonTraceContext, + withInteractionSpan, + startToolSpan, + endToolSpan, + runInToolSpanContext, + startToolExecutionSpan, + endToolExecutionSpan, + logConversationFinishedEvent, + ConversationFinishedEvent, acquireSleepInhibitor, + clearGoalTerminalObserver, + setGoalTerminalObserver, + sessionIdContext, } from '@qwen-code/qwen-code-core'; +import { NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE } from '@qwen-code/acp-bridge/bridgeErrors'; import { getCommandSubcommandNames } from '../../services/commandMetadata.js'; import { getEffectiveSupportedModes } from '../../services/commandUtils.js'; @@ -134,6 +152,13 @@ import { const debugLogger = createDebugLogger('SESSION'); +function maskApiKeyForDisplay(apiKey: string | undefined): string { + const trimmed = apiKey?.trim() ?? ''; + if (trimmed.length === 0) return '(not set)'; + if (trimmed.length <= 6) return '***'; + return `${trimmed.slice(0, 3)}...${trimmed.slice(-4)}`; +} + type AutoCompressionSendResult = | { responseStream: AsyncGenerator; stopReason?: never } | { responseStream: null; stopReason: PromptResponse['stopReason'] }; @@ -379,7 +404,15 @@ export class Session implements SessionContext { * process termination is slow. */ private pendingPromptCompletion: Promise | null = null; + /** + * Per-turn AbortController for the fire-and-forget follow-up suggestion + * generation. Aborted on the top of the next `prompt()` and on + * `cancelPendingPrompt()` so a stale suggestion never lands after the + * user has moved on. Null when no suggestion generation is in flight. + */ + private followupAbort: AbortController | null = null; private turn: number = 0; + private readonly createdAt: number = Date.now(); private readonly runtimeBaseDir: string; // Cron scheduling state @@ -449,6 +482,7 @@ export class Session implements SessionContext { this.historyReplayer = new HistoryReplayer(this); this.messageEmitter = new MessageEmitter(this); + this.#installGoalTerminalObserver(); this.#registerBackgroundNotificationCallbacks(); } @@ -460,6 +494,25 @@ export class Session implements SessionContext { return this.config; } + isIdle(): boolean { + return ( + !this.pendingPrompt && + !this.pendingPromptCompletion && + !this.cronProcessing && + !this.cronAbortController && + !this.notificationProcessing && + !this.notificationAbortController + ); + } + + getTurnCount(): number { + return this.turn; + } + + getCreatedAt(): number { + return this.createdAt; + } + dispose(): void { this.disposed = true; this.notificationQueue = []; @@ -479,6 +532,7 @@ export class Session implements SessionContext { this.config.getBackgroundTaskRegistry().setNotificationCallback(undefined); this.config.getMonitorRegistry().setNotificationCallback(undefined); this.config.getBackgroundShellRegistry().setNotificationCallback(undefined); + clearGoalTerminalObserver(this.sessionId); } /** @@ -497,6 +551,16 @@ export class Session implements SessionContext { } } + #installGoalTerminalObserver(): void { + setGoalTerminalObserver(this.sessionId, (event: GoalTerminalEvent) => { + void this.messageEmitter.emitGoalTerminal(event).catch((error) => { + debugLogger.warn( + `Failed to emit goal terminal update: ${this.#formatError(error)}`, + ); + }); + }); + } + /** * Replays conversation history to the client using modular components. * Delegates to HistoryReplayer for consistent event emission. @@ -633,8 +697,12 @@ export class Session implements SessionContext { const hadNotification = !!this.notificationAbortController || this.notificationProcessing; + if (this.followupAbort) { + this.followupAbort.abort(); + this.followupAbort = null; + } if (!hadPrompt && !hadCron && !hadNotification) { - throw new Error('Not currently generating'); + throw new Error(NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE); } if (this.pendingPrompt) { @@ -677,6 +745,14 @@ export class Session implements SessionContext { const pendingSend = new AbortController(); this.pendingPrompt = pendingSend; + // Abort the previous turn's in-flight follow-up suggestion + // generation (if any). Mirrors `pendingPrompt?.abort()` above — + // a fresh prompt arriving means any pending suggestion would be + // stale before it could ever render. + if (this.followupAbort) { + this.followupAbort.abort(); + this.followupAbort = null; + } // Abort any in-progress cron execution (user prompt takes priority) if (this.cronAbortController) { this.cronAbortController.abort(); @@ -734,18 +810,126 @@ export class Session implements SessionContext { const result = await this.#executePrompt(params, pendingSend); this.pendingPrompt = null; this.#startCronSchedulerIfNeeded(); - // Drain any cron prompts that queued while the prompt was active void this.#drainCronQueue(); void this.#drainNotificationQueue(); + this.#maybeEmitFollowupSuggestion(result); return result; } finally { + this.pendingPrompt = null; resolveCompletion(); + this.pendingPromptCompletion = null; } } + /** + * Generate a server-side follow-up suggestion for the just-completed + * turn and push it to attached clients via the daemon's + * `qwen/notify/session/prompt-suggestion` extNotification. Mirrors + * the CLI's `AppContainer.tsx` integration: same `generatePromptSuggestion` + * call, same `enableCacheSharing` flag forwarding, same curated + * history slice (`getHistory(true).slice(-40)`). + * + * Differences from the CLI: + * - Triggers only on `stopReason === 'end_turn'` (the daemon + * equivalent of "the assistant finished cleanly"). Cancelled / + * errored turns don't get a suggestion. + * - Aborted via `this.followupAbort`, which is reset on the next + * `prompt()` and on `cancelPendingPrompt()`. + * - Filter-reason logging only — accept / dismiss telemetry stays + * client-side (the CLI hook owns it). + * + * Fire-and-forget by design: an unawaited IIFE that swallows its own + * errors. A failed suggestion is invisible to the user; a thrown + * error here would propagate up through `prompt()` and break the + * primary response path. + */ + #maybeEmitFollowupSuggestion(result: PromptResponse): void { + if (result.stopReason !== 'end_turn') return; + if (this.settings.merged.ui?.enableFollowupSuggestions !== true) return; + if (this.config.getApprovalMode() === ApprovalMode.PLAN) return; + + const chat = this.config.getGeminiClient()?.getChat(); + if (!chat) return; + + const ac = new AbortController(); + this.followupAbort = ac; + const promptId = + this.config.getSessionId() + '########' + String(this.turn); + + void (async () => { + try { + const fullHistory = chat.getHistory(true); + const lastEntry = fullHistory[fullHistory.length - 1]; + if (!lastEntry || lastEntry.role !== 'model') { + debugLogger.debug( + 'Skipping followup suggestion: last history entry is not model', + ); + return; + } + const conversationHistory = + fullHistory.length > 40 ? fullHistory.slice(-40) : fullHistory; + + const r = await generatePromptSuggestion( + this.config, + conversationHistory, + ac.signal, + { + enableCacheSharing: + this.settings.merged.ui?.enableCacheSharing === true, + }, + ); + if (ac.signal.aborted) return; + if (r.suggestion) { + await this.client.extNotification( + 'qwen/notify/session/prompt-suggestion', + { + v: 1, + sessionId: this.sessionId, + suggestion: r.suggestion, + promptId, + }, + ); + } else if (r.filterReason) { + // Mirror the CLI's suppression analytics path so server-side + // generations are observable in the same telemetry stream. + logPromptSuggestion( + this.config, + new PromptSuggestionEvent({ + outcome: 'suppressed', + reason: r.filterReason, + }), + ); + } + } catch (error) { + if (ac.signal.aborted) { + debugLogger.debug('Follow-up suggestion generation aborted'); + } else { + debugLogger.warn('Follow-up suggestion generation failed', error); + } + } finally { + if (this.followupAbort === ac) { + this.followupAbort = null; + } + } + })(); + } + async #executePrompt( params: PromptRequest, pendingSend: AbortController, + ): Promise { + // Bind this turn to the session's ID via AsyncLocalStorage so shell + // subprocesses (and hooks) read the CURRENT session's ID instead of + // the process-global env slot, which in daemon mode only ever holds + // the first session created in this process. + return sessionIdContext.run(this.config.getSessionId(), () => + this.#executePromptInner(params, pendingSend), + ); + } + + async #executePromptInner( + params: PromptRequest, + pendingSend: AbortController, ): Promise { return Storage.runWithRuntimeBaseDir( this.runtimeBaseDir, @@ -755,276 +939,317 @@ export class Session implements SessionContext { this.turn += 1; const promptId = this.config.getSessionId() + '########' + this.turn; + const parentContext = extractDaemonTraceContext(params); - // Extract text from all text blocks to construct the full prompt text for logging - const promptText = params.prompt - .filter((block) => block.type === 'text') - .map((block) => (block.type === 'text' ? block.text : '')) - .join(' '); - - // Log user prompt - logUserPrompt( + return await withInteractionSpan( this.config, - new UserPromptEvent( - promptText.length, + { promptId, - this.config.getContentGeneratorConfig()?.authType, - promptText, - ), - ); + model: this.config.getModel(), + messageType: 'acp_prompt', + ...(parentContext ? { parentContext } : {}), + }, + async () => { + // Extract text from all text blocks to construct the full prompt text for logging + const promptText = params.prompt + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join(' '); - // record user message for session management - this.config.getChatRecordingService()?.recordUserMessage(promptText); - - // Check if the input contains a slash command - // Extract text from the first text block if present - const firstTextBlock = params.prompt.find( - (block) => block.type === 'text', - ); - const inputText = firstTextBlock?.text || ''; - - let parts: Part[] | null; - - if (isSlashCommand(inputText)) { - // Handle slash command in ACP mode using capability-based filtering - const slashCommandResult = await handleSlashCommand( - inputText, - pendingSend, - this.config, - this.settings, - ); - - parts = await this.#processSlashCommandResult( - slashCommandResult, - params.prompt, - ); - - // If parts is null, the command was fully handled (e.g., /summary completed) - // Return early without sending to the model - if (parts === null) { - return { stopReason: 'end_turn' }; - } - } else { - // Normal processing for non-slash commands - parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); - } - - // Fire UserPromptSubmit hook through MessageBus (aligned with core path in client.ts) - const hooksEnabled = !this.config.getDisableAllHooks?.(); - const messageBus = this.config.getMessageBus?.(); - if ( - hooksEnabled && - messageBus && - this.config.hasHooksForEvent?.('UserPromptSubmit') - ) { - const response = await messageBus.request< - HookExecutionRequest, - HookExecutionResponse - >( - { - type: MessageBusType.HOOK_EXECUTION_REQUEST, - eventName: 'UserPromptSubmit', - input: { - prompt: promptText, - }, - signal: pendingSend.signal, - }, - MessageBusType.HOOK_EXECUTION_RESPONSE, - ); - const hookOutput = response.output - ? createHookOutput('UserPromptSubmit', response.output) - : undefined; - - if ( - hookOutput?.isBlockingDecision() || - hookOutput?.shouldStopExecution() - ) { - // Hook blocked the prompt - send notification to UI and return - const blockReason = - hookOutput?.getEffectiveReason() || 'No reason provided'; - await this.messageEmitter.emitAgentMessage( - `🚫 **UserPromptSubmit blocked**: ${blockReason}`, + // Log user prompt + logUserPrompt( + this.config, + new UserPromptEvent( + promptText.length, + promptId, + this.config.getContentGeneratorConfig()?.authType, + promptText, + ), ); - return { stopReason: 'end_turn' }; - } - // Add additional context from hooks to the request - const additionalContext = hookOutput?.getAdditionalContext(); - if (additionalContext) { - parts = [...parts, { text: additionalContext }]; - } - } + // record user message for session management + this.config + .getChatRecordingService() + ?.recordUserMessage(promptText); - // Prepend session-level system reminders (plan mode / subagent / - // arena) so the model sees them, matching the behaviour of - // `GeminiClient.sendMessageStream` in the CLI/TUI path. Without this, - // plan mode in ACP has no effect because the model never learns it - // should avoid edits (#1151). - const systemReminders = await this.#buildInitialSystemReminders(); - if (systemReminders.length > 0) { - parts = [...systemReminders, ...parts]; - } - - // Phase C: one-shot worktree restore notice, set by acpAgent on - // --resume / loadSession when the session's worktree is still alive. - // Prepended exactly once, then cleared so it doesn't repeat on - // subsequent turns. - if (this.pendingWorktreeNotice) { - parts = [ - { - text: `\n${this.pendingWorktreeNotice}\n\n\n`, - }, - ...parts, - ]; - this.pendingWorktreeNotice = null; - } - - let nextMessage: Content | null = { role: 'user', parts }; - - while (nextMessage !== null) { - if (pendingSend.signal.aborted) { - this.#getCurrentChat().addHistory(nextMessage); - return { stopReason: 'cancelled' }; - } - - const functionCalls: FunctionCall[] = []; - let usageMetadata: GenerateContentResponseUsageMetadata | null = null; - const streamStartTime = Date.now(); - - try { - const sendResult = await this.#sendMessageStreamWithAutoCompression( - promptId, - nextMessage?.parts ?? [], - pendingSend.signal, + // Check if the input contains a slash command + // Extract text from the first text block if present + const firstTextBlock = params.prompt.find( + (block) => block.type === 'text', ); - if (!sendResult.responseStream) { - this.#preserveUnsentMessageHistory( - nextMessage, - sendResult.stopReason === 'cancelled', + const inputText = firstTextBlock?.text || ''; + + let parts: Part[] | null; + + if (isSlashCommand(inputText)) { + // Handle slash command in ACP mode using capability-based filtering + const slashCommandResult = await handleSlashCommand( + inputText, + pendingSend, + this.config, + this.settings, ); - return { stopReason: sendResult.stopReason }; - } - const responseStream = sendResult.responseStream; - nextMessage = null; - for await (const resp of responseStream) { - if (pendingSend.signal.aborted) { - return { stopReason: 'cancelled' }; + parts = await this.#processSlashCommandResult( + slashCommandResult, + params.prompt, + ); + + // If parts is null, the command was fully handled (e.g., /summary completed) + // Return early without sending to the model + if (parts === null) { + return { stopReason: 'end_turn' }; } + } else { + // Normal processing for non-slash commands + parts = await this.#resolvePrompt( + params.prompt, + pendingSend.signal, + ); + } + + // Fire UserPromptSubmit hook through MessageBus (aligned with core path in client.ts) + const hooksEnabled = !this.config.getDisableAllHooks?.(); + const messageBus = this.config.getMessageBus?.(); + if ( + hooksEnabled && + messageBus && + this.config.hasHooksForEvent?.('UserPromptSubmit') + ) { + const response = await messageBus.request< + HookExecutionRequest, + HookExecutionResponse + >( + { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'UserPromptSubmit', + input: { + prompt: promptText, + }, + signal: pendingSend.signal, + }, + MessageBusType.HOOK_EXECUTION_RESPONSE, + ); + const hookOutput = response.output + ? createHookOutput('UserPromptSubmit', response.output) + : undefined; if ( - resp.type === StreamEventType.CHUNK && - resp.value.candidates && - resp.value.candidates.length > 0 + hookOutput?.isBlockingDecision() || + hookOutput?.shouldStopExecution() ) { - const candidate = resp.value.candidates[0]; - for (const part of candidate.content?.parts ?? []) { - if (!part.text) { - continue; + // Hook blocked the prompt - send notification to UI and return + const blockReason = + hookOutput?.getEffectiveReason() || 'No reason provided'; + await this.messageEmitter.emitAgentMessage( + `🚫 **UserPromptSubmit blocked**: ${blockReason}`, + ); + return { stopReason: 'end_turn' }; + } + + // Add additional context from hooks to the request + const additionalContext = hookOutput?.getAdditionalContext(); + if (additionalContext) { + parts = [...parts, { text: additionalContext }]; + } + } + + // Prepend session-level system reminders (plan mode / subagent / + // arena) so the model sees them, matching the behaviour of + // `GeminiClient.sendMessageStream` in the CLI/TUI path. Without this, + // plan mode in ACP has no effect because the model never learns it + // should avoid edits. + const systemReminders = await this.#buildInitialSystemReminders(); + if (systemReminders.length > 0) { + parts = [...systemReminders, ...parts]; + } + + // Phase C: one-shot worktree restore notice, set by acpAgent on + // --resume / loadSession when the session's worktree is still alive. + // Prepended exactly once, then cleared so it doesn't repeat on + // subsequent turns. + if (this.pendingWorktreeNotice) { + parts = [ + { + text: `\n${this.pendingWorktreeNotice}\n\n\n`, + }, + ...parts, + ]; + this.pendingWorktreeNotice = null; + } + + let nextMessage: Content | null = { role: 'user', parts }; + let turnCount = 0; + + // conversation_finished must fire on every terminal path of the + // turn — the loop below has cancel/abort/no-stream early-returns + // and API-error throws — so the emission lives in a finally that + // wraps the whole turn, not just the stop-hook loop. Daemon turns + // run autonomously in all approval modes (approvals are mediated by + // the ACP client rather than by gating this loop), so unlike the + // CLI reference (useGeminiStream.ts, which only emits in YOLO) this + // is intentionally emitted for every mode. + try { + while (nextMessage !== null) { + turnCount++; + if (pendingSend.signal.aborted) { + this.#getCurrentChat().addHistory(nextMessage); + return { stopReason: 'cancelled' }; + } + + const functionCalls: FunctionCall[] = []; + let usageMetadata: GenerateContentResponseUsageMetadata | null = + null; + const streamStartTime = Date.now(); + + try { + const sendResult = + await this.#sendMessageStreamWithAutoCompression( + promptId, + nextMessage?.parts ?? [], + pendingSend.signal, + ); + if (!sendResult.responseStream) { + this.#preserveUnsentMessageHistory( + nextMessage, + sendResult.stopReason === 'cancelled', + ); + return { stopReason: sendResult.stopReason }; + } + const responseStream = sendResult.responseStream; + nextMessage = null; + + for await (const resp of responseStream) { + if (pendingSend.signal.aborted) { + return { stopReason: 'cancelled' }; + } + + if ( + resp.type === StreamEventType.CHUNK && + resp.value.candidates && + resp.value.candidates.length > 0 + ) { + const candidate = resp.value.candidates[0]; + for (const part of candidate.content?.parts ?? []) { + if (!part.text) { + continue; + } + + this.messageEmitter.emitMessage( + part.text, + 'assistant', + part.thought, + ); + } + } + + if ( + resp.type === StreamEventType.CHUNK && + resp.value.usageMetadata + ) { + usageMetadata = resp.value.usageMetadata; + } + + if ( + resp.type === StreamEventType.CHUNK && + resp.value.functionCalls + ) { + functionCalls.push(...resp.value.functionCalls); + } + } + } catch (error) { + // Fire StopFailure hook (fire-and-forget, replaces Stop event for API errors) + // Aligned with useGeminiStream.ts handleFinishedWithErrorEvent + const errorStatus = getErrorStatus(error); + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorType = classifyApiError({ + message: errorMessage, + status: errorStatus, + }); + + const hookSystem = this.config.getHookSystem?.(); + const hooksEnabledForStopFailure = + !this.config.getDisableAllHooks?.(); + if ( + hooksEnabledForStopFailure && + hookSystem && + this.config.hasHooksForEvent?.('StopFailure') + ) { + // Fire-and-forget: don't wait for hook to complete + hookSystem + .fireStopFailureEvent(errorType, errorMessage) + .catch((err) => { + debugLogger.warn(`StopFailure hook failed: ${err}`); + }); } - this.messageEmitter.emitMessage( - part.text, - 'assistant', - part.thought, + if (errorStatus === 429) { + throw new RequestError( + 429, + 'Rate limit exceeded. Try again later.', + ); + } + + throw error; + } + + if (usageMetadata) { + this.#recordPromptTokenCount(usageMetadata); + // Kick off rewrite in background (non-blocking, runs parallel to tools) + if (this.messageRewriter) { + this.messageRewriter.flushTurn(pendingSend.signal); + } + + const durationMs = Date.now() - streamStartTime; + await this.messageEmitter.emitUsageMetadata( + usageMetadata, + '', + durationMs, ); } + + if (functionCalls.length > 0) { + const toolResponseParts = await this.runToolCalls( + pendingSend.signal, + promptId, + functionCalls, + ); + nextMessage = { + role: 'user', + parts: [ + ...toolResponseParts, + ...(await this.#drainMidTurnUserMessages()), + ], + }; + } } - if ( - resp.type === StreamEventType.CHUNK && - resp.value.usageMetadata - ) { - usageMetadata = resp.value.usageMetadata; + // Wait for any pending rewrite before returning + if (this.messageRewriter) { + await this.messageRewriter.waitForPendingRewrites(); } - if ( - resp.type === StreamEventType.CHUNK && - resp.value.functionCalls - ) { - functionCalls.push(...resp.value.functionCalls); - } - } - } catch (error) { - // Fire StopFailure hook (fire-and-forget, replaces Stop event for API errors) - // Aligned with useGeminiStream.ts handleFinishedWithErrorEvent - const errorStatus = getErrorStatus(error); - const errorMessage = - error instanceof Error ? error.message : String(error); - const errorType = classifyApiError({ - message: errorMessage, - status: errorStatus, - }); - - const hookSystem = this.config.getHookSystem?.(); - const hooksEnabledForStopFailure = - !this.config.getDisableAllHooks?.(); - if ( - hooksEnabledForStopFailure && - hookSystem && - this.config.hasHooksForEvent?.('StopFailure') - ) { - // Fire-and-forget: don't wait for hook to complete - hookSystem - .fireStopFailureEvent(errorType, errorMessage) - .catch((err) => { - debugLogger.warn(`StopFailure hook failed: ${err}`); - }); - } - - if (errorStatus === 429) { - throw new RequestError( - 429, - 'Rate limit exceeded. Try again later.', + // Fire Stop hook loop (aligned with core path in client.ts) + // This is triggered after model response completes with no pending tool calls + return await this.#handleStopHookLoop( + pendingSend, + promptId, + hooksEnabled, + messageBus, + ); + } finally { + logConversationFinishedEvent( + this.config, + new ConversationFinishedEvent( + this.config.getApprovalMode(), + turnCount, + ), ); } - - throw error; - } - - if (usageMetadata) { - this.#recordPromptTokenCount(usageMetadata); - // Kick off rewrite in background (non-blocking, runs parallel to tools) - if (this.messageRewriter) { - this.messageRewriter.flushTurn(pendingSend.signal); - } - - const durationMs = Date.now() - streamStartTime; - await this.messageEmitter.emitUsageMetadata( - usageMetadata, - '', - durationMs, - ); - } - - if (functionCalls.length > 0) { - const toolResponseParts = await this.runToolCalls( - pendingSend.signal, - promptId, - functionCalls, - ); - nextMessage = { - role: 'user', - parts: [ - ...toolResponseParts, - ...(await this.#drainMidTurnUserMessages()), - ], - }; - } - } - - // Wait for any pending rewrite before returning - if (this.messageRewriter) { - await this.messageRewriter.waitForPendingRewrites(); - } - - // Fire Stop hook loop (aligned with core path in client.ts) - // This is triggered after model response completes with no pending tool calls - return this.#handleStopHookLoop( - pendingSend, - promptId, - hooksEnabled, - messageBus, + }, + (result: { stopReason: PromptResponse['stopReason'] }) => + result.stopReason === 'cancelled' ? 'cancelled' : 'ok', ); }, ); @@ -1681,6 +1906,13 @@ export class Session implements SessionContext { * `_meta.source='cron'`, streams the model response, and handles tool calls. */ async #executeCronPrompt(prompt: string): Promise { + // Same session-ID binding rationale as #executePrompt. + return sessionIdContext.run(this.config.getSessionId(), () => + this.#executeCronPromptInner(prompt), + ); + } + + async #executeCronPromptInner(prompt: string): Promise { return Storage.runWithRuntimeBaseDir( this.runtimeBaseDir, this.config.getWorkingDir(), @@ -1690,121 +1922,148 @@ export class Session implements SessionContext { const promptId = this.config.getSessionId() + '########cron' + Date.now(); - try { - // Echo the cron prompt as a user message so the client sees it - await this.sendUpdate({ - sessionUpdate: 'user_message_chunk', - content: { type: 'text', text: prompt }, - _meta: { source: 'cron' }, - }); + let cronHadError = false; + await withInteractionSpan( + this.config, + { + promptId, + model: this.config.getModel(), + messageType: 'cron', + }, + async () => { + let turnCount = 0; + try { + // Echo the cron prompt as a user message so the client sees it + await this.sendUpdate({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: prompt }, + _meta: { source: 'cron' }, + }); - // Prepend session-level system reminders (same rationale as the - // user-query path in #executePrompt). - const cronReminders = await this.#buildInitialSystemReminders(); - let nextMessage: Content | null = { - role: 'user', - parts: [...cronReminders, { text: prompt }], - }; + // Prepend session-level system reminders (same rationale as the + // user-query path in #executePrompt). + const cronReminders = await this.#buildInitialSystemReminders(); + let nextMessage: Content | null = { + role: 'user', + parts: [...cronReminders, { text: prompt }], + }; - while (nextMessage !== null) { - if (ac.signal.aborted) return; + while (nextMessage !== null) { + turnCount++; + if (ac.signal.aborted) return; - const functionCalls: FunctionCall[] = []; - let usageMetadata: GenerateContentResponseUsageMetadata | null = - null; - const streamStartTime = Date.now(); + const functionCalls: FunctionCall[] = []; + let usageMetadata: GenerateContentResponseUsageMetadata | null = + null; + const streamStartTime = Date.now(); - const sendResult = await this.#sendMessageStreamWithAutoCompression( - promptId, - nextMessage.parts ?? [], - ac.signal, - ); - if (!sendResult.responseStream) { - this.#preserveUnsentMessageHistory( - nextMessage, - sendResult.stopReason === 'cancelled', - ); - if (sendResult.stopReason === 'max_tokens') { - this.#stopCronAfterTokenLimit(); - } - return; - } - const responseStream = sendResult.responseStream; - nextMessage = null; + const sendResult = + await this.#sendMessageStreamWithAutoCompression( + promptId, + nextMessage.parts ?? [], + ac.signal, + ); + if (!sendResult.responseStream) { + this.#preserveUnsentMessageHistory( + nextMessage, + sendResult.stopReason === 'cancelled', + ); + if (sendResult.stopReason === 'max_tokens') { + this.#stopCronAfterTokenLimit(); + } + return; + } + const responseStream = sendResult.responseStream; + nextMessage = null; - for await (const resp of responseStream) { - if (ac.signal.aborted) return; + for await (const resp of responseStream) { + if (ac.signal.aborted) return; - if ( - resp.type === StreamEventType.CHUNK && - resp.value.candidates && - resp.value.candidates.length > 0 - ) { - const candidate = resp.value.candidates[0]; - for (const part of candidate.content?.parts ?? []) { - if (!part.text) continue; - this.messageEmitter.emitMessage( - part.text, - 'assistant', - part.thought, + if ( + resp.type === StreamEventType.CHUNK && + resp.value.candidates && + resp.value.candidates.length > 0 + ) { + const candidate = resp.value.candidates[0]; + for (const part of candidate.content?.parts ?? []) { + if (!part.text) continue; + this.messageEmitter.emitMessage( + part.text, + 'assistant', + part.thought, + ); + } + } + + if ( + resp.type === StreamEventType.CHUNK && + resp.value.usageMetadata + ) { + usageMetadata = resp.value.usageMetadata; + } + + if ( + resp.type === StreamEventType.CHUNK && + resp.value.functionCalls + ) { + functionCalls.push(...resp.value.functionCalls); + } + } + + if (usageMetadata) { + this.#recordPromptTokenCount(usageMetadata); + if (this.messageRewriter) { + this.messageRewriter.flushTurn(ac.signal); + } + const durationMs = Date.now() - streamStartTime; + await this.messageEmitter.emitUsageMetadata( + usageMetadata, + '', + durationMs, ); } - } - if ( - resp.type === StreamEventType.CHUNK && - resp.value.usageMetadata - ) { - usageMetadata = resp.value.usageMetadata; + if (functionCalls.length > 0) { + const toolResponseParts = await this.runToolCalls( + ac.signal, + promptId, + functionCalls, + ); + nextMessage = { + role: 'user', + parts: [ + ...toolResponseParts, + ...(await this.#drainMidTurnUserMessages()), + ], + }; + } } - - if ( - resp.type === StreamEventType.CHUNK && - resp.value.functionCalls - ) { - functionCalls.push(...resp.value.functionCalls); + } catch (error) { + if (ac.signal.aborted) return; + cronHadError = true; + debugLogger.error('Error processing cron prompt:', error); + const msg = + error instanceof Error ? error.message : String(error); + await this.messageEmitter.emitAgentMessage(`[cron error] ${msg}`); + } finally { + if (this.cronAbortController === ac) { + this.cronAbortController = null; } - } - - if (usageMetadata) { - this.#recordPromptTokenCount(usageMetadata); - // Kick off rewrite in background (non-blocking) - if (this.messageRewriter) { - this.messageRewriter.flushTurn(ac.signal); - } - const durationMs = Date.now() - streamStartTime; - await this.messageEmitter.emitUsageMetadata( - usageMetadata, - '', - durationMs, + // Mirror the user-query path: emit conversation_finished on every + // terminal cron path (clean finish, abort, or caught error) so + // cron turns are not silently missing from conversation metrics. + logConversationFinishedEvent( + this.config, + new ConversationFinishedEvent( + this.config.getApprovalMode(), + turnCount, + ), ); } - - if (functionCalls.length > 0) { - const toolResponseParts = await this.runToolCalls( - ac.signal, - promptId, - functionCalls, - ); - nextMessage = { - role: 'user', - parts: [ - ...toolResponseParts, - ...(await this.#drainMidTurnUserMessages()), - ], - }; - } - } - } catch (error) { - if (ac.signal.aborted) return; - debugLogger.error('Error processing cron prompt:', error); - const msg = error instanceof Error ? error.message : String(error); - await this.messageEmitter.emitAgentMessage(`[cron error] ${msg}`); - } finally { - if (this.cronAbortController === ac) { - this.cronAbortController = null; - } - } + }, + () => + ac.signal.aborted ? 'cancelled' : cronHadError ? 'error' : 'ok', + ); }, ); } @@ -1920,6 +2179,15 @@ export class Session implements SessionContext { async #executeBackgroundNotificationPrompt( item: BackgroundNotificationQueueItem, + ): Promise { + // Same session-ID binding rationale as #executePrompt. + return sessionIdContext.run(this.config.getSessionId(), () => + this.#executeBackgroundNotificationPromptInner(item), + ); + } + + async #executeBackgroundNotificationPromptInner( + item: BackgroundNotificationQueueItem, ): Promise { return Storage.runWithRuntimeBaseDir( this.runtimeBaseDir, @@ -2045,7 +2313,13 @@ export class Session implements SessionContext { promptId, functionCalls, ); - nextMessage = { role: 'user', parts: toolResponseParts }; + nextMessage = { + role: 'user', + parts: [ + ...toolResponseParts, + ...(await this.#drainMidTurnUserMessages()), + ], + }; } } @@ -2195,8 +2469,34 @@ export class Session implements SessionContext { yolo: ApprovalMode.YOLO, }; + // `modeId` arrives over the wire (ACP `session/set_mode`, or + // `setSessionConfigOption` casting an unknown `value` to string), so + // validate at this boundary. An unknown id would otherwise call + // `setApprovalMode(undefined)` — leaving the permission system in an + // undefined state — and the A2 broadcast below would fan the bogus id + // out to every attached SSE client. const approvalMode = modeMap[params.modeId as ApprovalModeValue]; + if (approvalMode === undefined) { + throw RequestError.invalidParams( + undefined, + `Unknown approval mode: ${params.modeId}`, + ); + } this.config.setApprovalMode(approvalMode); + + // A2 (#4511): notify attached clients of an in-session mode switch. + // Mirrors the model-update extNotification in `setModel`. + void this.client + .extNotification('qwen/notify/session/mode-update', { + v: 1, + sessionId: this.sessionId, + currentModeId: params.modeId, + }) + .catch((error) => { + // Advisory only; a failed notification must not fail the mode + // switch. Matches the model-update extNotification in `setModel`. + debugLogger.debug('mode-update extNotification failed', error); + }); } /** @@ -2233,6 +2533,31 @@ export class Session implements SessionContext { : undefined, ); + const after = this.config.getContentGeneratorConfig?.(); + const effectiveAuthType = after?.authType ?? selectedAuthType; + const effectiveModelId = after?.model ?? parsed.modelId; + + // Notify attached clients of an in-session model switch so a + // `/model` slash command or plan-mode change reaches the bus (today only + // the HTTP `POST /session/:id/model` path publishes `model_switched`). + // `current_model_update` is NOT an ACP `SessionUpdate` variant (the type + // is the external @agentclientprotocol/sdk union, which has + // `current_mode_update` but not a model equivalent), so this goes over + // the agent→bridge `extNotification` side-channel. The bridge demuxes it + // to `model_switched` and SUPPRESSES it when the bridge itself is driving + // the change (the HTTP path also flows through this method), avoiding a + // double publish. Fire-and-forget, matching the MCP-budget extNotification. + void this.client + .extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: this.sessionId, + currentModelId: effectiveModelId, + }) + .catch((error) => { + // Advisory only; a failed notification must not fail the model switch. + debugLogger.debug('model-update extNotification failed', error); + }); + if (options.persistDefault ?? true) { const persistScope = getPersistScopeForModelSelection(this.settings); this.settings.setValue(persistScope, 'model.name', parsed.modelId); @@ -2242,6 +2567,18 @@ export class Session implements SessionContext { selectedAuthType, ); } + + return { + _meta: { + qwenModelSwitch: { + authType: effectiveAuthType, + modelId: effectiveModelId, + baseUrl: after?.baseUrl ?? '(default)', + apiKey: maskApiKeyForDisplay(after?.apiKey), + isRuntime: rawModelId.startsWith('$runtime|'), + }, + }, + }; } /** @@ -2274,6 +2611,29 @@ export class Session implements SessionContext { }; await this.sendUpdate(update); + + // A2 (#4511): promote the mode change to the bridge side-channel so + // it reaches `approval_mode_changed` on the SSE bus, matching the + // extNotification in `setMode`. + // + // Unlike `setMode`, this path already published the legacy + // `session_update{current_mode_update}` frame via `sendUpdate` above + // (BridgeClient.sessionUpdate fans it onto the bus). Tell the demux to + // skip its compat dual-emit so the IDE companion sees exactly one + // legacy frame for this change, not two. `setMode` omits the flag, so + // its dual-emit still fires (it has no `sendUpdate`). + void this.client + .extNotification('qwen/notify/session/mode-update', { + v: 1, + sessionId: this.sessionId, + currentModeId: newModeId, + legacyFrameSent: true, + }) + .catch((error) => { + // Advisory only; a failed notification must not fail the mode + // change. Matches the model-update extNotification in `setModel`. + debugLogger.debug('mode-update extNotification failed', error); + }); } /** @@ -2354,7 +2714,7 @@ export class Session implements SessionContext { * start of a user query or cron fire. Mirrors the subagent/plan/arena * branches in `GeminiClient.sendMessageStream` (`client.ts:848-878`) — * the ACP path bypasses that code, so without this helper plan mode is - * silently inert (#1151) and subagent/arena sessions lose context. + * silently inert and subagent/arena sessions lose context. * * Scope note: the `relevantAutoMemory` reminder is intentionally NOT * included here. Managed auto-memory requires a prefetch pipeline that @@ -2393,6 +2753,7 @@ export class Session implements SessionContext { let args = (fc.args ?? {}) as Record; const startTime = Date.now(); + let spanError: string | undefined; const errorResponse = (error: Error) => { const durationMs = Date.now() - startTime; @@ -2403,7 +2764,8 @@ export class Session implements SessionContext { function_name: fc.name ?? '', function_args: args, duration_ms: durationMs, - status: 'error', + // An aborted signal means the call was cancelled, not a genuine error. + status: abortSignal.aborted ? 'cancelled' : 'error', success: false, error: error.message, tool_type: @@ -2427,6 +2789,7 @@ export class Session implements SessionContext { error: Error, toolName = fc.name ?? 'unknown_tool', ) => { + spanError = error.message; if (toolName !== ToolNames.TODO_WRITE) { await this.toolCallEmitter.emitError(callId, toolName, error); } @@ -2446,657 +2809,741 @@ export class Session implements SessionContext { return earlyErrorResponse(new Error('Missing function name')); } + const toolName = fc.name; const toolRegistry = this.config.getToolRegistry(); - const tool = toolRegistry.getTool(fc.name as string); + const tool = toolRegistry.getTool(toolName); if (!tool) { return earlyErrorResponse( - new Error(`Tool "${fc.name}" not found in registry.`), + new Error(`Tool "${toolName}" not found in registry.`), ); } - // ---- L1: Tool enablement check ---- - const pm = this.config.getPermissionManager?.(); - if (pm && !(await pm.isToolEnabled(fc.name as string))) { - return earlyErrorResponse( - new Error( - `Qwen Code requires permission to use "${fc.name}", but that permission was declined.`, - ), - fc.name, - ); - } - - // Detect TodoWriteTool early - route to plan updates instead of tool_call events - const isTodoWriteTool = tool.name === ToolNames.TODO_WRITE; - const isAgentTool = tool.name === ToolNames.AGENT; - const isExitPlanModeTool = tool.name === ToolNames.EXIT_PLAN_MODE; - - // Track cleanup functions for sub-agent event listeners - let subAgentCleanupFunctions: Array<() => void> = []; - - // Generate tool_use_id for hook tracking (aligned with core path) - const toolUseId = generateToolUseId(); - - // Get approval mode for hook context (defined outside try for catch block access) - const approvalMode = this.config.getApprovalMode(); + const toolSpan = startToolSpan(toolName, { + 'tool.call_id': callId, + // Dual-emit the legacy call_id/tool_name aliases like CoreToolScheduler + // (coreToolScheduler.ts) so pre-Phase-2 dashboards keyed off call_id keep + // matching daemon/ACP tool spans during the migration window. + call_id: callId, + tool_name: toolName, + }); + let spanSuccess = false; try { - const invocation = tool.build(args); - - // Production AgentTool always initializes `eventEmitter` on its - // invocation (`agent.ts:392`). Be defensive about the `undefined` - // case too so an incomplete/custom AgentTool invocation degrades - // gracefully (no sub-agent event forwarding) instead of throwing - // inside SubAgentTracker.setup — the `'eventEmitter' in invocation` - // key-presence check passed for `{ eventEmitter: undefined }` and - // the ensuing `eventEmitter.on(...)` blew up. - const taskEventEmitter = ( - invocation as { - eventEmitter?: AgentEventEmitter; - } - ).eventEmitter; - if (isAgentTool && taskEventEmitter) { - // Extract subagent metadata from AgentTool call - const parentToolCallId = callId; - const subagentType = (args['subagent_type'] as string) ?? ''; - - // Create a SubAgentTracker for this tool execution - const subSubAgentTracker = new SubAgentTracker( - this, - this.client, - parentToolCallId, - subagentType, - ); - - // Set up sub-agent tool tracking - subAgentCleanupFunctions = subSubAgentTracker.setup( - taskEventEmitter, - abortSignal, - ); - } - - // L3→L4→L5 Permission Flow (aligned with coreToolScheduler) - // - // L3: Tool's intrinsic default permission - // L4: PermissionManager rule override - // L5: ApprovalMode override (YOLO / AUTO_EDIT / PLAN) - // - // AUTO_EDIT auto-approval is handled HERE, same as coreToolScheduler. - // The VS Code extension is just a UI layer for requestPermission. - const isAskUserQuestionTool = fc.name === ToolNames.ASK_USER_QUESTION; - - // ---- L3→L4: Shared permission flow ---- - const toolParams = invocation.params as Record; - const flowResult = await evaluatePermissionFlow( - this.config, - invocation, - fc.name, - toolParams, - ); - const { finalPermission, pmForcedAsk, pmCtx, denyMessage } = flowResult; - - // ---- L5: ApprovalMode overrides ---- - const isPlanMode = approvalMode === ApprovalMode.PLAN; - - if (finalPermission === 'deny') { - return earlyErrorResponse( - new Error(denyMessage ?? `Tool "${fc.name}" is denied.`), - fc.name, - ); - } - - // Explicit allow (user rule matched, or tool's L3 default is 'allow') - // is authoritative for ordinary calls. In AUTO, protected - // self-modification writes must still reach the classifier/fail-closed - // path so allow rules cannot bypass AUTO mode's safety boundary. - // Also resets the denialTracking streak so a following - // classifier-eligible call doesn't surprise the user with a manual - // prompt right after an allow-rule call just worked. - const forceAutoReviewForAllow = - approvalMode === ApprovalMode.AUTO && - shouldForceAutoModeReviewForAllow(pmCtx, this.config.getCwd()); - const confirmationPermission = getEffectivePermissionForConfirmation( - finalPermission, - forceAutoReviewForAllow, - ); - if (finalPermission === 'allow' && forceAutoReviewForAllow) { - debugLogger.info( - `Auto mode: L4 allow overridden by protected-write guard for ${fc.name}`, - ); - } - let autoModeAllowed = - finalPermission === 'allow' && !forceAutoReviewForAllow; - if (autoModeAllowed && approvalMode === ApprovalMode.AUTO) { - this.config.setAutoModeDenialState( - recordAllow(this.config.getAutoModeDenialState()), - ); - } - let wasAutoModeDenialFallback = false; - - // ── L5: AUTO mode three-layer filter (duplicated from - // coreToolScheduler.ts; ACP routes through this Session path). - // Returns 'allowed' / 'blocked' / 'fallback'. Blocked early-returns; - // allowed skips requestPermission; fallback drops through to the - // existing manual-approval flow below. - if (!autoModeAllowed && shouldRunAutoModeForCall(approvalMode, fc.name)) { - const denialState = this.config.getAutoModeDenialState(); - const fallback = shouldFallback(denialState); - // `buildClassifierContents` retains only the most recent - // MAX_TRANSCRIPT_MESSAGES messages; ask the chat client for - // exactly that tail rather than triggering a `structuredClone` - // of the whole session on every non-fast-path AUTO call. - // Parallels coreToolScheduler.ts. - const messages = - this.config - .getGeminiClient?.() - ?.getHistoryTail(MAX_TRANSCRIPT_MESSAGES, false) ?? []; - const decision = await evaluateAutoMode({ - ctx: pmCtx, - pmForcedAsk, - toolParams, - messages, - config: this.config, - signal: abortSignal, - skipClassifierReason: fallback.fallback ? fallback.reason : undefined, - }); - - // Apply decision via shared helper — eliminates ~40 lines of - // line-for-line duplication with coreToolScheduler.ts and makes - // the CLI / ACP paths share one source of truth for the - // switch + denial-tracking state updates + exhaustiveness - // guard. - const outcome = applyAutoModeDecision( - decision, - this.config, - denialState, - ); - await fireSessionPermissionDeniedForAutoMode( - this.config, - decision, - outcome, - fc.name, - toolParams, - callId, - abortSignal, - ); - switch (outcome.kind) { - case 'approved': - autoModeAllowed = true; - break; - case 'blocked': - debugLogger.warn( - `Auto mode blocked (${outcome.reason}): tool=${fc.name}, ` + - formatDenialStateLog(denialState), - ); - return earlyErrorResponse(new Error(outcome.errorMessage), fc.name); - case 'fallback': - // Drop through to the manual-approval flow below. - wasAutoModeDenialFallback = isDenialFallbackReason(outcome.reason); - if (wasAutoModeDenialFallback) { - debugLogger.warn( - `Auto mode fallback to manual approval (${outcome.reason}): ` + - formatDenialStateLog(denialState), - ); - } - break; - default: { - const _exhaustive: never = outcome; - void _exhaustive; - } - } - } - - let didRequestPermission = false; - let confirmationDetails: ToolCallConfirmationDetails | undefined; - const recordAutoModeFallbackResolution = ( - outcome: ToolConfirmationOutcome, - ) => { - // Reset AUTO-mode fallback counters when approval resolves a prompt - // raised because denialTracking forced fallback. This covers both ACP - // requestPermission and PermissionRequest hook approvals. - if ( - approvalMode === ApprovalMode.AUTO && - wasAutoModeDenialFallback && - isApproveOutcome(outcome) - ) { - const before = this.config.getAutoModeDenialState(); - const after = recordFallbackApprove(before); - if (after === before) { - debugLogger.warn( - `Auto mode denial counters already clear after fallback approval: ` + - formatDenialStateLog(before), - ); - return; - } - debugLogger.warn( - `Auto mode denial counters reset after fallback approval: ` + - `${formatDenialStateLog(before)} -> ${formatDenialStateLog(after)}`, - ); - this.config.setAutoModeDenialState(after); - } - }; - - if ( - !autoModeAllowed && - needsConfirmation(confirmationPermission, approvalMode, fc.name) - ) { - confirmationDetails = - await invocation.getConfirmationDetails(abortSignal); - - // Centralised rule injection (for display and persistence) - injectPermissionRulesIfMissing(confirmationDetails, pmCtx); - - if ( - isPlanModeBlocked( - isPlanMode, - isExitPlanModeTool, - isAskUserQuestionTool, - confirmationDetails, - ) - ) { + return await runInToolSpanContext(toolSpan, async () => { + // ---- L1: Tool enablement check ---- + const pm = this.config.getPermissionManager?.(); + if (pm && !(await pm.isToolEnabled(toolName))) { return earlyErrorResponse( - new Error( - `Plan mode is active. The tool "${fc.name}" cannot be executed because it modifies the system. ` + - 'Please use the exit_plan_mode tool to present your plan and exit plan mode before making changes.', - ), - fc.name, + new Error(`Tool "${toolName}" is disabled.`), + toolName, ); } - const messageBus = this.config.getMessageBus?.(); - const hooksEnabled = !this.config.getDisableAllHooks?.(); - let hookHandled = false; + // Detect TodoWriteTool early - route to plan updates instead of tool_call events + const isTodoWriteTool = tool.name === ToolNames.TODO_WRITE; + const isAgentTool = tool.name === ToolNames.AGENT; + const isExitPlanModeTool = tool.name === ToolNames.EXIT_PLAN_MODE; - if (hooksEnabled && messageBus) { - const hookResult = await firePermissionRequestHook( - messageBus, - fc.name, - args, - String(approvalMode), + // Track cleanup functions for sub-agent event listeners + let subAgentCleanupFunctions: Array<() => void> = []; + + // Generate tool_use_id for hook tracking (aligned with core path) + const toolUseId = generateToolUseId(); + + // Get approval mode for hook context (defined outside try for catch block access) + const approvalMode = this.config.getApprovalMode(); + + try { + const invocation = tool.build(args); + + // Production AgentTool always initializes `eventEmitter` on its + // invocation (`agent.ts:392`). Be defensive about the `undefined` + // case too so an incomplete/custom AgentTool invocation degrades + // gracefully (no sub-agent event forwarding) instead of throwing + // inside SubAgentTracker.setup — the `'eventEmitter' in invocation` + // key-presence check passed for `{ eventEmitter: undefined }` and + // the ensuing `eventEmitter.on(...)` blew up. + const taskEventEmitter = ( + invocation as { + eventEmitter?: AgentEventEmitter; + } + ).eventEmitter; + if (isAgentTool && taskEventEmitter) { + // Extract subagent metadata from AgentTool call + const parentToolCallId = callId; + const subagentType = (args['subagent_type'] as string) ?? ''; + + // Create a SubAgentTracker for this tool execution + const subSubAgentTracker = new SubAgentTracker( + this, + this.client, + parentToolCallId, + subagentType, + ); + + // Set up sub-agent tool tracking + subAgentCleanupFunctions = subSubAgentTracker.setup( + taskEventEmitter, + abortSignal, + ); + } + + // L3→L4→L5 Permission Flow (aligned with coreToolScheduler) + // + // L3: Tool's intrinsic default permission + // L4: PermissionManager rule override + // L5: ApprovalMode override (YOLO / AUTO_EDIT / PLAN) + // + // AUTO_EDIT auto-approval is handled HERE, same as coreToolScheduler. + // The VS Code extension is just a UI layer for requestPermission. + const isAskUserQuestionTool = + toolName === ToolNames.ASK_USER_QUESTION; + + // ---- L3→L4: Shared permission flow ---- + const toolParams = invocation.params as Record; + const flowResult = await evaluatePermissionFlow( + this.config, + invocation, + toolName, + toolParams, ); + const { finalPermission, pmForcedAsk, pmCtx, denyMessage } = + flowResult; - if (hookResult.hasDecision) { - hookHandled = true; - if (hookResult.shouldAllow) { - if (hookResult.updatedInput) { - args = hookResult.updatedInput; - invocation.params = - hookResult.updatedInput as typeof invocation.params; + // ---- L5: ApprovalMode overrides ---- + const isPlanMode = approvalMode === ApprovalMode.PLAN; + + if (finalPermission === 'deny') { + return earlyErrorResponse( + new Error(denyMessage ?? `Tool "${toolName}" is denied.`), + toolName, + ); + } + + // Explicit allow (user rule matched, or tool's L3 default is 'allow') + // is authoritative for ordinary calls. In AUTO, protected + // self-modification writes must still reach the classifier/fail-closed + // path so allow rules cannot bypass AUTO mode's safety boundary. + // Also resets the denialTracking streak so a following + // classifier-eligible call doesn't surprise the user with a manual + // prompt right after an allow-rule call just worked. + const forceAutoReviewForAllow = + approvalMode === ApprovalMode.AUTO && + shouldForceAutoModeReviewForAllow(pmCtx, this.config.getCwd()); + const confirmationPermission = getEffectivePermissionForConfirmation( + finalPermission, + forceAutoReviewForAllow, + ); + if (finalPermission === 'allow' && forceAutoReviewForAllow) { + debugLogger.info( + `Auto mode: L4 allow overridden by protected-write guard for ${toolName}`, + ); + } + let autoModeAllowed = + finalPermission === 'allow' && !forceAutoReviewForAllow; + if (autoModeAllowed && approvalMode === ApprovalMode.AUTO) { + this.config.setAutoModeDenialState( + recordAllow(this.config.getAutoModeDenialState()), + ); + } + let wasAutoModeDenialFallback = false; + + // ── L5: AUTO mode three-layer filter (duplicated from + // coreToolScheduler.ts; ACP routes through this Session path). + // Returns 'allowed' / 'blocked' / 'fallback'. Blocked early-returns; + // allowed skips requestPermission; fallback drops through to the + // existing manual-approval flow below. + if ( + !autoModeAllowed && + shouldRunAutoModeForCall(approvalMode, toolName) + ) { + const denialState = this.config.getAutoModeDenialState(); + const fallback = shouldFallback(denialState); + // `buildClassifierContents` retains only the most recent + // MAX_TRANSCRIPT_MESSAGES messages; ask the chat client for + // exactly that tail rather than triggering a `structuredClone` + // of the whole session on every non-fast-path AUTO call. + // Parallels coreToolScheduler.ts. + const messages = + this.config + .getGeminiClient?.() + ?.getHistoryTail(MAX_TRANSCRIPT_MESSAGES, false) ?? []; + const decision = await evaluateAutoMode({ + ctx: pmCtx, + pmForcedAsk, + toolParams, + messages, + config: this.config, + signal: abortSignal, + skipClassifierReason: fallback.fallback + ? fallback.reason + : undefined, + }); + + // Apply decision via shared helper — eliminates ~40 lines of + // line-for-line duplication with coreToolScheduler.ts and makes + // the CLI / ACP paths share one source of truth for the + // switch + denial-tracking state updates + exhaustiveness + // guard. + const outcome = applyAutoModeDecision( + decision, + this.config, + denialState, + ); + await fireSessionPermissionDeniedForAutoMode( + this.config, + decision, + outcome, + toolName, + toolParams, + callId, + abortSignal, + ); + switch (outcome.kind) { + case 'approved': + autoModeAllowed = true; + break; + case 'blocked': + debugLogger.warn( + `Auto mode blocked (${outcome.reason}): tool=${toolName}, ` + + formatDenialStateLog(denialState), + ); + return earlyErrorResponse( + new Error(outcome.errorMessage), + toolName, + ); + case 'fallback': + // Drop through to the manual-approval flow below. + wasAutoModeDenialFallback = isDenialFallbackReason( + outcome.reason, + ); + if (wasAutoModeDenialFallback) { + debugLogger.warn( + `Auto mode fallback to manual approval (${outcome.reason}): ` + + formatDenialStateLog(denialState), + ); + } + break; + default: { + const _exhaustive: never = outcome; + void _exhaustive; } + } + } - await confirmationDetails.onConfirm( - ToolConfirmationOutcome.ProceedOnce, + let didRequestPermission = false; + let confirmationDetails: ToolCallConfirmationDetails | undefined; + const recordAutoModeFallbackResolution = ( + outcome: ToolConfirmationOutcome, + ) => { + // Reset AUTO-mode fallback counters when approval resolves a prompt + // raised because denialTracking forced fallback. This covers both ACP + // requestPermission and PermissionRequest hook approvals. + if ( + approvalMode === ApprovalMode.AUTO && + wasAutoModeDenialFallback && + isApproveOutcome(outcome) + ) { + const before = this.config.getAutoModeDenialState(); + const after = recordFallbackApprove(before); + if (after === before) { + debugLogger.warn( + `Auto mode denial counters already clear after fallback approval: ` + + formatDenialStateLog(before), + ); + return; + } + debugLogger.warn( + `Auto mode denial counters reset after fallback approval: ` + + `${formatDenialStateLog(before)} -> ${formatDenialStateLog(after)}`, ); - recordAutoModeFallbackResolution( - ToolConfirmationOutcome.ProceedOnce, - ); - } else { + this.config.setAutoModeDenialState(after); + } + }; + + if ( + !autoModeAllowed && + needsConfirmation(confirmationPermission, approvalMode, toolName) + ) { + confirmationDetails = + await invocation.getConfirmationDetails(abortSignal); + + // Centralised rule injection (for display and persistence) + injectPermissionRulesIfMissing(confirmationDetails, pmCtx); + + if ( + isPlanModeBlocked( + isPlanMode, + isExitPlanModeTool, + isAskUserQuestionTool, + confirmationDetails, + ) + ) { return earlyErrorResponse( new Error( - hookResult.denyMessage || - `Permission denied by hook for "${fc.name}"`, + `Plan mode is active. The tool "${toolName}" cannot be executed because it modifies the system. ` + + 'Please use the exit_plan_mode tool to present your plan and exit plan mode before making changes.', ), - fc.name, + toolName, + ); + } + + const messageBus = this.config.getMessageBus?.(); + const hooksEnabled = !this.config.getDisableAllHooks?.(); + let hookHandled = false; + + if (hooksEnabled && messageBus) { + const hookResult = await firePermissionRequestHook( + messageBus, + toolName, + args, + String(approvalMode), + ); + + if (hookResult.hasDecision) { + hookHandled = true; + if (hookResult.shouldAllow) { + if (hookResult.updatedInput) { + args = hookResult.updatedInput; + invocation.params = + hookResult.updatedInput as typeof invocation.params; + } + + await confirmationDetails.onConfirm( + ToolConfirmationOutcome.ProceedOnce, + ); + recordAutoModeFallbackResolution( + ToolConfirmationOutcome.ProceedOnce, + ); + } else { + return earlyErrorResponse( + new Error( + hookResult.denyMessage || + `Permission denied by hook for "${toolName}"`, + ), + toolName, + ); + } + } + } + + // AUTO_EDIT mode: auto-approve edit and info tools + // (same as coreToolScheduler L5 — NOT delegated to the extension) + if ( + approvalMode === ApprovalMode.AUTO_EDIT && + (confirmationDetails.type === 'edit' || + confirmationDetails.type === 'info') + ) { + // Auto-approve, skip requestPermission. + // didRequestPermission stays false → emitStart below. + } else if (!hookHandled) { + // Show permission dialog via ACP requestPermission + didRequestPermission = true; + const content = + buildPermissionRequestContent(confirmationDetails); + + // Map tool kind, using switch_mode for exit_plan_mode per ACP spec + const mappedKind = this.toolCallEmitter.mapToolKind( + tool.kind, + toolName, + ); + + if (hooksEnabled && messageBus) { + this.fireNotificationHookWithTerminalSequence( + messageBus, + `Qwen Code needs your permission to use ${toolName}`, + NotificationType.PermissionPrompt, + 'Permission needed', + ); + } + + const params: RequestPermissionRequest = { + sessionId: this.sessionId, + options: toPermissionOptions(confirmationDetails, pmForcedAsk), + toolCall: { + toolCallId: callId, + status: 'pending', + title: invocation.getDescription(), + content, + locations: invocation.toolLocations(), + kind: mappedKind, + rawInput: args, + }, + }; + + const output = (await this.client.requestPermission( + params, + )) as RequestPermissionResponse & { + answers?: Record; + }; + const outcome = + output.outcome.outcome === 'cancelled' + ? ToolConfirmationOutcome.Cancel + : z + .nativeEnum(ToolConfirmationOutcome) + .parse(output.outcome.optionId); + + recordAutoModeFallbackResolution(outcome); + + await confirmationDetails.onConfirm(outcome, { + answers: output.answers, + }); + + // Persist permission rules when user explicitly chose "Always Allow". + // This branch is only reached for tools that went through + // requestPermission (user saw dialog and made a choice). + // AUTO_EDIT auto-approved tools never reach here. + if ( + outcome === ToolConfirmationOutcome.ProceedAlways || + outcome === ToolConfirmationOutcome.ProceedAlwaysProject || + outcome === ToolConfirmationOutcome.ProceedAlwaysUser + ) { + await persistPermissionOutcome( + outcome, + confirmationDetails, + this.config.getOnPersistPermissionRule?.(), + this.config.getPermissionManager?.(), + { answers: output.answers }, + ); + } + + // After exit_plan_mode confirmation, send current_mode_update + if ( + isExitPlanModeTool && + outcome !== ToolConfirmationOutcome.Cancel + ) { + await this.sendCurrentModeUpdateNotification(outcome); + } + + // After edit tool ProceedAlways, notify the client about mode change + if ( + confirmationDetails.type === 'edit' && + outcome === ToolConfirmationOutcome.ProceedAlways + ) { + await this.sendCurrentModeUpdateNotification(outcome); + } + + switch (outcome) { + case ToolConfirmationOutcome.Cancel: + // Route through earlyErrorResponse so spanError carries the + // cancellation reason (plain errorResponse leaves it unset, + // which makes endToolSpan fall back to the generic 'tool + // error' message) and the declined call is still recorded. + return earlyErrorResponse( + new Error(`Tool "${toolName}" was canceled by the user.`), + toolName, + ); + case ToolConfirmationOutcome.ProceedOnce: + case ToolConfirmationOutcome.ProceedAlways: + case ToolConfirmationOutcome.ProceedAlwaysProject: + case ToolConfirmationOutcome.ProceedAlwaysUser: + case ToolConfirmationOutcome.ProceedAlwaysServer: + case ToolConfirmationOutcome.ProceedAlwaysTool: + case ToolConfirmationOutcome.ModifyWithEditor: + case ToolConfirmationOutcome.RestorePrevious: + break; + default: { + const resultOutcome: never = outcome; + throw new Error(`Unexpected: ${resultOutcome}`); + } + } + } + } + + if (!didRequestPermission && !isTodoWriteTool) { + // Auto-approved (L3 allow / L4 PM allow / L5 YOLO|AUTO_EDIT) + // → emit tool_call start notification + const startParams: ToolCallStartParams = { + callId, + toolName, + args, + status: 'in_progress', + }; + await this.toolCallEmitter.emitStart(startParams); + } + + // Fire PreToolUse hook (aligned with core path in coreToolScheduler.ts) + const hooksEnabledForTool = !this.config.getDisableAllHooks?.(); + const messageBusForTool = this.config.getMessageBus?.(); + const permissionMode = String(approvalMode); + + if (hooksEnabledForTool && messageBusForTool) { + const preHookResult = await firePreToolUseHook( + messageBusForTool, + toolName, + args, + toolUseId, + permissionMode, + abortSignal, + ); + + if (!preHookResult.shouldProceed) { + // Hook blocked the tool execution - send notification to UI + const blockReason = + preHookResult.blockReason || 'Blocked by PreToolUse hook'; + await this.messageEmitter.emitAgentMessage( + `🚫 **PreToolUse blocked**: ${toolName} - ${blockReason}`, + ); + return earlyErrorResponse(new Error(blockReason), toolName); + } + + // Add additional context from PreToolUse hook if provided + // Note: This context would need to be passed to the tool invocation + // For now, we just log it as the tool execution proceeds + if (preHookResult.additionalContext) { + debugLogger.debug( + `PreToolUse hook additional context for ${toolName}: ${preHookResult.additionalContext}`, ); } } - } - // AUTO_EDIT mode: auto-approve edit and info tools - // (same as coreToolScheduler L5 — NOT delegated to the extension) - if ( - approvalMode === ApprovalMode.AUTO_EDIT && - (confirmationDetails.type === 'edit' || - confirmationDetails.type === 'info') - ) { - // Auto-approve, skip requestPermission. - // didRequestPermission stays false → emitStart below. - } else if (!hookHandled) { - // Show permission dialog via ACP requestPermission - didRequestPermission = true; - const content = buildPermissionRequestContent(confirmationDetails); - - // Map tool kind, using switch_mode for exit_plan_mode per ACP spec - const mappedKind = this.toolCallEmitter.mapToolKind( - tool.kind, - fc.name, - ); - - if (hooksEnabled && messageBus) { - this.fireNotificationHookWithTerminalSequence( - messageBus, - `Qwen Code needs your permission to use ${fc.name}`, - NotificationType.PermissionPrompt, - 'Permission needed', + const execSpan = startToolExecutionSpan(); + let toolResult: ToolResult; + try { + const sleepInhibitorHandle = acquireSleepInhibitor( + this.config, + `Qwen Code is executing tool ${toolName}`, ); + try { + toolResult = await invocation.execute(abortSignal); + } finally { + sleepInhibitorHandle.release(); + } + const aborted = abortSignal.aborted; + endToolExecutionSpan(execSpan, { + success: !toolResult.error && !aborted, + error: aborted + ? 'tool_cancelled' + : toolResult.error + ? 'tool_error' + : undefined, + cancelled: aborted, + }); + } catch (execError) { + endToolExecutionSpan(execSpan, { + success: false, + error: abortSignal.aborted ? 'tool_cancelled' : 'tool_exception', + cancelled: abortSignal.aborted, + }); + throw execError; } - const params: RequestPermissionRequest = { - sessionId: this.sessionId, - options: toPermissionOptions(confirmationDetails, pmForcedAsk), - toolCall: { - toolCallId: callId, - status: 'pending', - title: invocation.getDescription(), - content, - locations: invocation.toolLocations(), - kind: mappedKind, - rawInput: args, - }, - }; + // Clean up event listeners + subAgentCleanupFunctions.forEach((cleanup) => cleanup()); - const output = (await this.client.requestPermission( - params, - )) as RequestPermissionResponse & { - answers?: Record; - }; - const outcome = - output.outcome.outcome === 'cancelled' - ? ToolConfirmationOutcome.Cancel - : z - .nativeEnum(ToolConfirmationOutcome) - .parse(output.outcome.optionId); + // Create response parts first (needed for emitResult and recordToolResult) + const responseParts = convertToFunctionResponse( + toolName, + callId, + toolResult.llmContent, + ); - recordAutoModeFallbackResolution(outcome); + // Fire PostToolUse hook on successful execution (aligned with core path) + if (hooksEnabledForTool && messageBusForTool && !toolResult.error) { + // Use the same response shape as core (llmContent/returnDisplay) + const toolResponse = { + llmContent: toolResult.llmContent, + returnDisplay: toolResult.returnDisplay, + }; + const postHookResult = await firePostToolUseHook( + messageBusForTool, + toolName, + args, + toolResponse, + toolUseId, + permissionMode, + abortSignal, + ); - await confirmationDetails.onConfirm(outcome, { - answers: output.answers, + // If hook indicates to stop, return an error response + if (postHookResult.shouldStop) { + const stopMessage = + postHookResult.stopReason || + 'Execution stopped by PostToolUse hook'; + debugLogger.info( + `PostToolUse hook requested stop for ${toolName}: ${stopMessage}`, + ); + return earlyErrorResponse(new Error(stopMessage), toolName); + } + + // Add additional context from PostToolUse hook if provided + if (postHookResult.additionalContext) { + // Append additional context to the tool response + const contextPart = { text: postHookResult.additionalContext }; + responseParts.push(contextPart); + } + } else if ( + hooksEnabledForTool && + messageBusForTool && + toolResult.error + ) { + // Fire PostToolUseFailure hook when tool returns an error (aligned with core path) + const failureHookResult = await firePostToolUseFailureHook( + messageBusForTool, + toolUseId, + toolName, + args, + toolResult.error.message, + false, // not an interrupt + permissionMode, + abortSignal, + ); + + // Log additional context if provided + if (failureHookResult.additionalContext) { + debugLogger.debug( + `PostToolUseFailure hook additional context for ${toolName}: ${failureHookResult.additionalContext}`, + ); + } + } + + // A tool can fail "softly" by returning toolResult.error without + // throwing, and can be cancelled mid-flight. Compute the real outcome + // once and reflect it on the client-facing emitResult as well as + // logToolCall / recordToolResult / the tool span, instead of + // hardcoding success — otherwise failed/cancelled daemon/ACP tools + // are mislabeled as successful in telemetry, session replay, and the + // client UI. + const aborted = abortSignal.aborted; + const status: 'success' | 'error' | 'cancelled' = aborted + ? 'cancelled' + : toolResult.error + ? 'error' + : 'success'; + const succeeded = status === 'success'; + + // Handle TodoWriteTool: extract todos and send plan update + if (isTodoWriteTool) { + const todos = this.planEmitter.extractTodos( + toolResult.returnDisplay, + args, + ); + + // Match original logic: emit plan if todos.length > 0 OR if args had todos + if ((todos && todos.length > 0) || Array.isArray(args['todos'])) { + await this.planEmitter.emitPlan(todos ?? []); + } + + // Skip tool_call_update event for TodoWriteTool + // Still log and return function response for LLM + } else { + // Normal tool handling: emit result using ToolCallEmitter + const error = toolResult.error + ? new Error(toolResult.error.message) + : aborted + ? new Error('Tool execution was cancelled') + : undefined; + + await this.toolCallEmitter.emitResult({ + callId, + toolName, + args, + message: responseParts, + resultDisplay: toolResult.returnDisplay, + error, + success: succeeded, + }); + } + + const durationMs = Date.now() - startTime; + logToolCall(this.config, { + 'event.name': 'tool_call', + 'event.timestamp': new Date().toISOString(), + function_name: toolName, + function_args: args, + duration_ms: durationMs, + status, + success: succeeded, + error: toolResult.error?.message, + error_type: toolResult.error?.type, + prompt_id: promptId, + tool_type: + typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool + ? 'mcp' + : 'native', }); - // Persist permission rules when user explicitly chose "Always Allow". - // This branch is only reached for tools that went through - // requestPermission (user saw dialog and made a choice). - // AUTO_EDIT auto-approved tools never reach here. - if ( - outcome === ToolConfirmationOutcome.ProceedAlways || - outcome === ToolConfirmationOutcome.ProceedAlwaysProject || - outcome === ToolConfirmationOutcome.ProceedAlwaysUser - ) { - await persistPermissionOutcome( - outcome, - confirmationDetails, - this.config.getOnPersistPermissionRule?.(), - this.config.getPermissionManager?.(), - { answers: output.answers }, + // Record tool result for session management + this.config + .getChatRecordingService() + ?.recordToolResult(responseParts, { + callId, + status, + resultDisplay: toolResult.returnDisplay, + error: toolResult.error + ? new Error(toolResult.error.message) + : undefined, + errorType: toolResult.error?.type, + }); + + spanSuccess = succeeded; + if (toolResult.error) { + spanError = toolResult.error.message; + } else if (aborted) { + spanError = 'Tool execution was cancelled'; + } + return responseParts; + } catch (e) { + // Ensure cleanup on error + subAgentCleanupFunctions.forEach((cleanup) => cleanup()); + + const error = e instanceof Error ? e : new Error(String(e)); + spanError = error.message; + + // Fire PostToolUseFailure hook (aligned with core path in coreToolScheduler.ts) + const hooksEnabledForError = !this.config.getDisableAllHooks?.(); + const messageBusForError = this.config.getMessageBus?.(); + const isInterrupt = abortSignal.aborted; + + if (hooksEnabledForError && messageBusForError) { + const failureHookResult = await firePostToolUseFailureHook( + messageBusForError, + toolUseId, + toolName, + args, + error.message, + isInterrupt, + String(approvalMode), + abortSignal, ); - } - // After exit_plan_mode confirmation, send current_mode_update - if ( - isExitPlanModeTool && - outcome !== ToolConfirmationOutcome.Cancel - ) { - await this.sendCurrentModeUpdateNotification(outcome); - } - - // After edit tool ProceedAlways, notify the client about mode change - if ( - confirmationDetails.type === 'edit' && - outcome === ToolConfirmationOutcome.ProceedAlways - ) { - await this.sendCurrentModeUpdateNotification(outcome); - } - - switch (outcome) { - case ToolConfirmationOutcome.Cancel: - return errorResponse( - new Error(`Tool "${fc.name}" was canceled by the user.`), + // Log additional context if provided + if (failureHookResult.additionalContext) { + debugLogger.debug( + `PostToolUseFailure hook additional context for ${toolName}: ${failureHookResult.additionalContext}`, ); - case ToolConfirmationOutcome.ProceedOnce: - case ToolConfirmationOutcome.ProceedAlways: - case ToolConfirmationOutcome.ProceedAlwaysProject: - case ToolConfirmationOutcome.ProceedAlwaysUser: - case ToolConfirmationOutcome.ProceedAlwaysServer: - case ToolConfirmationOutcome.ProceedAlwaysTool: - case ToolConfirmationOutcome.ModifyWithEditor: - case ToolConfirmationOutcome.RestorePrevious: - break; - default: { - const resultOutcome: never = outcome; - throw new Error(`Unexpected: ${resultOutcome}`); } } + + // Use ToolCallEmitter for error handling + await this.toolCallEmitter.emitError(callId, toolName, error); + + // Record tool error for session management + const errorParts = [ + { + functionResponse: { + id: callId, + name: toolName, + response: { error: error.message }, + }, + }, + ]; + this.config.getChatRecordingService()?.recordToolResult(errorParts, { + callId, + // A throw caused by abort (e.g. AbortError) is a cancellation, not + // a genuine tool error — keep it consistent with the success path. + status: abortSignal.aborted ? 'cancelled' : 'error', + resultDisplay: undefined, + error, + errorType: undefined, + }); + + return errorResponse(error); } - } - - if (!didRequestPermission && !isTodoWriteTool) { - // Auto-approved (L3 allow / L4 PM allow / L5 YOLO|AUTO_EDIT) - // → emit tool_call start notification - const startParams: ToolCallStartParams = { - callId, - toolName: fc.name, - args, - status: 'in_progress', - }; - await this.toolCallEmitter.emitStart(startParams); - } - - // Fire PreToolUse hook (aligned with core path in coreToolScheduler.ts) - const hooksEnabledForTool = !this.config.getDisableAllHooks?.(); - const messageBusForTool = this.config.getMessageBus?.(); - const permissionMode = String(approvalMode); - - if (hooksEnabledForTool && messageBusForTool) { - const preHookResult = await firePreToolUseHook( - messageBusForTool, - fc.name, - args, - toolUseId, - permissionMode, - abortSignal, - ); - - if (!preHookResult.shouldProceed) { - // Hook blocked the tool execution - send notification to UI - const blockReason = - preHookResult.blockReason || 'Blocked by PreToolUse hook'; - await this.messageEmitter.emitAgentMessage( - `🚫 **PreToolUse blocked**: ${fc.name} - ${blockReason}`, - ); - return earlyErrorResponse(new Error(blockReason), fc.name); - } - - // Add additional context from PreToolUse hook if provided - // Note: This context would need to be passed to the tool invocation - // For now, we just log it as the tool execution proceeds - if (preHookResult.additionalContext) { - debugLogger.debug( - `PreToolUse hook additional context for ${fc.name}: ${preHookResult.additionalContext}`, - ); - } - } - - const sleepInhibitorHandle = acquireSleepInhibitor( - this.config, - `Qwen Code is executing tool ${fc.name}`, - ); - let toolResult: ToolResult; - try { - toolResult = await invocation.execute(abortSignal); - } finally { - sleepInhibitorHandle.release(); - } - - // Clean up event listeners - subAgentCleanupFunctions.forEach((cleanup) => cleanup()); - - // Create response parts first (needed for emitResult and recordToolResult) - const responseParts = convertToFunctionResponse( - fc.name, - callId, - toolResult.llmContent, - ); - - // Fire PostToolUse hook on successful execution (aligned with core path) - if (hooksEnabledForTool && messageBusForTool && !toolResult.error) { - // Use the same response shape as core (llmContent/returnDisplay) - const toolResponse = { - llmContent: toolResult.llmContent, - returnDisplay: toolResult.returnDisplay, - }; - const postHookResult = await firePostToolUseHook( - messageBusForTool, - fc.name, - args, - toolResponse, - toolUseId, - permissionMode, - abortSignal, - ); - - // If hook indicates to stop, return an error response - if (postHookResult.shouldStop) { - const stopMessage = - postHookResult.stopReason || - 'Execution stopped by PostToolUse hook'; - debugLogger.info( - `PostToolUse hook requested stop for ${fc.name}: ${stopMessage}`, - ); - return earlyErrorResponse(new Error(stopMessage), fc.name); - } - - // Add additional context from PostToolUse hook if provided - if (postHookResult.additionalContext) { - // Append additional context to the tool response - const contextPart = { text: postHookResult.additionalContext }; - responseParts.push(contextPart); - } - } else if (hooksEnabledForTool && messageBusForTool && toolResult.error) { - // Fire PostToolUseFailure hook when tool returns an error (aligned with core path) - const failureHookResult = await firePostToolUseFailureHook( - messageBusForTool, - toolUseId, - fc.name ?? 'unknown_tool', - args, - toolResult.error.message, - false, // not an interrupt - permissionMode, - abortSignal, - ); - - // Log additional context if provided - if (failureHookResult.additionalContext) { - debugLogger.debug( - `PostToolUseFailure hook additional context for ${fc.name}: ${failureHookResult.additionalContext}`, - ); - } - } - - // Handle TodoWriteTool: extract todos and send plan update - if (isTodoWriteTool) { - const todos = this.planEmitter.extractTodos( - toolResult.returnDisplay, - args, - ); - - // Match original logic: emit plan if todos.length > 0 OR if args had todos - if ((todos && todos.length > 0) || Array.isArray(args['todos'])) { - await this.planEmitter.emitPlan(todos ?? []); - } - - // Skip tool_call_update event for TodoWriteTool - // Still log and return function response for LLM - } else { - // Normal tool handling: emit result using ToolCallEmitter - // Convert toolResult.error to Error type if present - const error = toolResult.error - ? new Error(toolResult.error.message) - : undefined; - - await this.toolCallEmitter.emitResult({ - callId, - toolName: fc.name, - args, - message: responseParts, - resultDisplay: toolResult.returnDisplay, - error, - success: !toolResult.error, - }); - } - - const durationMs = Date.now() - startTime; - logToolCall(this.config, { - 'event.name': 'tool_call', - 'event.timestamp': new Date().toISOString(), - function_name: fc.name, - function_args: args, - duration_ms: durationMs, - status: 'success', - success: true, - prompt_id: promptId, - tool_type: - typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool - ? 'mcp' - : 'native', - }); - - // Record tool result for session management - this.config.getChatRecordingService()?.recordToolResult(responseParts, { - callId, - status: 'success', - resultDisplay: toolResult.returnDisplay, - error: undefined, - errorType: undefined, - }); - - return responseParts; - } catch (e) { - // Ensure cleanup on error - subAgentCleanupFunctions.forEach((cleanup) => cleanup()); - - const error = e instanceof Error ? e : new Error(String(e)); - - // Fire PostToolUseFailure hook (aligned with core path in coreToolScheduler.ts) - const hooksEnabledForError = !this.config.getDisableAllHooks?.(); - const messageBusForError = this.config.getMessageBus?.(); - const isInterrupt = abortSignal.aborted; - - if (hooksEnabledForError && messageBusForError) { - const failureHookResult = await firePostToolUseFailureHook( - messageBusForError, - toolUseId, - fc.name ?? 'unknown_tool', - args, - error.message, - isInterrupt, - String(approvalMode), - abortSignal, - ); - - // Log additional context if provided - if (failureHookResult.additionalContext) { - debugLogger.debug( - `PostToolUseFailure hook additional context for ${fc.name}: ${failureHookResult.additionalContext}`, - ); - } - } - - // Use ToolCallEmitter for error handling - await this.toolCallEmitter.emitError( - callId, - fc.name ?? 'unknown_tool', - error, - ); - - // Record tool error for session management - const errorParts = [ - { - functionResponse: { - id: callId, - name: fc.name ?? '', - response: { error: error.message }, - }, - }, - ]; - this.config.getChatRecordingService()?.recordToolResult(errorParts, { - callId, - status: 'error', - resultDisplay: undefined, - error, - errorType: undefined, - }); - - return errorResponse(error); + }); // end runInToolSpanContext + } finally { + endToolSpan(toolSpan, { success: spanSuccess, error: spanError }); } } @@ -3223,12 +3670,12 @@ export class Session implements SessionContext { return { text: part.text }; case 'image': case 'audio': - return { + return clampInlineMediaPart({ inlineData: { mimeType: part.mimeType, data: part.data, }, - }; + }); case 'resource_link': { if (part.uri.startsWith(FILE_URI_SCHEME)) { return { @@ -3262,7 +3709,7 @@ export class Session implements SessionContext { // Extract paths from @ commands - pass directly to readManyFiles without filtering // since this is user-triggered behavior, not LLM-triggered const pathSpecsToRead: string[] = atPathCommandParts.map( - (part) => part.fileData!.fileUri, + (part) => part.fileData!.fileUri!, ); // Construct the initial part of the query for the LLM @@ -3305,14 +3752,10 @@ export class Session implements SessionContext { if (typeof part === 'string') { processedQueryParts.push({ text: part }); } else { - processedQueryParts.push(part); + processedQueryParts.push(clampInlineMediaPart(part)); } } - } else if (embeddedContext.length > 0) { - // No @path files to read, but we have embedded context - processedQueryParts.push({ text: initialQueryText.trim() }); } else { - // No @path files found processedQueryParts.push({ text: initialQueryText.trim() }); } @@ -3326,12 +3769,14 @@ export class Session implements SessionContext { } // Type guard for blob resources if ('blob' in contextPart && contextPart.blob) { - processedQueryParts.push({ - inlineData: { - mimeType: contextPart.mimeType ?? 'application/octet-stream', - data: contextPart.blob, - }, - }); + processedQueryParts.push( + clampInlineMediaPart({ + inlineData: { + mimeType: contextPart.mimeType ?? 'application/octet-stream', + data: contextPart.blob, + }, + }), + ); } } diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.ts index fe1fbc3a63..56f17b0422 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.ts @@ -45,6 +45,10 @@ const debugLogger = createDebugLogger('ACP_SUBAGENT_TRACKER'); export class SubAgentTracker { private readonly toolCallEmitter: ToolCallEmitter; private readonly messageEmitter: MessageEmitter; + private readonly subagentMeta: { + parentToolCallId: string; + subagentType: string; + }; private readonly toolStates = new Map< string, { @@ -57,21 +61,12 @@ export class SubAgentTracker { constructor( private readonly ctx: SessionContext, private readonly client: AgentSideConnection, - private readonly parentToolCallId: string, - private readonly subagentType: string, + parentToolCallId: string, + subagentType: string, ) { this.toolCallEmitter = new ToolCallEmitter(ctx); this.messageEmitter = new MessageEmitter(ctx); - } - - /** - * Gets the subagent metadata to attach to all events. - */ - private getSubagentMeta() { - return { - parentToolCallId: this.parentToolCallId, - subagentType: this.subagentType, - }; + this.subagentMeta = { parentToolCallId, subagentType }; } /** @@ -146,7 +141,7 @@ export class SubAgentTracker { toolName: event.name, callId: event.callId, args: event.args, - subagentMeta: this.getSubagentMeta(), + subagentMeta: this.subagentMeta, }); }; } @@ -171,7 +166,7 @@ export class SubAgentTracker { message: event.responseParts ?? [], resultDisplay: event.resultDisplay, args: state?.args, - subagentMeta: this.getSubagentMeta(), + subagentMeta: this.subagentMeta, }); // Clean up state @@ -255,7 +250,7 @@ export class SubAgentTracker { event.usage, '', event.durationMs, - this.getSubagentMeta(), + this.subagentMeta, ); }; } @@ -277,7 +272,7 @@ export class SubAgentTracker { 'assistant', event.thought ?? false, undefined, - this.getSubagentMeta(), + this.subagentMeta, ); }; } diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts index 6debc37956..3f6bdfa079 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts @@ -83,6 +83,29 @@ describe('MessageEmitter', () => { }); }); + describe('emitGoalTerminal', () => { + it('should send a goal terminal update in metadata', async () => { + const event = { + kind: 'achieved' as const, + condition: 'ship goal support', + iterations: 2, + durationMs: 1234, + lastReason: 'The requested support is complete.', + }; + + await emitter.emitGoalTerminal(event); + + expect(sendUpdateSpy).toHaveBeenCalledTimes(1); + expect(sendUpdateSpy).toHaveBeenCalledWith({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '' }, + _meta: { + goalTerminal: event, + }, + }); + }); + }); + describe('emitAgentThought', () => { it('should send agent_thought_chunk update with text content', async () => { await emitter.emitAgentThought('Let me think about this...'); diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts index 623536fa5b..36750b0334 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts @@ -7,6 +7,10 @@ import type { GenerateContentResponseUsageMetadata } from '@google/genai'; import type { SubagentMeta } from '../types.js'; import type { Usage } from '@agentclientprotocol/sdk'; +import { + getActiveGoal, + type GoalTerminalEvent, +} from '@qwen-code/qwen-code-core'; import { BaseEmitter } from './BaseEmitter.js'; /** @@ -30,6 +34,7 @@ export class MessageEmitter extends BaseEmitter { reasons: string[], stopHookCount: number, ): Promise { + const activeGoal = getActiveGoal(this.sessionId); await this.sendUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '' }, @@ -38,10 +43,31 @@ export class MessageEmitter extends BaseEmitter { iterationCount, reasons, stopHookCount, + ...(activeGoal + ? { + goal: { + condition: activeGoal.condition, + iterations: activeGoal.iterations, + setAt: activeGoal.setAt, + lastReason: activeGoal.lastReason, + }, + } + : {}), }, }, }); } + + async emitGoalTerminal(event: GoalTerminalEvent): Promise { + await this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '' }, + _meta: { + goalTerminal: event, + }, + }); + } + /** * Emits a user message chunk. * @@ -71,15 +97,14 @@ export class MessageEmitter extends BaseEmitter { timestamp?: string | number, subagentMeta?: SubagentMeta, ): Promise { - const epochMs = BaseEmitter.toEpochMs(timestamp); - const meta = { - ...subagentMeta, - ...(epochMs != null && { timestamp: epochMs }), - }; + const _meta = this.buildChunkMeta( + BaseEmitter.toEpochMs(timestamp), + subagentMeta, + ); await this.sendUpdate({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text }, - ...(Object.keys(meta).length > 0 && { _meta: meta }), + ...(_meta ? { _meta } : {}), }); } @@ -94,15 +119,14 @@ export class MessageEmitter extends BaseEmitter { timestamp?: string | number, subagentMeta?: SubagentMeta, ): Promise { - const epochMs = BaseEmitter.toEpochMs(timestamp); - const meta = { - ...subagentMeta, - ...(epochMs != null && { timestamp: epochMs }), - }; + const _meta = this.buildChunkMeta( + BaseEmitter.toEpochMs(timestamp), + subagentMeta, + ); await this.sendUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text }, - ...(Object.keys(meta).length > 0 && { _meta: meta }), + ...(_meta ? { _meta } : {}), }); } @@ -158,4 +182,20 @@ export class MessageEmitter extends BaseEmitter { ? this.emitAgentThought(text, timestamp, subagentMeta) : this.emitAgentMessage(text, timestamp, subagentMeta); } + + private buildChunkMeta( + epochMs: number | undefined, + subagentMeta?: SubagentMeta, + ): Record | undefined { + const meta: Record = { + ...(subagentMeta?.parentToolCallId + ? { parentToolCallId: subagentMeta.parentToolCallId } + : {}), + ...(subagentMeta?.subagentType + ? { subagentType: subagentMeta.subagentType } + : {}), + ...(epochMs != null ? { timestamp: epochMs } : {}), + }; + return Object.keys(meta).length > 0 ? meta : undefined; + } } diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts index 6acc302221..4b2e8a3698 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ToolCallEmitter } from './ToolCallEmitter.js'; -import type { SessionContext } from '../types.js'; +import type { SessionContext, SubagentMeta } from '../types.js'; import type { Config, ToolRegistry, @@ -77,7 +77,7 @@ describe('ToolCallEmitter', () => { locations: [], kind: 'other', rawInput: { arg1: 'value1' }, - _meta: { toolName: 'unknown_tool' }, + _meta: { toolName: 'unknown_tool', provenance: 'builtin' }, }); }); @@ -101,7 +101,7 @@ describe('ToolCallEmitter', () => { locations: [{ path: '/test/file.ts', line: 10 }], kind: 'edit', rawInput: { path: '/test.ts' }, - _meta: { toolName: 'edit_file' }, + _meta: { toolName: 'edit_file', provenance: 'builtin' }, }); }); @@ -125,7 +125,7 @@ describe('ToolCallEmitter', () => { expect(sendUpdateSpy).toHaveBeenCalledWith( expect.objectContaining({ rawInput: {}, - _meta: { toolName: 'test_tool' }, + _meta: { toolName: 'test_tool', provenance: 'builtin' }, }), ); }); @@ -153,7 +153,7 @@ describe('ToolCallEmitter', () => { locations: [], // Fallback to empty kind: 'other', // Fallback to other rawInput: { invalid: true }, - _meta: { toolName: 'failing_tool' }, + _meta: { toolName: 'failing_tool', provenance: 'builtin' }, }); }); }); @@ -174,7 +174,7 @@ describe('ToolCallEmitter', () => { toolCallId: 'call-123', status: 'completed', rawOutput: 'Tool completed successfully', - _meta: { toolName: 'test_tool' }, + _meta: { toolName: 'test_tool', provenance: 'builtin' }, }), ); }); @@ -198,7 +198,7 @@ describe('ToolCallEmitter', () => { content: { type: 'text', text: 'Something went wrong' }, }, ], - _meta: { toolName: 'test_tool' }, + _meta: { toolName: 'test_tool', provenance: 'builtin' }, }); }); @@ -228,7 +228,7 @@ describe('ToolCallEmitter', () => { newText: 'new content', }, ], - _meta: { toolName: 'edit_file' }, + _meta: { toolName: 'edit_file', provenance: 'builtin' }, }), ); }); @@ -263,7 +263,7 @@ describe('ToolCallEmitter', () => { }, }, ], - _meta: { toolName: 'edit_file' }, + _meta: { toolName: 'edit_file', provenance: 'builtin' }, }), ); }); @@ -289,7 +289,7 @@ describe('ToolCallEmitter', () => { }, ], rawOutput: 'raw output', - _meta: { toolName: 'test_tool' }, + _meta: { toolName: 'test_tool', provenance: 'builtin' }, }), ); }); @@ -307,7 +307,7 @@ describe('ToolCallEmitter', () => { toolCallId: 'call-empty', status: 'completed', content: [], - _meta: { toolName: 'test_tool' }, + _meta: { toolName: 'test_tool', provenance: 'builtin' }, }); }); @@ -399,7 +399,7 @@ describe('ToolCallEmitter', () => { content: { type: 'text', text: 'Connection timeout' }, }, ], - _meta: { toolName: 'test_tool' }, + _meta: { toolName: 'test_tool', provenance: 'builtin' }, }); }); }); @@ -543,7 +543,7 @@ describe('ToolCallEmitter', () => { }, ], rawOutput: { unknownField: 'value', nested: { data: 123 } }, - _meta: { toolName: 'test_tool' }, + _meta: { toolName: 'test_tool', provenance: 'builtin' }, }), ); }); @@ -565,7 +565,7 @@ describe('ToolCallEmitter', () => { toolCallId: 'call-extra', status: 'completed', rawOutput: 'Result text', - _meta: { toolName: 'test_tool' }, + _meta: { toolName: 'test_tool', provenance: 'builtin' }, }), ); }); @@ -580,7 +580,10 @@ describe('ToolCallEmitter', () => { const call = sendUpdateSpy.mock.calls[0][0]; expect(call.rawOutput).toBeUndefined(); - expect(call._meta).toEqual({ toolName: 'test_tool' }); + expect(call._meta).toEqual({ + toolName: 'test_tool', + provenance: 'builtin', + }); }); }); @@ -671,7 +674,7 @@ describe('ToolCallEmitter', () => { content: { type: 'text', text: 'Text content from message' }, }, ], - _meta: { toolName: 'test_tool' }, + _meta: { toolName: 'test_tool', provenance: 'builtin' }, }); }); @@ -703,10 +706,121 @@ describe('ToolCallEmitter', () => { }, ], rawOutput: 'raw result', - _meta: { toolName: 'test_tool' }, + _meta: { toolName: 'test_tool', provenance: 'builtin' }, }), ); }); }); }); + + describe('resolveToolProvenance (#4175 F4 prereq, chiga0 #19 P0)', () => { + // Pure static utility — exercise without an emitter instance. + it('classifies a plain tool name as builtin (no serverId)', () => { + const out = ToolCallEmitter.resolveToolProvenance('shell'); + expect(out).toEqual({ provenance: 'builtin' }); + }); + + it('classifies a tool name without mcp__ prefix as builtin', () => { + const out = ToolCallEmitter.resolveToolProvenance('read_file'); + expect(out).toEqual({ provenance: 'builtin' }); + }); + + it('classifies mcp____ as mcp with serverId', () => { + const out = ToolCallEmitter.resolveToolProvenance( + 'mcp__filesystem__read', + ); + expect(out).toEqual({ provenance: 'mcp', serverId: 'filesystem' }); + }); + + it('preserves underscores in the tool segment', () => { + // Server segment is `playwright`; tool segment is `take_screenshot` + // (with underscore inside the tool name — `split("__")` handles + // this because we split on the double-underscore delimiter). + const out = ToolCallEmitter.resolveToolProvenance( + 'mcp__playwright__take_screenshot', + ); + expect(out).toEqual({ provenance: 'mcp', serverId: 'playwright' }); + }); + + it('classifies malformed mcp__ prefix (only one segment) as builtin', () => { + // No double-underscore delimiter past the prefix → not a valid + // mcp tool name; fall back to builtin rather than stamping + // garbage serverId. + const out = ToolCallEmitter.resolveToolProvenance('mcp__just_one'); + expect(out).toEqual({ provenance: 'builtin' }); + }); + + it('classifies mcp____ as builtin (empty server segment)', () => { + const out = ToolCallEmitter.resolveToolProvenance('mcp____read'); + expect(out).toEqual({ provenance: 'builtin' }); + }); + + it('classifies any tool as subagent when subagentMeta is present', () => { + // subagent takes precedence over mcp__ naming — a sub-agent + // calling an MCP tool is rendered as "subagent block" not + // "MCP block" in the UI. + const out = ToolCallEmitter.resolveToolProvenance('mcp__fs__read', { + agentType: 'researcher', + } as unknown as SubagentMeta); + expect(out).toEqual({ provenance: 'subagent' }); + }); + + it('classifies a plain builtin tool with subagentMeta as subagent', () => { + const out = ToolCallEmitter.resolveToolProvenance('shell', { + agentType: 'coder', + } as unknown as SubagentMeta); + expect(out).toEqual({ provenance: 'subagent' }); + }); + }); + + describe('provenance stamping on emit (#4175 F4 prereq)', () => { + it('stamps provenance:mcp + serverId on emitStart for mcp__ tools', async () => { + await emitter.emitStart({ + toolName: 'mcp__github__create_issue', + callId: 'call-mcp', + args: { title: 'bug' }, + }); + expect(sendUpdateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sessionUpdate: 'tool_call', + _meta: expect.objectContaining({ + toolName: 'mcp__github__create_issue', + provenance: 'mcp', + serverId: 'github', + }), + }), + ); + }); + + it('stamps provenance:subagent (no serverId) when subagentMeta present', async () => { + await emitter.emitStart({ + toolName: 'shell', + callId: 'call-sub', + args: {}, + subagentMeta: { agentType: 'researcher' } as unknown as SubagentMeta, + }); + const call = sendUpdateSpy.mock.calls[0][0]; + expect(call._meta.provenance).toBe('subagent'); + expect(call._meta.serverId).toBeUndefined(); + }); + + it('stamps provenance on emitResult so reconnecting clients can re-derive it', async () => { + await emitter.emitResult({ + toolName: 'mcp__db__query', + callId: 'call-r', + success: true, + message: [], + }); + const call = sendUpdateSpy.mock.calls[0][0]; + expect(call._meta.provenance).toBe('mcp'); + expect(call._meta.serverId).toBe('db'); + }); + + it('stamps provenance on emitError as well', async () => { + await emitter.emitError('call-e', 'mcp__fs__write', new Error('boom')); + const call = sendUpdateSpy.mock.calls[0][0]; + expect(call._meta.provenance).toBe('mcp'); + expect(call._meta.serverId).toBe('fs'); + }); + }); }); diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts index 92f66ee474..0a24a1492b 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts @@ -22,6 +22,18 @@ import type { Part } from '@google/genai'; import { ToolNames, Kind } from '@qwen-code/qwen-code-core'; import { buildTruncatedDiffPreviewText } from '../../../utils/truncatedDiffPreview.js'; +const KIND_MAP: Record = { + [Kind.Read]: 'read', + [Kind.Edit]: 'edit', + [Kind.Delete]: 'delete', + [Kind.Move]: 'move', + [Kind.Search]: 'search', + [Kind.Execute]: 'execute', + [Kind.Think]: 'think', + [Kind.Fetch]: 'fetch', + [Kind.Other]: 'other', +}; + /** * Unified tool call event emitter. * @@ -57,6 +69,10 @@ export class ToolCallEmitter extends BaseEmitter { params.toolName, params.args, ); + const provenance = ToolCallEmitter.resolveToolProvenance( + params.toolName, + params.subagentMeta, + ); await this.sendUpdate({ sessionUpdate: 'tool_call', @@ -70,6 +86,8 @@ export class ToolCallEmitter extends BaseEmitter { _meta: { toolName: params.toolName, ...params.subagentMeta, + provenance: provenance.provenance, + ...(provenance.serverId ? { serverId: provenance.serverId } : {}), ...(BaseEmitter.toEpochMs(params.timestamp) != null && { timestamp: BaseEmitter.toEpochMs(params.timestamp), }), @@ -124,6 +142,10 @@ export class ToolCallEmitter extends BaseEmitter { } // Build the update + const provenance = ToolCallEmitter.resolveToolProvenance( + params.toolName, + params.subagentMeta, + ); const update: Parameters[0] = { sessionUpdate: 'tool_call_update', toolCallId: params.callId, @@ -132,6 +154,8 @@ export class ToolCallEmitter extends BaseEmitter { _meta: { toolName: params.toolName, ...params.subagentMeta, + provenance: provenance.provenance, + ...(provenance.serverId ? { serverId: provenance.serverId } : {}), ...(BaseEmitter.toEpochMs(params.timestamp) != null && { timestamp: BaseEmitter.toEpochMs(params.timestamp), }), @@ -161,6 +185,10 @@ export class ToolCallEmitter extends BaseEmitter { error: Error, subagentMeta?: SubagentMeta, ): Promise { + const provenance = ToolCallEmitter.resolveToolProvenance( + toolName, + subagentMeta, + ); await this.sendUpdate({ sessionUpdate: 'tool_call_update', toolCallId: callId, @@ -171,10 +199,55 @@ export class ToolCallEmitter extends BaseEmitter { _meta: { toolName, ...subagentMeta, + provenance: provenance.provenance, + ...(provenance.serverId ? { serverId: provenance.serverId } : {}), }, }); } + /** + * Resolve a tool's provenance for UI dispatch on tool_call events. + * The SDK reads `_meta. + * provenance` + `_meta.serverId` to render builtin / MCP-server-badge / + * subagent-block differently. Without this stamping, the SDK falls + * back to string-matching the toolName which can't reliably + * distinguish builtin from subagent. + * + * Resolution rules: + * - `subagentMeta` present → `'subagent'` (a Task tool / Codex + * subagent / etc. wrapping its own tool calls) + * - toolName matches `mcp____` → `'mcp'` with + * `serverId: `. Naming convention from + * `packages/core/src/tools/mcp-tool.ts` in the + * `@qwen-code/qwen-code-core` package — mirrors the SDK's same + * heuristic fallback so SDK consumers stay consistent with + * daemon classification. + * - everything else → `'builtin'` + * + * Static + pure so it can be unit-tested without an emitter + * instance. Exported via `ToolCallEmitter.resolveToolProvenance`. + */ + static resolveToolProvenance( + toolName: string, + subagentMeta?: SubagentMeta, + ): { provenance: 'builtin' | 'mcp' | 'subagent'; serverId?: string } { + if (subagentMeta !== undefined) { + return { provenance: 'subagent' }; + } + if (toolName.startsWith('mcp__')) { + // mcp____ — split is "__", not single "_", + // so server / tool segments can contain underscores. Require + // both a non-empty server segment and at least one segment past + // it; malformed names fall through to 'builtin' rather than + // stamping an empty/garbage serverId. + const parts = toolName.split('__'); + if (parts.length >= 3 && parts[1] && parts[1].length > 0) { + return { provenance: 'mcp', serverId: parts[1] }; + } + } + return { provenance: 'builtin' }; + } + // ==================== Public Utilities ==================== /** @@ -242,23 +315,10 @@ export class ToolCallEmitter extends BaseEmitter { * @param toolName - Optional tool name to handle special cases like exit_plan_mode */ mapToolKind(kind: Kind, toolName?: string): ToolKind { - // Special case: exit_plan_mode uses 'switch_mode' kind per ACP spec if (toolName && this.isExitPlanModeTool(toolName)) { return 'switch_mode'; } - - const kindMap: Record = { - [Kind.Read]: 'read', - [Kind.Edit]: 'edit', - [Kind.Delete]: 'delete', - [Kind.Move]: 'move', - [Kind.Search]: 'search', - [Kind.Execute]: 'execute', - [Kind.Think]: 'think', - [Kind.Fetch]: 'fetch', - [Kind.Other]: 'other', - }; - return kindMap[kind] ?? 'other'; + return KIND_MAP[kind] ?? 'other'; } // ==================== Private Helpers ==================== diff --git a/packages/cli/src/acp-integration/session/tasksSnapshot.ts b/packages/cli/src/acp-integration/session/tasksSnapshot.ts new file mode 100644 index 0000000000..fa156840c6 --- /dev/null +++ b/packages/cli/src/acp-integration/session/tasksSnapshot.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + buildBackgroundEntryLabel, + type AgentTask, + type Config, + type MonitorTask, + type ShellTask, +} from '@qwen-code/qwen-code-core'; +import { + STATUS_SCHEMA_VERSION, + type ServeSessionAgentTaskStatus, + type ServeSessionMonitorTaskStatus, + type ServeSessionShellTaskStatus, + type ServeSessionTaskStatus, + type ServeSessionTasksStatus, +} from '../../serve/status.js'; + +function runtimeMs( + entry: { startTime: number; endTime?: number }, + now: number, +): number { + return Math.max(0, (entry.endTime ?? now) - entry.startTime); +} + +/** Include `{key: value}` in a spread only when `value` is defined; empty object otherwise. */ +function optionalField( + key: K, + value: V | undefined, +): { [P in K]: V } | Record { + return value !== undefined + ? ({ [key]: value } as { [P in K]: V }) + : ({} as Record); +} + +function serializeAgentTask( + entry: AgentTask, + now: number, +): ServeSessionAgentTaskStatus { + return { + kind: 'agent', + id: entry.id, + label: buildBackgroundEntryLabel(entry), + description: entry.description, + status: entry.status, + startTime: entry.startTime, + runtimeMs: runtimeMs(entry, now), + outputFile: entry.outputFile, + ...optionalField('endTime', entry.endTime), + ...optionalField('subagentType', entry.subagentType), + isBackgrounded: entry.isBackgrounded, + ...optionalField('error', entry.error), + ...optionalField('resumeBlockedReason', entry.resumeBlockedReason), + ...optionalField('stats', entry.stats), + ...(entry.recentActivities && entry.recentActivities.length > 0 + ? { + recentActivities: entry.recentActivities.map((a) => ({ + name: a.name, + description: a.description, + at: a.at, + })), + } + : {}), + ...optionalField('prompt', entry.prompt), + }; +} + +function serializeShellTask( + entry: ShellTask, + now: number, +): ServeSessionShellTaskStatus { + return { + kind: 'shell', + id: entry.id, + label: entry.command, + description: entry.description, + status: entry.status, + startTime: entry.startTime, + runtimeMs: runtimeMs(entry, now), + outputFile: entry.outputFile, + command: entry.command, + cwd: entry.cwd, + ...optionalField('endTime', entry.endTime), + ...optionalField('pid', entry.pid), + ...optionalField('exitCode', entry.exitCode), + ...optionalField('error', entry.error), + }; +} + +function serializeMonitorTask( + entry: MonitorTask, + now: number, +): ServeSessionMonitorTaskStatus { + return { + kind: 'monitor', + id: entry.id, + label: entry.description, + description: entry.description, + status: entry.status, + startTime: entry.startTime, + runtimeMs: runtimeMs(entry, now), + command: entry.command, + eventCount: entry.eventCount, + lastEventTime: entry.lastEventTime, + droppedLines: entry.droppedLines, + ...optionalField('endTime', entry.endTime), + ...optionalField('pid', entry.pid), + ...optionalField('exitCode', entry.exitCode), + ...optionalField('error', entry.error), + ...optionalField('ownerAgentId', entry.ownerAgentId), + }; +} + +export function buildSessionTasksStatus( + sessionId: string, + config: Config, + now = Date.now(), +): ServeSessionTasksStatus { + const tasks: ServeSessionTaskStatus[] = [ + ...config + .getBackgroundTaskRegistry() + .getAll() + .map((entry) => serializeAgentTask(entry, now)), + ...config + .getBackgroundShellRegistry() + .getAll() + .map((entry) => serializeShellTask(entry, now)), + ...config + .getMonitorRegistry() + .getAll() + .map((entry) => serializeMonitorTask(entry, now)), + ].sort((a, b) => a.startTime - b.startTime); + + return { + v: STATUS_SCHEMA_VERSION, + sessionId, + now, + tasks, + }; +} diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 3e372e982b..ac87b9cb1b 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -24,8 +24,7 @@ import { HEADLESS_YOLO_NO_SANDBOX_WARNING } from '../utils/headlessSafetyWarning * listener is up so yargs `parse()` never resolves — if it did, the * top-level CLI would fall through to the interactive (TUI) entry point * in `gemini.tsx`. SIGINT / SIGTERM in `runQwenServe` is the sole exit - * route. Named so a future maintainer doesn't read the bare - * `new Promise(() => {})` as a bug (BRQQZ). + * route. */ function blockForever(): Promise { return new Promise(() => {}); @@ -46,6 +45,18 @@ interface ServeArgs { 'http-bridge': boolean; 'mcp-client-budget'?: number; 'mcp-budget-mode'?: 'enforce' | 'warn' | 'off'; + 'allow-origin'?: string[]; + 'allow-private-auth-base-url': boolean; + 'prompt-deadline-ms'?: number; + 'writer-idle-timeout-ms'?: number; + 'channel-idle-timeout-ms'?: number; + 'session-reap-interval-ms'?: number; + 'session-idle-timeout-ms'?: number; + 'rate-limit'?: boolean; + 'rate-limit-prompt'?: number; + 'rate-limit-mutation'?: number; + 'rate-limit-read'?: number; + 'rate-limit-window-ms'?: number; } export const serveCommand: CommandModule = { @@ -108,13 +119,13 @@ export const serveCommand: CommandModule = { }) .option('event-ring-size', { type: 'number', - // Single source of truth — `DEFAULT_RING_SIZE` (currently 8000, - // #3803 §02) is also what the bridge falls back to when the + // Single source of truth — `DEFAULT_RING_SIZE` is also what + // the bridge falls back to when the // option is undefined. Importing here keeps a future bump in // one place rather than drifting between CLI and bus. default: DEFAULT_RING_SIZE, description: - 'Per-session SSE replay ring depth (#3803 §02 target). Sets the ' + + 'Per-session SSE replay ring depth. Sets the ' + 'replay backlog available to `GET /session/:id/events` reconnects ' + 'that send a `Last-Event-ID: N` header. Larger = more reconnect ' + 'headroom at the cost of a few hundred KB extra RAM per session. ' + @@ -133,7 +144,7 @@ export const serveCommand: CommandModule = { type: 'number', description: 'Cap on live MCP clients spawned inside the ACP child for the bound ' + - 'workspace (issue #4175 PR 14). Positive integer. Combine with ' + + 'workspace. Positive integer. Combine with ' + '--mcp-budget-mode to control behavior at the cap. When unset, ' + 'mode defaults to off (no accounting-driven enforcement, but ' + 'GET /workspace/mcp still reports `clientCount`). Distinct from ' + @@ -143,12 +154,84 @@ export const serveCommand: CommandModule = { .option('mcp-budget-mode', { choices: ['enforce', 'warn', 'off'] as const, description: - 'How --mcp-client-budget is enforced (issue #4175 PR 14). ' + + 'How --mcp-client-budget is enforced. ' + '`warn` (default when budget set): no refusal, snapshot surfaces ' + 'warning at >=75% of budget. `enforce`: connects past the cap are ' + 'refused (`disabledReason: "budget"`, deterministic by mcpServers ' + 'declaration order). `off`: pure observability. Boot rejects ' + '`enforce` without a budget.', + }) + .option('allow-origin', { + type: 'string', + array: true, + description: 'Cross-origin allowlist for browser webui clients.', + }) + .option('allow-private-auth-base-url', { + type: 'boolean', + default: false, + description: + 'Allow /workspace/auth/provider to install localhost/private-network baseUrl values. ' + + 'Use only for local development with trusted clients.', + }) + .option('prompt-deadline-ms', { + type: 'number', + description: + 'Server-side wallclock cap on POST /session/:id/prompt (ms). ' + + 'Falls back to QWEN_SERVE_PROMPT_DEADLINE_MS. Positive integer.', + }) + .option('writer-idle-timeout-ms', { + type: 'number', + description: + 'Per-SSE-connection idle deadline (ms). ' + + 'Falls back to QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS. Positive integer.', + }) + .option('channel-idle-timeout-ms', { + type: 'number', + description: + 'Milliseconds to keep ACP child alive after last session closes. ' + + '0 or unset = immediate kill (default).', + }) + .option('session-reap-interval-ms', { + type: 'number', + description: + 'Session reaper scan interval (ms). 0 = disabled. Default: 60000.', + }) + .option('session-idle-timeout-ms', { + type: 'number', + description: + 'Idle timeout before a disconnected session is reaped (ms). ' + + '0 = disabled. Default: 1800000 (30 min).', + }) + .option('rate-limit', { + type: 'boolean', + description: + 'Enable per-tier HTTP rate limiting. Tiers: prompt (10/min), ' + + 'mutation (30/min), read (120/min). Health, heartbeat, SSE, ' + + 'and /acp are exempt.', + }) + .option('rate-limit-prompt', { + type: 'number', + description: + 'Max prompt requests per window per client (default 10). ' + + 'Requires --rate-limit.', + }) + .option('rate-limit-mutation', { + type: 'number', + description: + 'Max mutation requests per window per client (default 30). ' + + 'Requires --rate-limit.', + }) + .option('rate-limit-read', { + type: 'number', + description: + 'Max read requests per window per client (default 120). ' + + 'Requires --rate-limit.', + }) + .option('rate-limit-window-ms', { + type: 'number', + description: + 'Rate limit window duration in ms (default 60000). ' + + 'Requires --rate-limit.', }) as unknown as Argv, handler: async (argv) => { if (!argv['http-bridge']) { @@ -168,7 +251,7 @@ export const serveCommand: CommandModule = { 'deployment.', ); } - // PR 14: validate budget + mode combination at boot, before we + // Validate budget + mode combination at boot, before we // lazy-load the serve module. Yargs already constrains `choices` // for mcp-budget-mode, so we only have to police the budget value // and the `enforce` ⇒ budget invariant. @@ -195,7 +278,7 @@ export const serveCommand: CommandModule = { const resolvedMcpMode: 'enforce' | 'warn' | 'off' = mcpBudgetMode ?? (mcpClientBudget !== undefined ? 'warn' : 'off'); if (mcpClientBudget !== undefined) { - // Mirror PR 15's `--require-auth` breadcrumb: surface the active + // Mirror the `--require-auth` breadcrumb: surface the active // policy in stderr (journald / docker logs) so operators don't // have to parse /capabilities or /workspace/mcp to confirm it. writeStderrLine( @@ -239,6 +322,57 @@ export const serveCommand: CommandModule = { // path will report the same error to the user via Session. } + // Rate limit resolution: --rate-limit / --no-rate-limit override env var. + // With no default, argv['rate-limit'] is undefined when neither flag is passed. + const rateLimit = + argv['rate-limit'] ?? + (process.env['QWEN_SERVE_RATE_LIMIT'] === '1' || + process.env['QWEN_SERVE_RATE_LIMIT'] === 'true'); + let rateLimitPrompt: number | undefined; + let rateLimitMutation: number | undefined; + let rateLimitRead: number | undefined; + let rateLimitWindowMs: number | undefined; + if (rateLimit) { + const envInt = (key: string): number | undefined => { + const v = process.env[key]; + return v ? Number(v) : undefined; + }; + rateLimitPrompt = + argv['rate-limit-prompt'] ?? envInt('QWEN_SERVE_RATE_LIMIT_PROMPT'); + rateLimitMutation = + argv['rate-limit-mutation'] ?? envInt('QWEN_SERVE_RATE_LIMIT_MUTATION'); + rateLimitRead = + argv['rate-limit-read'] ?? envInt('QWEN_SERVE_RATE_LIMIT_READ'); + rateLimitWindowMs = + argv['rate-limit-window-ms'] ?? + envInt('QWEN_SERVE_RATE_LIMIT_WINDOW_MS'); + + for (const [name, value] of [ + ['--rate-limit-prompt', rateLimitPrompt], + ['--rate-limit-mutation', rateLimitMutation], + ['--rate-limit-read', rateLimitRead], + ] as const) { + if ( + value !== undefined && + (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) + ) { + writeStderrLine(`qwen serve: ${name} must be a positive integer.`); + process.exit(1); + } + } + if ( + rateLimitWindowMs !== undefined && + (!Number.isFinite(rateLimitWindowMs) || + !Number.isInteger(rateLimitWindowMs) || + rateLimitWindowMs < 1000) + ) { + writeStderrLine( + 'qwen serve: --rate-limit-window-ms must be an integer >= 1000.', + ); + process.exit(1); + } + } + // Lazy-load the serve module so non-serve invocations don't pay for // express + body-parser + qs in their startup path. const { runQwenServe } = await import('../serve/index.js'); @@ -253,8 +387,32 @@ export const serveCommand: CommandModule = { eventRingSize: argv['event-ring-size'], workspace: argv.workspace, requireAuth: argv['require-auth'], + allowPrivateAuthBaseUrl: argv['allow-private-auth-base-url'], mcpClientBudget, mcpBudgetMode: resolvedMcpMode, + ...(argv['allow-origin'] && argv['allow-origin'].length > 0 + ? { allowOrigins: argv['allow-origin'] } + : {}), + ...(argv['prompt-deadline-ms'] !== undefined + ? { promptDeadlineMs: argv['prompt-deadline-ms'] } + : {}), + ...(argv['writer-idle-timeout-ms'] !== undefined + ? { writerIdleTimeoutMs: argv['writer-idle-timeout-ms'] } + : {}), + ...(argv['channel-idle-timeout-ms'] !== undefined + ? { channelIdleTimeoutMs: argv['channel-idle-timeout-ms'] } + : {}), + ...(argv['session-reap-interval-ms'] !== undefined + ? { sessionReapIntervalMs: argv['session-reap-interval-ms'] } + : {}), + ...(argv['session-idle-timeout-ms'] !== undefined + ? { sessionIdleTimeoutMs: argv['session-idle-timeout-ms'] } + : {}), + ...(rateLimit ? { rateLimit: true } : {}), + ...(rateLimitPrompt !== undefined ? { rateLimitPrompt } : {}), + ...(rateLimitMutation !== undefined ? { rateLimitMutation } : {}), + ...(rateLimitRead !== undefined ? { rateLimitRead } : {}), + ...(rateLimitWindowMs !== undefined ? { rateLimitWindowMs } : {}), }); } catch (err) { writeStderrLine( diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a3c97a68b2..0e3186a6cc 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -37,6 +37,7 @@ import { } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import { hooksCommand } from '../commands/hooks.js'; +import { normalizeDisabledToolList } from './normalizeDisabledTools.js'; import type { LoadedSettings, Settings } from './settings.js'; import { loadSettings, SettingScope } from './settings.js'; import { @@ -1031,7 +1032,7 @@ export async function parseArguments(): Promise { .command(channelCommand) // Register /review skill helpers (presubmit checks, cleanup) .command(reviewCommand) - // Register `qwen serve` (Stage 1 daemon — see issue #3803) + // Register `qwen serve` (Stage 1 daemon) .command(serveCommand); yargsInstance @@ -1205,11 +1206,14 @@ function resolveMaxToolCalls(argv: CliArgs, settings: Settings): number { } export function isDebugMode(argv: CliArgs): boolean { + if (argv.debug) return true; + const debugVal = process.env['DEBUG']; + const debugModeVal = process.env['DEBUG_MODE']; return ( - argv.debug || - [process.env['DEBUG'], process.env['DEBUG_MODE']].some( - (v) => v === 'true' || v === '1', - ) + debugVal === 'true' || + debugVal === '1' || + debugModeVal === 'true' || + debugModeVal === '1' ); } @@ -1564,19 +1568,10 @@ export async function loadCliConfig( addDisabled(name); } - // Resolve the per-workspace tool denylist (#4175 Wave 4 PR 17). De-duplicate - // while preserving original casing; downstream lookups go through - // `Config.getDisabledTools()` which materializes a Set, so the order here - // is only meaningful for diagnostic output. - const disabledTools: string[] = []; - const seenDisabledTools = new Set(); - for (const raw of settings.tools?.disabled ?? []) { - if (typeof raw !== 'string') continue; - const trimmed = raw.trim(); - if (!trimmed || seenDisabledTools.has(trimmed)) continue; - seenDisabledTools.add(trimmed); - disabledTools.push(trimmed); - } + // Resolve the per-workspace tool denylist. De-duplicate while preserving + // original casing; shared helper since the MCP restart refresh path + // must agree byte-for-byte with this. + const disabledTools = normalizeDisabledToolList(settings.tools?.disabled); // Helper: check if a tool is explicitly covered by an allow rule OR by the // coreTools whitelist. Uses alias matching for coreTools (via isToolEnabled) diff --git a/packages/cli/src/config/normalizeDisabledTools.test.ts b/packages/cli/src/config/normalizeDisabledTools.test.ts new file mode 100644 index 0000000000..e92faa2193 --- /dev/null +++ b/packages/cli/src/config/normalizeDisabledTools.test.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { normalizeDisabledToolList } from './normalizeDisabledTools.js'; + +describe('normalizeDisabledToolList', () => { + describe('non-array short-circuit', () => { + it('returns [] for undefined', () => { + expect(normalizeDisabledToolList(undefined)).toEqual([]); + }); + it('returns [] for null', () => { + expect(normalizeDisabledToolList(null)).toEqual([]); + }); + it('returns [] for a plain object', () => { + expect(normalizeDisabledToolList({ 0: 'Foo' })).toEqual([]); + }); + it('returns [] for a number', () => { + expect(normalizeDisabledToolList(42)).toEqual([]); + }); + it('returns [] for a string', () => { + expect(normalizeDisabledToolList('Foo')).toEqual([]); + }); + it('returns [] for a boolean', () => { + expect(normalizeDisabledToolList(true)).toEqual([]); + }); + }); + + describe('typeof-string filter', () => { + it('drops non-string entries individually without aborting', () => { + expect( + normalizeDisabledToolList([ + 42, + 'Foo', + null, + 'Bar', + { name: 'Baz' }, + true, + 'Qux', + ]), + ).toEqual(['Foo', 'Bar', 'Qux']); + }); + }); + + describe('trim + empty-skip', () => { + it('trims surrounding whitespace', () => { + expect(normalizeDisabledToolList([' Foo ', '\tBar\n'])).toEqual([ + 'Foo', + 'Bar', + ]); + }); + it('drops empty-after-trim entries', () => { + expect(normalizeDisabledToolList(['', ' ', '\t', '\n', 'Foo'])).toEqual([ + 'Foo', + ]); + }); + it('returns [] when every entry is whitespace-only', () => { + expect(normalizeDisabledToolList(['', ' ', '\t', '\n'])).toEqual([]); + }); + }); + + describe('dedupe', () => { + it('removes exact duplicates, preserving first-occurrence order', () => { + expect(normalizeDisabledToolList(['Foo', 'Bar', 'Foo', 'Baz'])).toEqual([ + 'Foo', + 'Bar', + 'Baz', + ]); + }); + it('dedupes after trim — whitespace variants collapse', () => { + expect(normalizeDisabledToolList(['Foo', ' Foo', 'Foo '])).toEqual([ + 'Foo', + ]); + }); + it('does NOT case-fold — `Foo` and `foo` stay distinct', () => { + expect(normalizeDisabledToolList(['Foo', 'foo', 'FOO'])).toEqual([ + 'Foo', + 'foo', + 'FOO', + ]); + }); + }); + + describe('boot/restart parity scenarios (BkwQW class — wenshao #4329)', () => { + it("['Foo', ' Foo ', ''] → ['Foo'] (the bug that the helper was extracted to prevent)", () => { + // Pre-extraction, this scenario was handled at boot (config.ts) but + // the MCP restart path (acpAgent.ts) had only typeof-string filter. + // After fold-in, both call sites share this helper so a hand-edited + // `tools.disabled: [' Foo ']` produces Set(['Foo']) at boot AND + // after every subsequent MCP restart. + expect(normalizeDisabledToolList(['Foo', ' Foo ', ''])).toEqual([ + 'Foo', + ]); + }); + + it('mixed real-world settings — typo + extra whitespace + dup', () => { + expect( + normalizeDisabledToolList([ + 'ShellTool', + 'WebFetch', + ' ShellTool', // typo: extra space + '', // operator pressed Enter + 'WebFetch ', // trailing whitespace + ]), + ).toEqual(['ShellTool', 'WebFetch']); + }); + }); + + describe('order preservation', () => { + it('first-occurrence order survives dedupe + trim', () => { + expect( + normalizeDisabledToolList(['Zebra', 'Apple', ' Zebra', 'Banana']), + ).toEqual(['Zebra', 'Apple', 'Banana']); + }); + }); +}); diff --git a/packages/cli/src/config/normalizeDisabledTools.ts b/packages/cli/src/config/normalizeDisabledTools.ts new file mode 100644 index 0000000000..edc6602afa --- /dev/null +++ b/packages/cli/src/config/normalizeDisabledTools.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Normalize a raw `tools.disabled` settings array into the canonical + * deduplicated list the agent / restart paths share. + * + * Boot path (`cli/src/config/config.ts`'s `disabledTools` array + * construction) and MCP restart refresh path + * (`cli/src/acp-integration/acpAgent.ts` post-`restartMcpServer` + * settings refresh) must agree byte-for-byte on what counts as + * "disabled" — without that agreement, `ToolRegistry.has(tool.name)` + * exact-match check silently re-registers tools whose disabled-name + * carries whitespace (e.g., `' Foo '` typed in settings.json by hand). + * + * Lifted from inline implementations so boot path and MCP restart + * refresh path share a single implementation. + * + * Behavior contract: + * + * 1. Non-array `raw` (object / number / boolean / null / undefined) + * → return `[]`. + * 2. Non-string entries inside the array → skipped individually + * (does NOT abort the whole list — e.g., `[42, 'Foo', null]` → `['Foo']`). + * 3. Each string entry is `.trim()`-ed. + * 4. Empty-after-trim entries (`''`, `' '`, `'\n'`, `'\t'`) → skipped. + * 5. Duplicates de-duped, preserving first-occurrence order. + * Downstream callers materialize the result to `Set` + * so order is only meaningful for diagnostic output today, + * but this helper preserves it for any future order-sensitive + * consumer. + * + * The helper does NOT case-fold (e.g., `'Foo'` vs `'foo'` remain + * distinct) — Stage 1 tool names are case-sensitive throughout + * `ToolRegistry`, so case-folding here would silently break tool + * lookups elsewhere. Unicode normalization (`String.prototype.normalize`) + * is similarly out of scope; if a user pastes a combining-form vs + * precomposed-form variant they want collapsed, that's a separate + * decision tracked under workspace settings UX. + */ +export function normalizeDisabledToolList(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const out: string[] = []; + const seen = new Set(); + for (const entry of raw) { + if (typeof entry !== 'string') continue; + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + return out; +} diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 2459b7df68..2cfe1b5c86 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -91,6 +91,32 @@ const PROJECT_ENV_HARDCODED_EXCLUSIONS = [ ENV_WAS_RECOVERED, ]; +const RELOAD_EXCLUDED_KEYS = new Set([ + ...PROJECT_ENV_HARDCODED_EXCLUSIONS, + 'QWEN_SERVER_TOKEN', + 'QWEN_CLI_ENTRY', + 'NODE_OPTIONS', + 'NODE_PATH', + 'NODE_TLS_REJECT_UNAUTHORIZED', + 'LD_PRELOAD', + 'LD_AUDIT', + 'LD_LIBRARY_PATH', + 'DYLD_INSERT_LIBRARIES', + 'DYLD_LIBRARY_PATH', + 'BASH_ENV', + 'ENV', + 'PATH', + 'HOME', + 'TMPDIR', + 'TMP', + 'TEMP', +]); + +const dotEnvSourcedKeys = new Set(); +const settingsEnvSourcedKeys = new Set(); +const lastReloadSnapshot = new Map(); +let lastReloadSnapshotSeeded = false; + // Settings version to track migration state export const SETTINGS_VERSION = 4; export const SETTINGS_VERSION_KEY = '$version'; @@ -879,6 +905,12 @@ export function loadEnvironment(settings: Settings): void { if (!Object.hasOwn(process.env, key)) { process.env[key] = parsedEnv[key]; + dotEnvSourcedKeys.add(key); + } + // Seed snapshot with ALL parsed keys (not just written ones) + // so child processes can detect deletions on first reload. + if (!lastReloadSnapshotSeeded) { + lastReloadSnapshot.set(key, parsedEnv[key]!); } } } @@ -897,9 +929,158 @@ export function loadEnvironment(settings: Settings): void { } if (!Object.hasOwn(process.env, key) && typeof value === 'string') { process.env[key] = value; + settingsEnvSourcedKeys.add(key); + } + if ( + !lastReloadSnapshotSeeded && + typeof value === 'string' && + !lastReloadSnapshot.has(key) + ) { + lastReloadSnapshot.set(key, value); } } } + lastReloadSnapshotSeeded = true; +} + +export interface EnvReloadResult { + updatedKeys: string[]; + removedKeys: string[]; +} + +/** + * Only keys previously set by loadEnvironment() are overwritten; + * shell-exported variables are never touched. + * Fully synchronous — no TOCTOU window between delete and re-add. + */ +export function reloadEnvironment( + settings: Settings, + workspaceCwd: string, +): EnvReloadResult { + const userLevelPaths = getUserLevelEnvPaths(); + const envFilePath = findEnvFile(settings, workspaceCwd, userLevelPaths); + + if (process.env['CLOUD_SHELL'] === 'true') { + setUpCloudShellEnvironment(envFilePath); + } + + // Build the set of new keys from .env (higher priority) + settings.env + let dotEnvReadFailed = false; + const newDotEnvKeys = new Map(); + const newSettingsEnvKeys = new Map(); + + if (envFilePath) { + try { + const envFileContent = fs.readFileSync(envFilePath, 'utf-8'); + const parsedEnv = dotenv.parse(envFileContent); + const excludedVars = + settings?.advanced?.excludedEnvVars || DEFAULT_EXCLUDED_ENV_VARS; + const normalizedEnvFilePath = path.normalize(envFilePath); + const isHomeScopedEnvFile = userLevelPaths.has(normalizedEnvFilePath); + const isQwenScopedEnvFile = + isHomeScopedEnvFile || + path.basename(path.dirname(normalizedEnvFilePath)) === QWEN_DIR; + + for (const key in parsedEnv) { + if (!Object.hasOwn(parsedEnv, key)) continue; + if (RELOAD_EXCLUDED_KEYS.has(key)) continue; + if ( + !isHomeScopedEnvFile && + PROJECT_ENV_HARDCODED_EXCLUSIONS.includes(key) + ) { + continue; + } + if (!isQwenScopedEnvFile && excludedVars.includes(key)) continue; + newDotEnvKeys.set(key, parsedEnv[key]!); + } + } catch { + dotEnvReadFailed = true; + } + } + + if (settings.env) { + for (const [key, value] of Object.entries(settings.env)) { + if (RELOAD_EXCLUDED_KEYS.has(key)) continue; + if (PROJECT_ENV_HARDCODED_EXCLUSIONS.includes(key)) continue; + if (typeof value !== 'string') continue; + if (newDotEnvKeys.has(key)) continue; + // When .env read failed, use the snapshot as the shadow set so + // settings.env keys that were previously shadowed by .env don't + // accidentally overwrite the still-live .env values in process.env. + if (dotEnvReadFailed && lastReloadSnapshot.has(key)) continue; + newSettingsEnvKeys.set(key, value); + } + } + + // Union of all new keys + const allNewKeys = new Set([ + ...newDotEnvKeys.keys(), + ...newSettingsEnvKeys.keys(), + ]); + + const updatedKeys: string[] = []; + const removedKeys: string[] = []; + + // Delete keys previously known (from tracking Sets OR the boot snapshot) + // that are no longer in any source file. The snapshot covers keys that + // ACP children inherited from the daemon without tracking. + // Skip deletion entirely if the .env file became unreadable — treat as + // transient I/O failure rather than intentional key removal. + if (!dotEnvReadFailed) { + const previouslyKnown = new Set([ + ...lastReloadSnapshot.keys(), + ...dotEnvSourcedKeys, + ...settingsEnvSourcedKeys, + ]); + for (const key of previouslyKnown) { + if (!allNewKeys.has(key) && !RELOAD_EXCLUDED_KEYS.has(key)) { + delete process.env[key]; + removedKeys.push(key); + } + } + } + + // Force-write all source keys. RELOAD_EXCLUDED_KEYS are already filtered + // at parse time so dangerous keys (PATH, HOME, etc.) never reach here. + // This unconditional write is necessary because ACP children inherit + // daemon env without tracking, so the tracking-based guard would miss them. + for (const [key, value] of newDotEnvKeys) { + if (process.env[key] !== value) { + updatedKeys.push(key); + } + process.env[key] = value; + } + for (const [key, value] of newSettingsEnvKeys) { + if (process.env[key] !== value) { + updatedKeys.push(key); + } + process.env[key] = value; + } + + // Update tracking sets and snapshot only when the .env file was readable. + // A transient read failure must not wipe provenance — the stale tracking + // state is needed so the next successful reload can still detect deletions. + if (!dotEnvReadFailed) { + dotEnvSourcedKeys.clear(); + for (const key of newDotEnvKeys.keys()) { + dotEnvSourcedKeys.add(key); + } + lastReloadSnapshot.clear(); + for (const [key, value] of newDotEnvKeys) { + lastReloadSnapshot.set(key, value); + } + for (const [key, value] of newSettingsEnvKeys) { + lastReloadSnapshot.set(key, value); + } + } + // settings.env is always readable (from settings.json, not a file), + // so its tracking set is always updated. + settingsEnvSourcedKeys.clear(); + for (const key of newSettingsEnvKeys.keys()) { + settingsEnvSourcedKeys.add(key); + } + + return { updatedKeys, removedKeys }; } export const CORRUPTED_SUFFIX = '.corrupted'; @@ -908,10 +1089,19 @@ export const CORRUPTED_SUFFIX = '.corrupted'; * Load and merge settings from all scopes: * System Defaults → User (~/.qwen/settings.json) → Workspace → System. */ +export interface LoadSettingsOptions { + consumeCorruptionEnvVars?: boolean; + skipLoadEnvironment?: boolean; +} + export function loadSettings( workspaceDir: string = process.cwd(), - consumeCorruptionEnvVars: boolean = true, + consumeCorruptionEnvVars: boolean | LoadSettingsOptions = true, ): LoadedSettings { + const opts: LoadSettingsOptions = + typeof consumeCorruptionEnvVars === 'object' + ? consumeCorruptionEnvVars + : { consumeCorruptionEnvVars }; // Apply any QWEN_HOME / QWEN_RUNTIME_DIR set in user-level `.env` files // BEFORE any code reads a path derived from them. After this call, the // lazy `getUserSettingsPath()` / `Storage.getGlobalQwenDir()` getters @@ -1052,7 +1242,7 @@ export function loadSettings( // don't re-trigger this path. const envCorruptedPath = process.env[ENV_CORRUPTED_PATH]; if ( - consumeCorruptionEnvVars && + (opts.consumeCorruptionEnvVars ?? true) && envCorruptedPath && envCorruptedPath === corruptedPath && scope === SettingScope.User @@ -1247,7 +1437,9 @@ export function loadSettings( // loadEnviroment depends on settings so we have to create a temp version of // the settings to avoid a cycle - loadEnvironment(tempMergedSettings); + if (!opts.skipLoadEnvironment) { + loadEnvironment(tempMergedSettings); + } // Create LoadedSettings first diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 0e01c2d56c..85668687e3 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1987,6 +1987,81 @@ const SETTINGS_SCHEMA = { }, }, + policy: { + type: 'object', + label: 'Daemon Policy', + category: 'Daemon', + requiresRestart: true, + default: {}, + description: + 'Daemon multi-client coordination policies. Tool-level allow/deny rules ' + + 'live under `permissions`; this section is for runtime mediation behavior ' + + 'between concurrent HTTP clients sharing one `qwen serve` daemon.', + showInDialog: false, + properties: { + permissionStrategy: { + type: 'enum', + label: 'Permission Mediation Policy', + category: 'Daemon', + requiresRestart: true, + default: 'first-responder', + description: + 'How permission requests resolve when multiple clients are attached. ' + + '`first-responder` (default) = any client decides, first wins. ' + + '`designated` = only the prompt originator decides; falls back to ' + + 'first-responder if originator is anonymous. ' + + 'NOTE: client identity comes from self-declared X-Qwen-Client-Id ' + + 'with no proof-of-possession (pair-token identity is not implemented yet), ' + + 'so any client observing originatorClientId on SSE frames can ' + + 'register with the same id and impersonate the originator. ' + + '`consensus` = N-of-M voters must agree. Default N=floor(M/2)+1, ' + + 'which means UNANIMITY for M=2 (quorum=2, both must agree) and ' + + 'supermajority for larger even M (M=4 → quorum=3; M=6 → quorum=4). ' + + 'For M=2 specifically, split votes resolve only via permissionTimeoutMs. ' + + '`local-only` = only loopback clients can RESOLVE; remote clients ' + + 'can still ABORT a pending permission via the cancel sentinel ' + + '({outcome:"cancelled"}) — cancel stays cross-policy for ' + + 'consistency. Strict-cancel-too deployments need a dedicated ' + + 'loopback-bound daemon. ' + + 'Requires daemon restart — read once at boot.', + showInDialog: true, + options: [ + { value: 'first-responder', label: 'First Responder' }, + { value: 'designated', label: 'Designated Originator' }, + { value: 'consensus', label: 'Consensus Quorum' }, + { value: 'local-only', label: 'Local Only' }, + ], + }, + consensusQuorum: { + type: 'number', + label: 'Consensus Quorum Override', + category: 'Daemon', + requiresRestart: true, + default: undefined as number | undefined, + description: + 'Optional fixed quorum size for consensus policy. Capped at M ' + + '(count of registered voters at request issue time) to prevent ' + + 'unreachable quorum. Unset = floor(M/2)+1. ' + + 'Requires daemon restart — read once at boot.', + showInDialog: false, + // runQwenServe.ts validates `Number.isInteger(n) && n >= 1` and + // refuses to boot otherwise. Override the generated schema so IDE + // (VSCode, JetBrains via JSON Schema) flags `0`, `-1`, `1.5` + // BEFORE the user restarts the daemon. The bare `type:'number'` + // mapping accepts all of these. + jsonSchemaOverride: { + type: 'integer', + minimum: 1, + description: + 'Optional fixed quorum size for consensus policy. Capped at M ' + + '(count of registered voters at request issue time) to prevent ' + + 'unreachable quorum. Unset = floor(M/2)+1. ' + + 'Requires daemon restart — read once at boot.', + }, + }, + }, + }, + mcp: { type: 'object', label: 'MCP', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 34cf5a9d14..c511011f07 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -620,6 +620,7 @@ export default { 'The name of the extension to update.', 'Either an extension name or --all must be provided': 'Either an extension name or --all must be provided', + 'List installed extensions': 'List installed extensions', 'Lists installed extensions.': 'Lists installed extensions.', 'Path:': 'Path:', 'Source:': 'Source:', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index fb2c4fe73e..84d4db3e88 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -545,6 +545,7 @@ export default { 'The name of the extension to update.': '要更新的擴展名稱。', 'Either an extension name or --all must be provided': '必須提供擴展名稱或 --all', + 'List installed extensions': '列出已安裝的擴展', 'Lists installed extensions.': '列出已安裝的擴展。', 'Path:': '路徑:', 'Source:': '來源:', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index a8a7a361de..b5f3495bb7 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -587,6 +587,7 @@ export default { 'The name of the extension to update.': '要更新的扩展名称。', 'Either an extension name or --all must be provided': '必须提供扩展名称或 --all', + 'List installed extensions': '列出已安装的扩展', 'Lists installed extensions.': '列出已安装的扩展。', 'Path:': '路径:', 'Source:': '来源:', diff --git a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts index e71475c318..0676daefe7 100644 --- a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts +++ b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts @@ -17,7 +17,6 @@ * - SystemController: initialize, interrupt, set_model, supported_commands, get_context_usage * - PermissionController: can_use_tool, set_permission_mode * - SdkMcpController: mcp_server_status (mcp_message handled via callback) - * - HookController: hook_callback * * Note: mcp_message requests are NOT routed through the dispatcher. CLI MCP * clients send messages via SdkMcpController.createSendSdkMcpMessage() callback. @@ -31,7 +30,6 @@ import type { IPendingRequestRegistry } from './controllers/baseController.js'; import { SystemController } from './controllers/systemController.js'; import { PermissionController } from './controllers/permissionController.js'; import { SdkMcpController } from './controllers/sdkMcpController.js'; -// import { HookController } from './controllers/hookController.js'; import type { CLIControlRequest, CLIControlResponse, @@ -72,7 +70,6 @@ export class ControlDispatcher implements IPendingRequestRegistry { readonly systemController: SystemController; readonly permissionController: PermissionController; readonly sdkMcpController: SdkMcpController; - // readonly hookController: HookController; // Central pending request registries private pendingIncomingRequests: Map = @@ -101,7 +98,6 @@ export class ControlDispatcher implements IPendingRequestRegistry { this, 'SdkMcpController', ); - // this.hookController = new HookController(context, this, 'HookController'); // Listen for main abort signal this.abortHandler = () => { @@ -273,7 +269,6 @@ export class ControlDispatcher implements IPendingRequestRegistry { this.systemController.cleanup(); this.permissionController.cleanup(); this.sdkMcpController.cleanup(); - // this.hookController.cleanup(); } /** @@ -390,9 +385,6 @@ export class ControlDispatcher implements IPendingRequestRegistry { case 'mcp_server_status': return this.sdkMcpController; - // case 'hook_callback': - // return this.hookController; - default: throw new Error(`Unknown control request subtype: ${subtype}`); } diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 20bf4f69d7..47685aada1 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -58,6 +58,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { ChatRecordingService: MockChatRecordingService, uiTelemetryService: { getMetrics: vi.fn(), + getMetricsForSession: vi.fn(), }, }; }); @@ -294,6 +295,9 @@ describe('runNonInteractive', () => { function setupMetricsMock(overrides?: Partial): void { const mockMetrics = createMockMetrics(overrides); vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + vi.mocked(uiTelemetryService.getMetricsForSession).mockReturnValue( + mockMetrics, + ); } async function* createStreamFromEvents( diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 252d8f2d9a..aa1c6e93a3 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -390,7 +390,7 @@ export const handleSlashCommand = async ( const sessionStats: SessionStatsState = { sessionId: config?.getSessionId(), sessionStartTime: new Date(), - metrics: uiTelemetryService.getMetrics(), + metrics: config ? uiTelemetryService.getMetricsForSession(config.getSessionId()) : uiTelemetryService.getMetrics(), lastPromptTokenCount: 0, promptCount: 1, }; diff --git a/packages/cli/src/serve/acpHttp/connectionRegistry.ts b/packages/cli/src/serve/acpHttp/connectionRegistry.ts new file mode 100644 index 0000000000..5313b8fe9e --- /dev/null +++ b/packages/cli/src/serve/acpHttp/connectionRegistry.ts @@ -0,0 +1,424 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { logSafe } from './jsonRpc.js'; +import type { TransportStream } from './transportStream.js'; + +/** + * Per-stream cap on frames buffered before the client attaches its SSE + * stream. Mirrors the EventBus's `maxQueued` backpressure cap so a client + * that drives requests without ever opening a stream can't grow daemon + * memory without bound. Oldest frames are dropped past the cap. + */ +const MAX_BUFFERED_FRAMES = 256; + +/** Default cap on concurrent live connections (mirrors a bounded resource). */ +const DEFAULT_MAX_CONNECTIONS = 64; + +/** + * Invoked when a session/connection tears down while an agent→client + * request (e.g. a permission prompt) is still outstanding, so the bridge + * isn't left blocked awaiting a vote that will never arrive. + */ +export type AbandonPendingFn = ( + req: PendingClientRequest, + clientId: string | undefined, +) => boolean; + +/** + * Best-effort bridge detach for a session's bridge-stamped clientId on + * teardown. Without it, `session/new`/`load`/`resume`-registered client ids + * stay visible in `knownClientIds()`/`votersForSession()` after the ACP + * connection is gone — skewing permission mediation + origin validation. + * ACP clients can't clean this up themselves (the id isn't on the wire). + */ +export type DetachSessionFn = ( + sessionId: string, + clientId: string | undefined, +) => void; + +/** + * Tracks one logical ACP-over-HTTP connection (RFD #721). A connection is + * minted at `initialize`, keyed by `Acp-Connection-Id`, and may host many + * sessions — each with its own session-scoped SSE stream. + */ +export interface SessionBinding { + sessionId: string; + /** + * The clientId the bridge STAMPED for this session at create/attach. + * The bridge ignores caller-supplied ids it has never issued and mints + * a fresh one (returned on `spawnOrAttach`/`loadSession`), so every + * later per-session call (`sendPrompt`, permission votes, …) must echo + * THIS id, not the connection's own — otherwise the bridge rejects it + * with "client id is not registered for session". + */ + clientId?: string; + /** Session-scoped SSE stream (the client's `GET /acp` with both headers). */ + stream?: TransportStream; + /** Frames emitted before the session stream attached, flushed on attach. */ + buffer: unknown[]; + /** + * Aborts the bridge event subscription tied to the CURRENT session + * stream. Replaced with a fresh controller on every re-attach — a + * controller, once aborted (on stream close), can never resume, so + * reusing it across reconnects would leave the new stream permanently + * event-starved. + */ + abort: AbortController; + /** + * Aborts the in-flight `session/prompt` for this session. Set by + * `handlePrompt` while a prompt runs; aborted on `session/cancel` and on + * session/connection teardown so a disconnecting client doesn't leave + * the agent burning model quota on a result nobody will read. + */ + promptAbort?: AbortController; +} + +/** An agent→client request awaiting the client's JSON-RPC response. */ +export interface PendingClientRequest { + sessionId: string; + /** Maps the JSON-RPC id we issued back to the bridge's permission id. */ + bridgeRequestId: string; + kind: 'permission'; +} + +export class AcpConnection { + readonly connectionId: string; + /** Connection-scoped SSE stream (the client's `GET /acp` with only the conn header). */ + connStream?: TransportStream; + /** Frames emitted before the connection stream attached, flushed on attach. */ + private readonly connBuffer: unknown[] = []; + readonly sessions = new Map(); + /** + * Sessions this connection created (`session/new`) or explicitly + * attached to (`session/load`/`resume`). Per-session operations + * (subscribe, prompt, cancel, …) are gated on membership here so one + * connection can't drive or eavesdrop on a session it never claimed. + */ + readonly ownedSessions = new Set(); + /** + * Sessions with an in-flight `session/close` (between the synchronous + * ownership-revoke and the bridge close + local teardown). `session/load` + * / `resume` reject for an id in this set so a close racing a re-load + * can't have its `finally` teardown destroy the freshly-loaded session. + */ + readonly closingSessions = new Set(); + /** Agent→client requests awaiting a client response, keyed by JSON-RPC id. */ + readonly pending = new Map(); + /** Daemon-issued client id reused across this connection's bridge calls. */ + readonly clientId: string; + /** + * True when the `initialize` POST arrived from a kernel-stamped loopback + * peer. Threaded into per-session bridge contexts so the `local-only` + * permission policy can gate votes by transport — mirrors the REST + * surface's `detectFromLoopback(req)`. NOT derived from forgeable + * headers (`X-Forwarded-For` etc). + */ + readonly fromLoopback: boolean; + /** + * Set by `destroy()`. An in-flight `session/new`/`load`/`resume` whose + * bridge call resolves AFTER teardown checks this to kill/detach the + * late-registered session, so a `DELETE` (or idle sweep) racing a spawn + * doesn't orphan a child process / phantom clientId. + */ + destroyed = false; + /** + * Grace-period reap timer armed when the connection-scoped SSE stream + * closes; cleared on reconnect (`attachConnStream`) or teardown. Avoids a + * dead connection locking its `ownedSessions` (and counting against + * `maxConnections`) for the full 30-min idle TTL. + */ + connGraceTimer?: ReturnType; + lastActiveMs: number = Date.now(); + private idCounter = 0; + + constructor( + connectionId: string | undefined, + fromLoopback: boolean, + private readonly onAbandonPending?: AbandonPendingFn, + private readonly onDetachSession?: DetachSessionFn, + ) { + this.connectionId = connectionId ?? randomUUID(); + this.clientId = randomUUID(); + this.fromLoopback = fromLoopback; + } + + /** + * Allocate a fresh JSON-RPC id for an agent→client request. STRING-typed + * (`_qwen_perm_N`) so it can never collide with a client-originated id — + * JSON-RPC 2.0 permits clients to use any number (incl. negatives) or + * string, so a numeric namespace wasn't actually safe. + */ + nextId(): string { + this.idCounter += 1; + return `_qwen_perm_${this.idCounter}`; + } + + touch(): void { + this.lastActiveMs = Date.now(); + } + + ownSession(sessionId: string): void { + this.ownedSessions.add(sessionId); + } + + ownsSession(sessionId: string): boolean { + return this.ownedSessions.has(sessionId); + } + + getOrCreateSession(sessionId: string): SessionBinding { + let binding = this.sessions.get(sessionId); + if (!binding) { + binding = { sessionId, abort: new AbortController(), buffer: [] }; + this.sessions.set(sessionId, binding); + } + return binding; + } + + /** Send a frame on the connection-scoped stream (buffer until it attaches). */ + sendConn(frame: unknown): void { + if (this.connStream && !this.connStream.isClosed) { + void this.connStream.send(frame); + } else { + pushCapped(this.connBuffer, frame, `conn ${this.connectionId}`); + } + } + + /** True if any session currently has a live (open) SSE stream. */ + hasLiveSessionStream(): boolean { + for (const b of this.sessions.values()) { + if (b.stream && !b.stream.isClosed) return true; + } + return false; + } + + /** Cancel a pending grace-period reap (e.g. on conn-stream reconnect). */ + clearGraceTimer(): void { + if (this.connGraceTimer) { + clearTimeout(this.connGraceTimer); + this.connGraceTimer = undefined; + } + } + + /** Attach the connection-scoped stream and flush any buffered frames. */ + attachConnStream(stream: TransportStream): void { + // A reconnect cancels any pending grace-period reap. + this.clearGraceTimer(); + // Close any prior connection stream so its heartbeat interval + socket + // don't leak when a client reconnects the connection-scoped GET. + if (this.connStream && this.connStream !== stream) this.connStream.close(); + this.connStream = stream; + for (const frame of this.connBuffer.splice(0)) void stream.send(frame); + } + + /** + * Send a frame on a session-scoped stream (buffer until it attaches). + * LOOKUP-ONLY: drops the frame when the session has no binding — a binding + * always exists for a live session (created at `session/new`/`load`/ + * `resume`), so a missing one means the session was torn down. Auto- + * creating here would resurrect a ghost binding (no stream, no owner) that + * buffers up to 256 late pump/reply frames forever. + */ + sendSession(sessionId: string, frame: unknown): void { + const binding = this.sessions.get(sessionId); + if (!binding) return; + if (binding.stream && !binding.stream.isClosed) { + void binding.stream.send(frame); + } else { + pushCapped(binding.buffer, frame, `session ${sessionId}`); + } + } + + /** + * Attach a session-scoped stream: close any prior stream, abort the prior + * subscription, install the caller's FRESH AbortController (the old one is + * aborted and can never resume — reusing it would leave the new stream + * event-starved), flush buffered frames, and return the binding. + */ + attachSessionStream( + sessionId: string, + stream: TransportStream, + abort: AbortController, + ): SessionBinding { + const binding = this.getOrCreateSession(sessionId); + const prevStream = binding.stream; + binding.abort.abort(); + binding.abort = abort; + // Install the NEW stream BEFORE closing the old one. The old stream's + // `onClose` is identity-guarded on `binding.stream` (see the session-GET + // handler in `index.ts` — `if (conn.sessions.get(sessionId)?.stream === + // stream) ...promptAbort?.abort()`), so installing first means a + // reconnect's close can't abort the in-flight prompt (the client is + // reconnecting, not leaving — the prompt must survive). CONTRACT: that + // identity guard and this ordering must stay in lockstep. + binding.stream = stream; + if (prevStream && prevStream !== stream && prevStream !== this.connStream) { + prevStream.close(); + } + for (const frame of binding.buffer.splice(0)) void stream.send(frame); + return binding; + } + + closeSessionStream(sessionId: string): void { + const binding = this.sessions.get(sessionId); + if (!binding) return; + this.teardownBinding(binding); + this.sessions.delete(sessionId); + this.ownedSessions.delete(sessionId); + } + + destroy(): void { + this.destroyed = true; + this.clearGraceTimer(); + for (const binding of this.sessions.values()) { + try { + this.teardownBinding(binding); + } catch (err) { + writeStderrLine( + `qwen serve: /acp teardownBinding(${logSafe(binding.sessionId)}) failed during destroy: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + this.sessions.clear(); + this.ownedSessions.clear(); + this.pending.clear(); + this.connStream?.close(); + } + + private teardownBinding(binding: SessionBinding): void { + binding.abort.abort(); + binding.promptAbort?.abort(); + // Don't close the stream if it's the shared connStream (WS reuses + // one socket for all sessions — closing it kills the entire connection). + if (binding.stream && binding.stream !== this.connStream) { + binding.stream.close(); + } + this.abandonPendingForSession(binding.sessionId, binding.clientId); + this.onDetachSession?.(binding.sessionId, binding.clientId); + } + + /** + * Cancel + drop any pending agent→client requests for a closing session. + * This is the LAST-RESORT recovery path: `resolveClientResponse` retains a + * pending entry on double-failure (vote AND cancel both threw) precisely so + * this teardown sweep can retry the cancel. We always drop the entry here + * (the connection is going away — there is no further retry after teardown), + * but if the cancel itself still fails (triple-failure) the bridge mediator + * may be stuck awaiting a vote that will never arrive, so log it for the + * operator rather than failing silently. + */ + private abandonPendingForSession( + sessionId: string, + clientId: string | undefined, + ): void { + for (const [id, req] of this.pending) { + if (req.sessionId !== sessionId) continue; + this.pending.delete(id); + const cancelled = this.onAbandonPending?.(req, clientId) ?? true; + if (!cancelled) { + writeStderrLine( + `qwen serve: /acp MEDIATOR STUCK: abandonPendingForSession(${logSafe(sessionId)}) cancel failed for ${logSafe(req.bridgeRequestId)}`, + ); + } + } + } +} + +function pushCapped(buf: unknown[], frame: unknown, label = 'stream'): void { + if (buf.length >= MAX_BUFFERED_FRAMES) { + buf.shift(); + writeStderrLine( + `qwen serve: /acp pre-attach buffer full (${label}), dropped oldest frame`, + ); + } + buf.push(frame); +} + +/** + * Registry of live ACP connections with an idle-TTL sweep. The sweep is + * defensive: a well-behaved client `DELETE /acp`s, but a crashed client + * that never closes its streams would otherwise leak connection state. + */ +export class ConnectionRegistry { + private readonly byId = new Map(); + private readonly sweepTimer: ReturnType; + + constructor( + private readonly onAbandonPending?: AbandonPendingFn, + private readonly onDetachSession?: DetachSessionFn, + private readonly maxConnections = DEFAULT_MAX_CONNECTIONS, + private readonly idleTtlMs = 30 * 60_000, + ) { + this.sweepTimer = setInterval(() => this.sweep(), 60_000); + this.sweepTimer.unref(); + } + + /** + * Mint a connection, or return `undefined` when the live-connection cap + * is reached (the caller answers `503`). Bounds an `initialize` flood from + * growing the registry without limit through the full TTL window. + */ + create(fromLoopback: boolean): AcpConnection | undefined { + if (this.maxConnections > 0 && this.byId.size >= this.maxConnections) { + return undefined; + } + const conn = new AcpConnection( + undefined, + fromLoopback, + this.onAbandonPending, + this.onDetachSession, + ); + this.byId.set(conn.connectionId, conn); + return conn; + } + + get(connectionId: string | undefined): AcpConnection | undefined { + if (!connectionId) return undefined; + const conn = this.byId.get(connectionId); + conn?.touch(); + return conn; + } + + delete(connectionId: string): boolean { + const conn = this.byId.get(connectionId); + if (!conn) return false; + conn.destroy(); + return this.byId.delete(connectionId); + } + + get size(): number { + return this.byId.size; + } + + /** The configured concurrent-connection cap (for operator-facing logs). */ + get connectionCap(): number { + return this.maxConnections; + } + + dispose(): void { + clearInterval(this.sweepTimer); + for (const id of [...this.byId.keys()]) this.delete(id); + } + + private sweep(): void { + const cutoff = Date.now() - this.idleTtlMs; + for (const [id, conn] of this.byId) { + if (conn.lastActiveMs >= cutoff) continue; + // Observability: a reaped connection silently dropping its SSE + // streams is otherwise invisible to operators chasing "my client + // froze". Note that `touch()` fires on inbound HTTP AND on event + // delivery (pumpSessionEvents), so a long quiet prompt isn't reaped. + writeStderrLine( + `qwen serve: /acp reaping idle connection ${id} ` + + `(idle > ${Math.round(this.idleTtlMs / 60_000)}m, ` + + `${conn.sessions.size} session(s))`, + ); + this.delete(id); + } + } +} diff --git a/packages/cli/src/serve/acpHttp/dispatch.ts b/packages/cli/src/serve/acpHttp/dispatch.ts new file mode 100644 index 0000000000..2eca63e79c --- /dev/null +++ b/packages/cli/src/serve/acpHttp/dispatch.ts @@ -0,0 +1,2345 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { + APPROVAL_MODES, + type ApprovalMode, + BTW_MAX_INPUT_LENGTH, + SessionService, + BuiltinAgentRegistry, + SubagentError, + WorkspaceMemoryFileTooLargeError, + WorkspaceMemoryWriteTimeoutError, + writeWorkspaceContextFile, + type SubagentLevel, +} from '@qwen-code/qwen-code-core'; +import { FsError } from '../fs/errors.js'; +import { + TooManyActiveDeviceFlowsError, + UnsupportedDeviceFlowProviderError, + UpstreamDeviceFlowError, +} from '../auth/deviceFlow.js'; +import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; +import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { MAX_WORKSPACE_PATH_LENGTH } from '../fs/paths.js'; +import type { WorkspaceFileSystemFactory } from '../fs/index.js'; +import type { DeviceFlowRegistry } from '../auth/deviceFlow.js'; +import { collectWorkspaceMemoryStatus } from '../workspaceMemory.js'; +import { + createDaemonSubagentManager, + toSummary as agentToSummary, + toDetail as agentToDetail, +} from '../workspaceAgents.js'; +import { + InvalidCursorError, + listWorkspaceSessionsForResponse, +} from '../server.js'; +import type { + DaemonWorkspaceService, + WorkspaceRequestContext, +} from '../workspace-service/types.js'; +import type { AcpConnection } from './connectionRegistry.js'; +import { + QWEN_META_KEY, + QWEN_METHOD_NS, + RPC, + error, + isNotification, + isObject, + isRequest, + isResponse, + logSafe, + notification, + request, + success, + type JsonRpcId, + type JsonRpcInbound, + type JsonRpcRequest, + type JsonRpcResponse, +} from './jsonRpc.js'; + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +const QWEN_VENDOR_METHODS: readonly string[] = [ + `${QWEN_METHOD_NS}session/heartbeat`, + `${QWEN_METHOD_NS}session/context`, + `${QWEN_METHOD_NS}session/supported_commands`, + `${QWEN_METHOD_NS}session/update_metadata`, + `${QWEN_METHOD_NS}workspace/mcp`, + `${QWEN_METHOD_NS}workspace/skills`, + `${QWEN_METHOD_NS}workspace/providers`, + `${QWEN_METHOD_NS}workspace/env`, + `${QWEN_METHOD_NS}workspace/preflight`, + `${QWEN_METHOD_NS}workspace/init`, + `${QWEN_METHOD_NS}workspace/set_tool_enabled`, + `${QWEN_METHOD_NS}workspace/restart_mcp_server`, + // Wave 1: session extensions + `${QWEN_METHOD_NS}session/recap`, + `${QWEN_METHOD_NS}session/btw`, + `${QWEN_METHOD_NS}session/shell`, + `${QWEN_METHOD_NS}session/detach`, + `${QWEN_METHOD_NS}session/context_usage`, + `${QWEN_METHOD_NS}session/tasks`, + // Wave 1: memory + `${QWEN_METHOD_NS}workspace/memory`, + `${QWEN_METHOD_NS}workspace/memory/write`, + // Wave 1: files + `${QWEN_METHOD_NS}file/read`, + `${QWEN_METHOD_NS}file/read_bytes`, + `${QWEN_METHOD_NS}file/stat`, + `${QWEN_METHOD_NS}file/list`, + `${QWEN_METHOD_NS}file/glob`, + `${QWEN_METHOD_NS}file/write`, + `${QWEN_METHOD_NS}file/edit`, + // Wave 1: auth + `${QWEN_METHOD_NS}workspace/auth/status`, + `${QWEN_METHOD_NS}workspace/auth/device_flow/start`, + `${QWEN_METHOD_NS}workspace/auth/device_flow/get`, + `${QWEN_METHOD_NS}workspace/auth/device_flow/cancel`, + // Wave 1: remaining workspace + `${QWEN_METHOD_NS}workspace/tools`, + `${QWEN_METHOD_NS}workspace/mcp/tools`, + `${QWEN_METHOD_NS}workspace/mcp/servers/add`, + `${QWEN_METHOD_NS}workspace/mcp/servers/remove`, + `${QWEN_METHOD_NS}sessions/delete`, + // Wave 2: agents + `${QWEN_METHOD_NS}workspace/agents/list`, + `${QWEN_METHOD_NS}workspace/agents/get`, + `${QWEN_METHOD_NS}workspace/agents/create`, + `${QWEN_METHOD_NS}workspace/agents/update`, + `${QWEN_METHOD_NS}workspace/agents/delete`, +]; + +/** + * Method names whose responses ride the CONNECTION-scoped stream (the + * session stream may not exist yet / ownership not granted on failure). + * Error frames must route the same way as their success path. + */ +const CONN_ROUTED_METHODS = new Set([ + 'authenticate', + 'session/new', + 'session/load', + 'session/resume', + 'session/list', + 'session/close', + ...QWEN_VENDOR_METHODS, +]); + +// SYNC: server.ts MAX_TOOL_NAME_LENGTH / MAX_SERVER_NAME_LENGTH (both 256). +// Keep in lockstep with the REST surface — a divergence means ACP clients get +// INVALID_PARAMS for names REST accepts (or vice versa). (Not extracted to a +// shared module to avoid churning the 2987-line server.ts near merge; a +// follow-up may lift all three to a `serve/limits.ts`.) +const MAX_NAME_LENGTH = 256; + +class AcpParamError extends Error {} + +/** + * Validate an optional `cwd` param the same way the REST `POST /session` + * route does: when present it must be a string, ≤ PATH_MAX, and absolute. + * Closes the body-amplification DoS the REST code documents. Returns the + * bound workspace when omitted. + */ +function parseOptionalWorkspaceCwd( + params: Record, + boundWorkspace: string, +): string { + if (!('cwd' in params) || params['cwd'] === undefined) return boundWorkspace; + const cwd = params['cwd']; + if (typeof cwd !== 'string') { + throw new AcpParamError( + '`cwd` must be a string absolute path when provided', + ); + } + if (cwd.length > MAX_WORKSPACE_PATH_LENGTH) { + throw new AcpParamError( + `\`cwd\` exceeds the ${MAX_WORKSPACE_PATH_LENGTH}-character limit`, + ); + } + // `path.isAbsolute` (platform-aware) — same as the REST route. A bare + // `startsWith('/')` would reject valid Windows `C:\…`/UNC paths a client + // gets back from `/capabilities.workspaceCwd`. + if (!path.isAbsolute(cwd)) { + throw new AcpParamError('`cwd` must be an absolute path when provided'); + } + return cwd; +} + +/** Validate a `session/prompt` body before it reaches the bridge/agent. */ +function validatePrompt(params: Record): void { + const prompt = params['prompt']; + if (!Array.isArray(prompt) || prompt.length === 0) { + throw new AcpParamError( + '`prompt` is required and must be a non-empty array of content blocks', + ); + } + if ( + !prompt.every( + (b) => typeof b === 'object' && b !== null && !Array.isArray(b), + ) + ) { + throw new AcpParamError('each `prompt` element must be an object'); + } +} + +/** + * Map a thrown error to a JSON-RPC error code + a client-safe message. + * Param-validation errors are echoed (they describe the client's own bad + * input); bridge/internal errors are coded by class name with their + * message preserved (the daemon's trust boundary is the bearer token, so + * the operator-facing message is not a cross-tenant leak), and anything + * unrecognized collapses to a generic INTERNAL_ERROR string. + */ +function toRpcError(err: unknown): { + code: number; + message: string; + data?: Record; +} { + if (err instanceof AcpParamError || err instanceof InvalidCursorError) { + return { code: RPC.INVALID_PARAMS, message: err.message }; + } + if (err instanceof SubagentError) { + return { code: RPC.INVALID_PARAMS, message: err.message }; + } + if (err instanceof FsError) { + return { + code: RPC.INVALID_PARAMS, + message: err.message, + data: { errorKind: err.kind, hint: err.hint }, + }; + } + if (err instanceof WorkspaceMemoryFileTooLargeError) { + return { + code: RPC.INVALID_PARAMS, + message: err.message, + data: { errorKind: 'memory_file_too_large' }, + }; + } + if (err instanceof WorkspaceMemoryWriteTimeoutError) { + return { + code: RPC.INTERNAL_ERROR, + message: err.message, + data: { errorKind: 'memory_write_timeout' }, + }; + } + if (err instanceof TooManyActiveDeviceFlowsError) { + return { + code: RPC.INTERNAL_ERROR, + message: err.message, + data: { errorKind: 'too_many_active_flows' }, + }; + } + if (err instanceof UnsupportedDeviceFlowProviderError) { + return { + code: RPC.INVALID_PARAMS, + message: err.message, + data: { errorKind: 'unsupported_provider' }, + }; + } + if (err instanceof UpstreamDeviceFlowError) { + return { + code: RPC.INTERNAL_ERROR, + message: err.message, + data: { errorKind: 'upstream_error' }, + }; + } + const name = err instanceof Error ? err.name : ''; + switch (name) { + case 'SessionNotFoundError': + case 'InvalidSessionScopeError': + case 'WorkspaceMismatchError': + case 'InvalidClientIdError': + return { code: RPC.INVALID_PARAMS, message: errMsg(err) }; + case 'SessionLimitExceededError': + return { code: RPC.INTERNAL_ERROR, message: errMsg(err) }; + default: + return { + code: RPC.INTERNAL_ERROR, + message: 'Internal error', + data: { errorKind: 'internal' }, + }; + } +} + +/** + * The ACP protocol version this transport speaks (ACP stable = 1). + */ +export const ACP_PROTOCOL_VERSION = 1; + +/** + * Routes JSON-RPC messages between the HTTP transport and the + * `HttpAcpBridge`. Inbound client messages map to bridge calls; the + * bridge's `BridgeEvent`s map back to JSON-RPC frames on the matching + * session stream (see the design doc §4 translation table). + */ +export class AcpDispatcher { + private readonly agentManager; + + constructor( + private readonly bridge: HttpAcpBridge, + private readonly boundWorkspace: string, + private readonly workspace: DaemonWorkspaceService, + private readonly fsFactory?: WorkspaceFileSystemFactory, + private readonly deviceFlowRegistry?: DeviceFlowRegistry, + ) { + this.agentManager = createDaemonSubagentManager(boundWorkspace); + } + + private killOrphanSession(sessionId: string): void { + void this.bridge + .killSession(sessionId, { requireZeroAttaches: true }) + .catch((err) => + writeStderrLine( + `qwen serve: /acp orphan killSession(${logSafe(sessionId)}) failed: ${logSafe(errMsg(err))}`, + ), + ); + } + + /** + * Build the `WorkspaceRequestContext` for workspace-scoped operations + * routed through the workspace service. The ACP dispatch has no session + * context, so `sessionId` is omitted. + */ + private wsCtx(conn: AcpConnection, method: string): WorkspaceRequestContext { + return { + originatorClientId: conn.clientId, + route: `ACP ${method}`, + workspaceCwd: this.boundWorkspace, + }; + } + + /** + * Build the bridge context for a per-session call. Echoes the clientId the + * bridge STAMPED at create/attach (the connection's own id is unregistered + * and would be rejected) and threads `fromLoopback` so the `local-only` + * permission policy can gate votes by transport — symmetric with the REST + * surface's `detectFromLoopback(req)`. + * + * Throws when no stamped clientId is present: the only callers reach here + * AFTER `requireOwned`, so the binding must exist and carry the bridge's + * id. A missing id means an invariant broke (a `session/new`/`load` that + * didn't record it) — fail loud rather than silently send an unregistered + * id whose rejection surfaces asynchronously, far from the cause. + */ + private sessionCtx( + conn: AcpConnection, + sessionId: string, + fromLoopback: boolean, + ): { clientId: string; fromLoopback: boolean } { + const clientId = conn.sessions.get(sessionId)?.clientId; + if (!clientId) { + throw new Error( + `no bridge-stamped clientId for session ${sessionId} (ownership invariant violated)`, + ); + } + return { clientId, fromLoopback }; + } + + /** + * The session's ACP-shaped config options (model/mode/…), read from the + * child's own session state. Returned in `session/new` and as the result + * of `session/set_config_option`. Best-effort — `undefined` on error. + */ + private async configOptionsFor( + sessionId: string, + ): Promise { + try { + const ctx = (await this.bridge.getSessionContextStatus(sessionId)) as { + state?: { configOptions?: unknown }; + }; + const co = ctx?.state?.configOptions; + return Array.isArray(co) ? co : undefined; + } catch (err) { + writeStderrLine( + `qwen serve: /acp configOptionsFor(${logSafe(sessionId)}) failed: ${logSafe(errMsg(err))}`, + ); + return undefined; + } + } + + /** + * Cancel a permission request the client abandoned (closed its stream / + * connection before voting), so the bridge isn't left blocked. Invoked + * by the connection-registry teardown path. + */ + cancelAbandonedPermission( + req: { sessionId: string; bridgeRequestId: string }, + clientId: string | undefined, + ): boolean { + try { + this.bridge.respondToSessionPermission( + req.sessionId, + req.bridgeRequestId, + { outcome: { outcome: 'cancelled' } } as unknown as Parameters< + HttpAcpBridge['respondToSessionPermission'] + >[2], + clientId !== undefined ? { clientId } : undefined, + ); + return true; + } catch (err) { + // "Session already gone" is the common, expected path (treat as done). + // Any OTHER failure means the mediator may still be stuck — log it AND + // report failure so a caller can keep the pending entry for a later + // teardown retry rather than dropping it. + const msg = errMsg(err); + if (/not found|unknown session/i.test(msg)) return true; + writeStderrLine( + `qwen serve: /acp cancelAbandonedPermission(${logSafe(req.sessionId)}) failed: ${logSafe(msg)}`, + ); + return false; + } + } + + /** + * Build the `initialize` result advertising standard + `_qwen` caps. + * Negotiates the protocol version: we only implement stable V1, so we + * clamp to `[1, ACP_PROTOCOL_VERSION]` — a client asking for 0/negative + * (ACP marks V0 a pre-release fallback) or a future version gets `1` + * rather than an echoed version we don't actually implement. + */ + buildInitializeResult( + connectionId: string, + requestedVersion?: unknown, + ): Record { + const requested = + typeof requestedVersion === 'number' && Number.isFinite(requestedVersion) + ? requestedVersion + : ACP_PROTOCOL_VERSION; + const negotiated = Math.max(1, Math.min(requested, ACP_PROTOCOL_VERSION)); + return { + protocolVersion: negotiated, + agentCapabilities: { + loadSession: true, + // Mirror acpAgent.ts promptCapabilities: #resolvePrompt handles audio + // blocks identically to image (both become inlineData Parts). + promptCapabilities: { + image: true, + audio: true, + embeddedContext: true, + }, + // Model + mode are exposed via the STANDARD `session/set_config_option` + // (categories `model`/`mode`); advertise that here. + configOptions: true, + // Vendor extensions are advertised under `_meta` keyed by domain + // (ACP convention, e.g. `_meta: { "zed.dev": … }`). Clients + // feature-detect before calling `_qwen/…` methods. + _meta: { + [QWEN_META_KEY]: { + connectionId, + workspaceCwd: this.boundWorkspace, + methods: [...QWEN_VENDOR_METHODS], + }, + }, + }, + }; + } + + /** + * Gate a per-session operation on connection ownership. Sends a JSON-RPC + * error and returns false when this connection never created/attached + * the session (prevents driving or eavesdropping on another + * connection's session). `session/new|load|resume` are the + * ownership-GRANTING ops and skip this. + */ + private requireOwned( + conn: AcpConnection, + sessionId: string, + id: JsonRpcId | undefined, + ): boolean { + if (conn.ownsSession(sessionId)) return true; + if (id === undefined) { + // Notification (no id) for an unowned session: no wire response to + // send, so log it — otherwise "my cancel did nothing" is undebuggable. + writeStderrLine( + `qwen serve: /acp notification for unowned session ${logSafe(sessionId)} (dropped)`, + ); + return false; + } + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `Session ${sessionId} is not owned by this connection`, + ), + ); + return false; + } + + /** + * Handle one inbound POST message. Returns nothing — every reply is + * delivered asynchronously on a long-lived SSE stream per the RFD + * (`POST` itself answers `202`). `initialize` is handled by the caller + * (it mints the connection) and never reaches here. + */ + async handle( + conn: AcpConnection, + msg: JsonRpcInbound, + sessionHeader?: string, + reqLoopback?: boolean, + ): Promise { + // Loopback is evaluated PER REQUEST (the permission-vote POST may arrive + // from a different peer than `initialize`), falling back to the + // connection's initialize-time value when the caller didn't supply it. + const loopback = reqLoopback ?? conn.fromLoopback; + + // A client's JSON-RPC RESPONSE (to an agent→client request) — wrapped + // so a throwing bridge call can't reject this promise after index.ts + // already sent `202` (which would surface as an unhandled rejection). + if (isResponse(msg)) { + try { + this.resolveClientResponse(conn, msg, loopback); + } catch (err) { + writeStderrLine( + `qwen serve: /acp response handling error: ${logSafe(errMsg(err))}`, + ); + } + return; + } + if (!isRequest(msg) && !isNotification(msg)) return; + + const method = msg.method; + const params = (isObject(msg.params) ? msg.params : {}) as Record< + string, + unknown + >; + const id = isRequest(msg) ? msg.id : undefined; + + // RFD §2.3: when both are present the `Acp-Session-Id` header and the + // `sessionId` param MUST agree — reject divergence rather than let a + // POST act on a session other than the one the header names. + if ( + sessionHeader && + typeof params['sessionId'] === 'string' && + params['sessionId'] !== sessionHeader + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + 'Acp-Session-Id header does not match params.sessionId', + ), + ); + } + return; + } + + try { + switch (method) { + case 'authenticate': + // HTTP transport authenticates via the daemon's bearer token + // middleware; the ACP-level method is a success no-op. + this.replyConn(conn, id, {}); + return; + + case 'session/new': { + const cwd = parseOptionalWorkspaceCwd(params, this.boundWorkspace); + // Forward sessionScope like REST (bridge supports single|thread). + const rawScope = params['sessionScope']; + if ( + rawScope !== undefined && + rawScope !== 'single' && + rawScope !== 'thread' + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`sessionScope` must be "single" or "thread"', + ), + ); + } + return; + } + const session = await this.bridge.spawnOrAttach({ + workspaceCwd: cwd, + clientId: conn.clientId, + ...(rawScope !== undefined + ? { sessionScope: rawScope as 'single' | 'thread' } + : {}), + }); + // Teardown raced the spawn: the connection was destroyed while the + // bridge call was in flight, so nothing will tear this session down. + // Kill the orphan (no other client could have attached yet). + if (conn.destroyed) { + this.killOrphanSession(session.sessionId); + return; + } + conn.getOrCreateSession(session.sessionId).clientId = + session.clientId; + conn.ownSession(session.sessionId); + const configOptions = await this.configOptionsFor(session.sessionId); + if (conn.destroyed) { + this.killOrphanSession(session.sessionId); + return; + } + this.replyConn(conn, id, { + sessionId: session.sessionId, + ...(configOptions ? { configOptions } : {}), + }); + return; + } + + case 'session/load': + case 'session/resume': { + const sessionId = String(params['sessionId'] ?? ''); + if (!sessionId) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`sessionId` is required'), + ); + } + return; + } + // Reject if a session/close for this id is in flight — otherwise the + // close's `finally` teardown would destroy the session we're about + // to load (TOCTOU). Client should retry after the close settles. + if (conn.closingSessions.has(sessionId)) { + if (id !== undefined) { + // The client's params are valid — the rejection is a server-side + // timing race against an in-flight close, so use INTERNAL_ERROR + // (-32603), not INVALID_PARAMS, to signal a transient/retryable + // condition rather than a permanent parameter fault. + conn.sendConn( + error( + id, + RPC.INTERNAL_ERROR, + `session ${sessionId} is being closed; retry`, + ), + ); + } + return; + } + const cwd = parseOptionalWorkspaceCwd(params, this.boundWorkspace); + const restored = + method === 'session/load' + ? await this.bridge.loadSession({ + sessionId, + workspaceCwd: cwd, + clientId: conn.clientId, + }) + : await this.bridge.resumeSession({ + sessionId, + workspaceCwd: cwd, + clientId: conn.clientId, + }); + // Teardown raced the restore — EITHER the whole connection was + // destroyed (`conn.destroyed`) OR a `session/close` for this id + // started DURING the await (`closingSessions`); in the latter the + // close's `finally` teardown would destroy the binding we're about + // to create. Both need the same cleanup; only the client reply + // differs. Cleanup depends on what restore did: + // - attached:true → detachClient rolls back just our attach. + // - attached:false → restore SPAWNED a fresh session from disk; + // detachClient only decrements attachCount and does NOT reap + // (reaping is the spawn-owner's job) — so kill it. + const closeRaced = conn.closingSessions.has(sessionId); + if (conn.destroyed || closeRaced) { + const cleanup = restored.attached + ? this.bridge.detachClient(sessionId, restored.clientId) + : this.bridge.killSession(sessionId, { + requireZeroAttaches: true, + }); + void cleanup.catch((err) => + writeStderrLine( + `qwen serve: /acp orphan ${restored.attached ? 'detach' : 'kill'}(${logSafe(sessionId)}) teardown-race: ${logSafe(errMsg(err))}`, + ), + ); + // Connection-still-alive close race → tell the client to retry. + // Same rationale as the pre-await guard: a transient server-side + // race, so INTERNAL_ERROR (-32603), not INVALID_PARAMS. + if (closeRaced && !conn.destroyed && id !== undefined) { + conn.sendConn( + error( + id, + RPC.INTERNAL_ERROR, + `session ${sessionId} was closed during load; retry`, + ), + ); + } + return; + } + conn.getOrCreateSession(sessionId).clientId = restored.clientId; + conn.ownSession(sessionId); + this.replyConn(conn, id, restored.state ?? {}); + return; + } + + case 'session/list': { + const cursor = + typeof params['cursor'] === 'string' ? params['cursor'] : undefined; + const meta = isObject(params['_meta']) ? params['_meta'] : undefined; + const metaSize = + typeof meta?.['size'] === 'number' + ? (meta['size'] as number) + : undefined; + const result = await listWorkspaceSessionsForResponse( + this.bridge, + this.boundWorkspace, + { cursor, size: metaSize }, + ); + this.replyConn(conn, id, { + sessions: result.sessions.map((s) => ({ + sessionId: s.sessionId, + cwd: s.workspaceCwd, + title: s.title, + updatedAt: s.updatedAt, + })), + ...(result.nextCursor != null + ? { nextCursor: result.nextCursor } + : {}), + }); + return; + } + + case 'session/close': { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + // Close the ownership gate SYNCHRONOUSLY (before the await) so two + // concurrent `session/close`s don't both pass `requireOwned` — + // the second would otherwise send a misleading error and trigger a + // redundant bridge close. + conn.ownedSessions.delete(sessionId); + // Mark closing so a concurrent session/load|resume of the SAME id + // can't grant fresh ownership + create a new binding that this + // close's `finally` teardown would then destroy (TOCTOU). + conn.closingSessions.add(sessionId); + try { + await this.bridge.closeSession( + sessionId, + this.sessionCtx(conn, sessionId, loopback), + ); + } finally { + // Local teardown must run even if the bridge close throws — + // otherwise the SSE stream, abort controller, buffered frames and + // pending permissions leak until idle TTL. + try { + conn.closeSessionStream(sessionId); + } catch (teardownErr) { + writeStderrLine( + `qwen serve: /acp session/close local teardown failed (${logSafe(sessionId)}): ${logSafe(errMsg(teardownErr))}`, + ); + } + conn.closingSessions.delete(sessionId); + } + this.replyConn(conn, id, {}); + return; + } + + case 'session/cancel': { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + // Abort our local in-flight prompt controller too — cancelSession + // tells the agent to wind down, but the HTTP-side `sendPrompt` + // await must also be released so the session FIFO unblocks. + conn.sessions.get(sessionId)?.promptAbort?.abort(); + await this.bridge.cancelSession( + sessionId, + // Forward client-supplied cancel fields (reason/context) while + // force-stamping sessionId — mirrors the REST surface. + { ...params, sessionId } as Parameters< + HttpAcpBridge['cancelSession'] + >[1], + this.sessionCtx(conn, sessionId, loopback), + ); + // `session/cancel` is normally a notification (no id), but answer + // the request-form so a client that sent an id isn't left hanging. + if (id !== undefined) this.replySession(conn, sessionId, id, {}); + return; + } + + case 'session/prompt': { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + validatePrompt(params); + await this.handlePrompt(conn, sessionId, id, params, loopback); + return; + } + + // STANDARD method (SDK 0.14.1, non-`unstable_`): model + mode live + // here under categories `model`/`mode`, routed to the existing bridge + // setters. Replaces the old vendor `_qwen/session/set_model`. + case 'session/set_config_option': { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const configId = String(params['configId'] ?? ''); + const rawValue = params['value']; + const ctx = this.sessionCtx(conn, sessionId, loopback); + // Validate value at the boundary like REST (empty/null is rejected + // rather than forwarded as "" to the bridge). + if (typeof rawValue !== 'string' || rawValue.length === 0) { + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error( + id, + RPC.INVALID_PARAMS, + '`value` must be a non-empty string', + ), + ); + } + return; + } + if (configId === 'model') { + await this.bridge.setSessionModel( + sessionId, + { modelId: rawValue } as unknown as Parameters< + HttpAcpBridge['setSessionModel'] + >[1], + ctx, + ); + } else if (configId === 'mode') { + if (!APPROVAL_MODES.includes(rawValue as ApprovalMode)) { + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error( + id, + RPC.INVALID_PARAMS, + `invalid mode "${rawValue}" (expected one of: ${APPROVAL_MODES.join(', ')})`, + ), + ); + } + return; + } + await this.bridge.setSessionApprovalMode( + sessionId, + rawValue as ApprovalMode, + { persist: params['persist'] === true }, + ctx, + ); + } else { + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error(id, RPC.INVALID_PARAMS, `Unknown configId: ${configId}`), + ); + } + return; + } + // Response returns the updated config option set (per ACP). + const configOptions = await this.configOptionsFor(sessionId); + this.replySession(conn, sessionId, id, { configOptions }); + return; + } + + case `${QWEN_METHOD_NS}session/heartbeat`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const result = this.bridge.recordHeartbeat( + sessionId, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}session/context`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + this.replyConn( + conn, + id, + await this.bridge.getSessionContextStatus(sessionId), + ); + return; + } + + case `${QWEN_METHOD_NS}session/supported_commands`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + this.replyConn( + conn, + id, + await this.bridge.getSessionSupportedCommandsStatus(sessionId), + ); + return; + } + + case `${QWEN_METHOD_NS}session/update_metadata`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const metadata = isObject(params['metadata']) + ? (params['metadata'] as Record) + : {}; + const result = this.bridge.updateSessionMetadata( + sessionId, + metadata as unknown as Parameters< + HttpAcpBridge['updateSessionMetadata'] + >[1], + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/mcp`: + this.replyConn( + conn, + id, + await this.workspace.getWorkspaceMcpStatus( + this.wsCtx(conn, method), + ), + ); + return; + case `${QWEN_METHOD_NS}workspace/skills`: + this.replyConn( + conn, + id, + await this.workspace.getWorkspaceSkillsStatus( + this.wsCtx(conn, method), + ), + ); + return; + case `${QWEN_METHOD_NS}workspace/providers`: + this.replyConn( + conn, + id, + await this.workspace.getWorkspaceProvidersStatus( + this.wsCtx(conn, method), + ), + ); + return; + case `${QWEN_METHOD_NS}workspace/env`: + this.replyConn( + conn, + id, + await this.workspace.getWorkspaceEnvStatus( + this.wsCtx(conn, method), + ), + ); + return; + case `${QWEN_METHOD_NS}workspace/preflight`: + this.replyConn( + conn, + id, + await this.workspace.getWorkspacePreflightStatus( + this.wsCtx(conn, method), + ), + ); + return; + + case `${QWEN_METHOD_NS}workspace/init`: { + const rawForce = params['force']; + if (rawForce !== undefined && typeof rawForce !== 'boolean') { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`force` must be a boolean when provided', + ), + ); + } + return; + } + const force = rawForce === true; + const result = await this.workspace.initWorkspace( + this.wsCtx(conn, method), + { force }, + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/set_tool_enabled`: { + const toolName = String(params['toolName'] ?? ''); + if (!toolName || toolName.length > MAX_NAME_LENGTH) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `\`toolName\` is required and must be ≤ ${MAX_NAME_LENGTH} chars`, + ), + ); + } + return; + } + const result = await this.workspace.setWorkspaceToolEnabled( + this.wsCtx(conn, method), + toolName, + params['enabled'] === true, + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/restart_mcp_server`: { + const serverName = String(params['serverName'] ?? ''); + if (!serverName || serverName.length > MAX_NAME_LENGTH) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `\`serverName\` is required and must be ≤ ${MAX_NAME_LENGTH} chars`, + ), + ); + } + return; + } + const rawIdx = params['entryIndex']; + if ( + rawIdx !== undefined && + (typeof rawIdx !== 'number' || + !Number.isInteger(rawIdx) || + rawIdx < 0) + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`entryIndex` must be a non-negative integer', + ), + ); + } + return; + } + const result = await this.workspace.restartMcpServer( + this.wsCtx(conn, method), + serverName, + rawIdx !== undefined ? { entryIndex: rawIdx } : undefined, + ); + this.replyConn(conn, id, result as unknown); + return; + } + + // ── Wave 1+2: ACP/REST parity methods ─────────────────────── + + case `${QWEN_METHOD_NS}session/recap`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const result = await this.bridge.generateSessionRecap( + sessionId, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}session/btw`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const rawQ = params['question']; + if ( + typeof rawQ !== 'string' || + rawQ.trim().length === 0 || + rawQ.length > BTW_MAX_INPUT_LENGTH + ) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `\`question\` required, non-empty, max ${BTW_MAX_INPUT_LENGTH} chars`, + ), + ); + return; + } + const result = await this.bridge.generateSessionBtw( + sessionId, + rawQ.trim(), + undefined, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}session/shell`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const rawCmd = params['command']; + if (typeof rawCmd !== 'string' || rawCmd.trim().length === 0) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`command` required and must be non-empty', + ), + ); + return; + } + + const logSessionId = logSafe(sessionId.slice(0, 8)); + const logClientId = logSafe(String(conn.clientId?.slice(0, 8))); + const logCommand = logSafe(rawCmd.slice(0, 120)); + writeStderrLine( + `qwen serve: /acp session/shell session=${logSessionId} client=${logClientId} cmd=${logCommand}`, + ); + const result = await this.bridge.executeShellCommand( + sessionId, + rawCmd, + undefined, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}session/detach`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const ctx = this.sessionCtx(conn, sessionId, loopback); + await this.bridge.detachClient(sessionId, ctx.clientId); + this.replyConn(conn, id, { ok: true }); + return; + } + + case `${QWEN_METHOD_NS}session/context_usage`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const result = await this.bridge.getSessionContextUsageStatus( + sessionId, + { detail: params['detail'] === true }, + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}session/tasks`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const result = await this.bridge.getSessionTasksStatus(sessionId); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/memory`: { + const result = await collectWorkspaceMemoryStatus( + this.boundWorkspace, + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/memory/write`: { + const content = params['content']; + if (typeof content !== 'string') { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`content` required, must be string', + ), + ); + return; + } + if (Buffer.byteLength(content, 'utf8') > 1024 * 1024) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`content` exceeds 1MB limit'), + ); + return; + } + const rawScope = params['scope']; + if ( + rawScope !== undefined && + rawScope !== 'workspace' && + rawScope !== 'global' + ) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`scope` must be "workspace" or "global"', + ), + ); + return; + } + const scope = (rawScope as 'workspace' | 'global') ?? 'workspace'; + const rawMode = params['mode']; + if ( + rawMode !== undefined && + rawMode !== 'append' && + rawMode !== 'replace' + ) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`mode` must be "append" or "replace"', + ), + ); + return; + } + const mode = (rawMode as 'append' | 'replace') ?? 'append'; + writeStderrLine( + `qwen serve: /acp workspace/memory/write scope=${scope} mode=${mode} client=${conn.clientId?.slice(0, 8)} bytes=${Buffer.byteLength(content, 'utf8')}`, + ); + const wr = await writeWorkspaceContextFile({ + scope, + mode, + content, + projectRoot: this.boundWorkspace, + }); + this.replyConn(conn, id, { + ok: true, + filePath: wr.filePath, + bytesWritten: wr.bytesWritten, + changed: wr.changed, + }); + if (wr.changed) { + try { + this.bridge.publishWorkspaceEvent({ + type: 'memory_changed', + data: { + scope, + filePath: wr.filePath, + mode, + bytesWritten: wr.bytesWritten, + }, + originatorClientId: conn.clientId, + }); + } catch { + /* best-effort */ + } + } + return; + } + + case `${QWEN_METHOD_NS}file/read`: { + const p = String(params['path'] ?? ''); + if (!p) { + if (id !== undefined) + conn.sendConn(error(id, RPC.INVALID_PARAMS, '`path` required')); + return; + } + if (!this.fsFactory) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INTERNAL_ERROR, 'File system not configured'), + ); + return; + } + const fs = this.fsFactory.forRequest({ + originatorClientId: conn.clientId, + route: `ACP ${method}`, + }); + const resolved = await fs.resolve(p, 'read'); + const out = await fs.readText(resolved, { + maxBytes: + typeof params['maxBytes'] === 'number' + ? params['maxBytes'] + : undefined, + line: + typeof params['line'] === 'number' ? params['line'] : undefined, + limit: + typeof params['limit'] === 'number' ? params['limit'] : undefined, + }); + this.replyConn(conn, id, { + path: p, + content: out.content, + ...out.meta, + } as unknown); + return; + } + + case `${QWEN_METHOD_NS}file/read_bytes`: { + if (!this.fsFactory) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INTERNAL_ERROR, 'File system not configured'), + ); + return; + } + const p = String(params['path'] ?? ''); + if (!p) { + if (id !== undefined) + conn.sendConn(error(id, RPC.INVALID_PARAMS, '`path` required')); + return; + } + const fs = this.fsFactory.forRequest({ + originatorClientId: conn.clientId, + route: `ACP ${method}`, + }); + const resolved = await fs.resolve(p, 'read'); + const buf = await fs.readBytesWindow(resolved, { + offset: + typeof params['offset'] === 'number' + ? params['offset'] + : undefined, + maxBytes: + typeof params['maxBytes'] === 'number' + ? params['maxBytes'] + : undefined, + }); + this.replyConn(conn, id, { path: p, ...buf } as unknown); + return; + } + + case `${QWEN_METHOD_NS}file/stat`: { + if (!this.fsFactory) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INTERNAL_ERROR, 'File system not configured'), + ); + return; + } + const p = String(params['path'] ?? ''); + if (!p) { + if (id !== undefined) + conn.sendConn(error(id, RPC.INVALID_PARAMS, '`path` required')); + return; + } + const fs = this.fsFactory.forRequest({ + originatorClientId: conn.clientId, + route: `ACP ${method}`, + }); + const resolved = await fs.resolve(p, 'read'); + const result = await fs.stat(resolved); + this.replyConn(conn, id, { path: p, ...result } as unknown); + return; + } + + case `${QWEN_METHOD_NS}file/list`: { + if (!this.fsFactory) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INTERNAL_ERROR, 'File system not configured'), + ); + return; + } + const p = String(params['path'] ?? ''); + if (!p) { + if (id !== undefined) + conn.sendConn(error(id, RPC.INVALID_PARAMS, '`path` required')); + return; + } + const fs = this.fsFactory.forRequest({ + originatorClientId: conn.clientId, + route: `ACP ${method}`, + }); + const resolved = await fs.resolve(p, 'read'); + const MAX_LIST = 2000; + const entries = await fs.list(resolved, { maxEntries: MAX_LIST + 1 }); + const truncated = entries.length > MAX_LIST; + this.replyConn(conn, id, { + path: p, + entries: truncated ? entries.slice(0, MAX_LIST) : entries, + truncated, + } as unknown); + return; + } + + case `${QWEN_METHOD_NS}file/glob`: { + const pattern = String(params['pattern'] ?? ''); + if (!pattern) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`pattern` required'), + ); + return; + } + if (!this.fsFactory) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INTERNAL_ERROR, 'File system not configured'), + ); + return; + } + const fs = this.fsFactory.forRequest({ + originatorClientId: conn.clientId, + route: `ACP ${method}`, + }); + const MAX_GLOB = 5000; + const maxResults = + typeof params['maxResults'] === 'number' + ? Math.max( + 1, + Math.min(Number(params['maxResults']) || 5000, 50000), + ) + : MAX_GLOB; + const matches = await fs.glob(pattern, { + maxResults: maxResults + 1, + }); + const truncated = matches.length > maxResults; + this.replyConn(conn, id, { + pattern, + matches: truncated ? matches.slice(0, maxResults) : matches, + truncated, + } as unknown); + return; + } + + case `${QWEN_METHOD_NS}file/write`: { + const p = String(params['path'] ?? ''); + if (!p) { + if (id !== undefined) + conn.sendConn(error(id, RPC.INVALID_PARAMS, '`path` required')); + return; + } + if (typeof params['content'] !== 'string') { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`content` must be string'), + ); + return; + } + if (!this.fsFactory) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INTERNAL_ERROR, 'File system not configured'), + ); + return; + } + const fs = this.fsFactory.forRequest({ + originatorClientId: conn.clientId, + route: `ACP ${method}`, + }); + const resolved = await fs.resolve(p, 'write'); + if ( + Buffer.byteLength(params['content'] as string, 'utf8') > + 10 * 1024 * 1024 + ) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, 'content exceeds 10MB limit'), + ); + return; + } + await fs.writeTextOverwrite(resolved, params['content'] as string); + this.replyConn(conn, id, { ok: true, path: p }); + return; + } + + case `${QWEN_METHOD_NS}file/edit`: { + const p = String(params['path'] ?? ''); + if (!p) { + if (id !== undefined) + conn.sendConn(error(id, RPC.INVALID_PARAMS, '`path` required')); + return; + } + if ( + typeof params['oldText'] !== 'string' || + typeof params['newText'] !== 'string' + ) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`oldText` and `newText` must be strings', + ), + ); + return; + } + if (!this.fsFactory) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INTERNAL_ERROR, 'File system not configured'), + ); + return; + } + const fs = this.fsFactory.forRequest({ + originatorClientId: conn.clientId, + route: `ACP ${method}`, + }); + const resolved = await fs.resolve(p, 'write'); + const result = await fs.edit( + resolved, + params['oldText'] as string, + params['newText'] as string, + ); + this.replyConn(conn, id, { ok: true, path: p, ...result } as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/auth/status`: { + if (!this.deviceFlowRegistry) { + this.replyConn(conn, id, { pendingDeviceFlows: [] }); + return; + } + const pending = this.deviceFlowRegistry.listPending(); + const projected = pending.map((v) => ({ + deviceFlowId: v.deviceFlowId, + providerId: v.providerId, + expiresAt: v.expiresAt, + })); + this.replyConn(conn, id, { pendingDeviceFlows: projected }); + return; + } + + case `${QWEN_METHOD_NS}workspace/auth/device_flow/start`: { + if (!this.deviceFlowRegistry) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INTERNAL_ERROR, 'Device flow not configured'), + ); + return; + } + const providerId = String(params['providerId'] ?? ''); + if (!providerId) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`providerId` required'), + ); + return; + } + const startResult = await this.deviceFlowRegistry.start({ + providerId: + providerId as import('../auth/deviceFlow.js').DeviceFlowProviderId, + initiatorClientId: conn.clientId, + }); + const { view, attached } = startResult; + const gated = + view.initiatorClientId === conn.clientId + ? view + : { + deviceFlowId: view.deviceFlowId, + providerId: view.providerId, + status: view.status, + expiresAt: view.expiresAt, + }; + this.replyConn(conn, id, { view: gated, attached } as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/auth/device_flow/get`: { + if (!this.deviceFlowRegistry) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INTERNAL_ERROR, 'Device flow not configured'), + ); + return; + } + const flowId = String(params['id'] ?? ''); + if (!flowId) { + if (id !== undefined) + conn.sendConn(error(id, RPC.INVALID_PARAMS, '`id` required')); + return; + } + const view = this.deviceFlowRegistry.get(flowId); + if (!view) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `Device flow "${flowId}" not found`, + ), + ); + return; + } + const gated = + view.initiatorClientId === conn.clientId + ? view + : { + deviceFlowId: view.deviceFlowId, + providerId: view.providerId, + status: view.status, + expiresAt: view.expiresAt, + }; + this.replyConn(conn, id, gated as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/auth/device_flow/cancel`: { + if (!this.deviceFlowRegistry) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INTERNAL_ERROR, 'Device flow not configured'), + ); + return; + } + const flowId = String(params['id'] ?? ''); + if (!flowId) { + if (id !== undefined) + conn.sendConn(error(id, RPC.INVALID_PARAMS, '`id` required')); + return; + } + const flowView = this.deviceFlowRegistry.get(flowId); + if ( + flowView && + flowView.initiatorClientId && + flowView.initiatorClientId !== conn.clientId + ) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + 'Only the flow initiator can cancel', + ), + ); + return; + } + const cancelResult = this.deviceFlowRegistry.cancel( + flowId, + conn.clientId, + ); + if (!cancelResult) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `Device flow "${flowId}" not found`, + ), + ); + return; + } + this.replyConn(conn, id, { + ok: true, + alreadyTerminal: cancelResult.alreadyTerminal, + }); + return; + } + + case `${QWEN_METHOD_NS}workspace/tools`: { + const result = await this.bridge.getWorkspaceToolsStatus(); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/mcp/tools`: { + const serverName = String(params['serverName'] ?? ''); + if (!serverName) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`serverName` required'), + ); + return; + } + const result = + await this.bridge.getWorkspaceMcpToolsStatus(serverName); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/mcp/servers/add`: { + const name = String(params['name'] ?? ''); + if (!name || name.length > MAX_NAME_LENGTH) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `\`name\` required, max ${MAX_NAME_LENGTH} chars`, + ), + ); + return; + } + const config = params['config']; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`config` required, must be object', + ), + ); + return; + } + const result = await this.bridge.addRuntimeMcpServer( + name, + config as Record, + conn.clientId, + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/mcp/servers/remove`: { + const name = String(params['name'] ?? ''); + if (!name || name.length > MAX_NAME_LENGTH) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `\`name\` required, max ${MAX_NAME_LENGTH} chars`, + ), + ); + return; + } + const result = await this.bridge.removeRuntimeMcpServer( + name, + conn.clientId, + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}sessions/delete`: { + const sessionIds = params['sessionIds']; + if ( + !Array.isArray(sessionIds) || + sessionIds.length === 0 || + sessionIds.length > 100 || + !sessionIds.every((s) => typeof s === 'string') + ) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`sessionIds` must be non-empty string array (max 100)', + ), + ); + return; + } + const ids = [...new Set(sessionIds as string[])]; + const closeErrors: Array<{ sessionId: string; error: string }> = []; + const closedIds: string[] = []; + await Promise.allSettled( + ids.map(async (sid) => { + try { + await this.bridge.closeSession(sid); + closedIds.push(sid); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if ( + err instanceof Error && + err.name === 'SessionNotFoundError' + ) { + closedIds.push(sid); + } else { + writeStderrLine( + `qwen serve: /acp sessions/delete closeSession(${sid.slice(0, 8)}) failed: ${msg}`, + ); + closeErrors.push({ sessionId: sid, error: msg }); + } + } + }), + ); + const svc = new SessionService(this.boundWorkspace); + const removeResult = await svc.removeSessions(closedIds); + for (const e of removeResult.errors) { + writeStderrLine( + `qwen serve: /acp sessions/delete removeSessions(${e.sessionId.slice(0, 8)}) failed: ${e.error.message}`, + ); + } + this.replyConn(conn, id, { + removed: removeResult.removed, + notFound: removeResult.notFound, + errors: [ + ...closeErrors, + ...removeResult.errors.map((e) => ({ + sessionId: e.sessionId, + error: e.error.message, + })), + ], + } as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/agents/list`: { + const agents = await this.agentManager.listSubagents({ force: true }); + this.replyConn(conn, id, { + v: 1, + workspaceCwd: this.boundWorkspace, + agents: agents.map(agentToSummary), + }); + return; + } + + case `${QWEN_METHOD_NS}workspace/agents/get`: { + const agentType = String(params['agentType'] ?? ''); + if (!agentType) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`agentType` required'), + ); + return; + } + const config = await this.agentManager.loadSubagent(agentType); + if (!config) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, `Agent "${agentType}" not found`), + ); + return; + } + this.replyConn(conn, id, agentToDetail(config) as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/agents/create`: { + const scope = params['scope']; + if (scope !== 'workspace' && scope !== 'global') { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`scope` must be "workspace" or "global"', + ), + ); + return; + } + const name = params['name']; + if (typeof name !== 'string' || !name.trim()) { + if (id !== undefined) + conn.sendConn(error(id, RPC.INVALID_PARAMS, '`name` required')); + return; + } + const level: SubagentLevel = + scope === 'workspace' ? 'project' : 'user'; + if (BuiltinAgentRegistry.isBuiltinAgent(name)) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `Cannot shadow built-in agent "${name}"`, + ), + ); + return; + } + const collision = await this.agentManager.loadSubagent(name, level); + if (collision) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, `Agent "${name}" already exists`), + ); + return; + } + await this.agentManager.createSubagent( + { + name, + level, + description: + typeof params['description'] === 'string' + ? params['description'] + : '', + systemPrompt: + typeof params['systemPrompt'] === 'string' + ? params['systemPrompt'] + : '', + tools: Array.isArray(params['tools']) + ? (params['tools'] as string[]) + : undefined, + model: + typeof params['model'] === 'string' + ? params['model'] + : undefined, + }, + { level }, + ); + const created = await this.agentManager.loadSubagent(name, level); + this.replyConn(conn, id, { + ok: true, + agent: created ? agentToDetail(created) : null, + } as unknown); + try { + this.bridge.publishWorkspaceEvent({ + type: 'agent_changed', + data: { change: 'created', name, level }, + originatorClientId: conn.clientId, + }); + } catch { + /* best-effort */ + } + return; + } + + case `${QWEN_METHOD_NS}workspace/agents/update`: { + const agentType = String(params['agentType'] ?? ''); + if (!agentType) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`agentType` required'), + ); + return; + } + const existing = await this.agentManager.loadSubagent(agentType); + if (!existing) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, `Agent "${agentType}" not found`), + ); + return; + } + const MAX_FIELD_BYTES = 256 * 1024; + const updates: Record = {}; + if (typeof params['description'] === 'string') { + if ( + Buffer.byteLength(params['description'], 'utf8') > MAX_FIELD_BYTES + ) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`description` exceeds 256KB limit', + ), + ); + return; + } + updates['description'] = params['description']; + } + if (typeof params['systemPrompt'] === 'string') { + if ( + Buffer.byteLength(params['systemPrompt'], 'utf8') > + MAX_FIELD_BYTES + ) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`systemPrompt` exceeds 256KB limit', + ), + ); + return; + } + updates['systemPrompt'] = params['systemPrompt']; + } + if (Array.isArray(params['tools'])) { + if (params['tools'].length > 256) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`tools` exceeds 256-entry limit', + ), + ); + return; + } + if ( + !params['tools'].every( + (t: unknown) => + typeof t === 'string' && (t as string).length <= 256, + ) + ) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`tools` elements must be strings ≤256 chars', + ), + ); + return; + } + updates['tools'] = params['tools']; + } + if (typeof params['model'] === 'string') + updates['model'] = params['model']; + if (Object.keys(updates).length === 0) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + 'at least one updatable field required', + ), + ); + return; + } + await this.agentManager.updateSubagent( + agentType, + updates, + existing.level, + ); + const updated = await this.agentManager.loadSubagent( + agentType, + existing.level, + ); + this.replyConn(conn, id, { + ok: true, + agent: updated ? agentToDetail(updated) : null, + } as unknown); + try { + this.bridge.publishWorkspaceEvent({ + type: 'agent_changed', + data: { + change: 'updated', + name: agentType, + level: existing.level, + }, + originatorClientId: conn.clientId, + }); + } catch { + /* best-effort */ + } + return; + } + + case `${QWEN_METHOD_NS}workspace/agents/delete`: { + const agentType = String(params['agentType'] ?? ''); + if (!agentType) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`agentType` required'), + ); + return; + } + const scope = + typeof params['scope'] === 'string' ? params['scope'] : undefined; + const level: SubagentLevel | undefined = + scope === 'workspace' + ? 'project' + : scope === 'global' + ? 'user' + : undefined; + const existing = await this.agentManager.loadSubagent( + agentType, + level, + ); + if (!existing) { + if (id !== undefined) + conn.sendConn( + error(id, RPC.INVALID_PARAMS, `Agent "${agentType}" not found`), + ); + return; + } + await this.agentManager.deleteSubagent(agentType, existing.level); + this.replyConn(conn, id, { ok: true }); + try { + this.bridge.publishWorkspaceEvent({ + type: 'agent_changed', + data: { + change: 'deleted', + name: agentType, + level: existing.level, + }, + originatorClientId: conn.clientId, + }); + } catch { + /* best-effort */ + } + return; + } + + default: + if (id !== undefined) { + conn.sendConn( + error(id, RPC.METHOD_NOT_FOUND, `Unknown method: ${method}`), + ); + } + return; + } + } catch (err) { + // Full detail to stderr for the operator; a coded, client-safe shape + // on the wire (raw bridge messages may carry internal paths/details). + writeStderrLine( + `qwen serve: /acp dispatch error (${logSafe(method)}): ${logSafe(errMsg(err))}`, + ); + if (id !== undefined) { + const { code, message, data } = toRpcError(err); + const frame = error(id, code, message, data); + // Route the error the SAME way as the method's success path. Inferring + // from `params.sessionId` would misroute conn-scoped method failures + // (session/load|resume|close|…) to a session stream that doesn't exist + // yet — the client waiting on the connection stream never sees them. + const sessionId = + typeof params['sessionId'] === 'string' + ? (params['sessionId'] as string) + : undefined; + if (sessionId && !CONN_ROUTED_METHODS.has(method)) { + this.replySession(conn, sessionId, id, undefined, frame); + } else { + conn.sendConn(frame); + } + } + } + } + + /** + * Bind a session-scoped SSE stream to the bridge's event stream, + * translating each `BridgeEvent` into a JSON-RPC frame (design §4.2). + */ + async pumpSessionEvents( + conn: AcpConnection, + sessionId: string, + signal: AbortSignal, + ): Promise { + try { + const iterable = this.bridge.subscribeEvents(sessionId, { signal }); + for await (const event of iterable) { + if (signal.aborted) break; + // Count event delivery as connection activity so a long, quiet prompt + // (no inbound HTTP) isn't reaped by the idle-TTL sweep. + conn.touch(); + this.translateEvent(conn, sessionId, event); + } + } catch (err) { + // Symmetric for the SYNC `subscribeEvents` throw and a MID-STREAM + // iterator error: surface a `stream_error` to the client, then re-throw + // so the caller's `.catch()` closes the stream. Returning would leave a + // zombie SSE stream (heartbeats, no events, no reconnect signal). + if (!signal.aborted) { + conn.sendSession( + sessionId, + notification(`${QWEN_METHOD_NS}notify`, { + kind: 'stream_error', + error: errMsg(err), + }), + ); + } + throw err; + } + // Normal completion (iterator returned `done` — e.g. the subprocess ended + // cleanly). The caller's `.then` closes the stream so it isn't left as a + // zombie heartbeating with nothing more to deliver. + } + + private translateEvent( + conn: AcpConnection, + sessionId: string, + event: BridgeEvent, + ): void { + switch (event.type) { + case 'session_update': { + // `event.data` is the ACP `SessionNotification` (params shape). + conn.sendSession(sessionId, notification('session/update', event.data)); + return; + } + case 'permission_request': { + const data = event.data as { + requestId: string; + sessionId: string; + toolCall: unknown; + options: unknown; + }; + // A permission request MUST reach a LIVE session stream. Going + // through `sendSession` would (a) silently drop the frame if the + // session was torn down (lookup-only), or (b) buffer it pre-attach + // where `pushCapped` could evict it under event throughput — either + // way the `pending` entry is orphaned and the agent's prompt blocks + // on a vote forever. So deliver DIRECTLY to a live stream, and if + // there is none, cancel (deny-safe) rather than register+stall. + const binding = conn.sessions.get(sessionId); + if (!binding?.stream || binding.stream.isClosed) { + const cancelled = this.cancelAbandonedPermission( + { sessionId, bridgeRequestId: data.requestId }, + // Pass the bridge-stamped clientId when the binding still exists + // (stream closed but session live) — only `undefined` when the + // session is fully gone. + binding?.clientId, + ); + // Unlike resolveClientResponse (where the pending entry exists and + // teardown can retry), this path returns BEFORE `conn.pending.set` — + // so `abandonPendingForSession` will NOT find it. A failed cancel + // here means the mediator is stuck permanently, not just until + // teardown. Log clearly so the operator knows there is no automatic + // recovery; manual intervention (restart the agent session) is needed. + if (!cancelled) { + writeStderrLine( + `qwen serve: /acp permission cancel FAILED for ${logSafe(sessionId)} (mediator stuck; no automatic recovery)`, + ); + } + return; + } + const id = conn.nextId(); + conn.pending.set(id, { + sessionId, + bridgeRequestId: data.requestId, + kind: 'permission', + }); + void binding.stream.send( + request(id, 'session/request_permission', { + sessionId: data.sessionId, + toolCall: data.toolCall, + options: data.options, + _meta: { [QWEN_META_KEY]: { requestId: data.requestId } }, + }), + ); + return; + } + case 'stream_error': { + conn.sendSession( + sessionId, + notification(`${QWEN_METHOD_NS}notify`, { + // Spread first so a stray `kind` in event.data can't shadow the + // discriminator the client's error handler keys on. + ...(event.data as object), + kind: 'stream_error', + }), + ); + return; + } + default: { + // client_evicted / slow_client_warning / state_resync_required / + // model_switched / approval_mode_changed / … → opaque qwen notify. + conn.sendSession( + sessionId, + notification(`${QWEN_METHOD_NS}notify`, { + kind: event.type, + data: event.data, + }), + ); + } + } + } + + /** + * Resolve a client's JSON-RPC response to an agent→client request. + * `fromLoopback` is the CURRENT request's loopback bit (the vote POST may + * arrive from a different peer than `initialize`). + */ + private resolveClientResponse( + conn: AcpConnection, + msg: JsonRpcResponse, + fromLoopback: boolean, + ): void { + // Our outbound request ids are strings (`_qwen_perm_N`); a client echoes + // the same id verbatim. Anything else can't match a pending entry. + const id = msg.id; + if (typeof id !== 'string') return; + const pending = conn.pending.get(id); + if (!pending) return; + // NOTE: do NOT delete the pending entry yet. Keep it until either the + // bridge vote OR the cancel fallback runs — if both somehow fail, the + // entry survives so a later session/connection teardown + // (`abandonPendingForSession`) can still release the mediator. + + // A client error response is a cancellation; otherwise pass the result + // through. The cast defers shape validation to the bridge, so a + // MALFORMED result (e.g. `{}` with no `outcome`) makes the mediator + // throw — caught below, where we fall back to an explicit cancel so the + // mediator is always released. The pending entry is dropped only after a + // successful vote/cancel (see the NOTE above), so a double-failure leaves + // it for teardown to retry. + const vote = + 'error' in msg + ? { outcome: { outcome: 'cancelled' } } + : (msg as { result: unknown }).result; + try { + this.bridge.respondToSessionPermission( + pending.sessionId, + pending.bridgeRequestId, + vote as unknown as Parameters< + HttpAcpBridge['respondToSessionPermission'] + >[2], + this.sessionCtx(conn, pending.sessionId, fromLoopback), + ); + conn.pending.delete(id); // vote landed — safe to drop + } catch (err) { + writeStderrLine( + `qwen serve: /acp permission vote failed (${logSafe(pending.sessionId)}): ${logSafe(errMsg(err))}`, + ); + // Cancel BEFORE deleting, and ONLY drop the entry if the cancel + // landed. If it also failed, keep the entry so teardown's + // `abandonPendingForSession` can retry — otherwise the mediator is + // permanently stuck with no recovery path. + const cancelled = this.cancelAbandonedPermission( + pending, + conn.sessions.get(pending.sessionId)?.clientId, + ); + if (cancelled) conn.pending.delete(id); + } + } + + private async handlePrompt( + conn: AcpConnection, + sessionId: string, + id: JsonRpcId | undefined, + params: Record, + fromLoopback: boolean, + ): Promise { + // Park the controller on the binding so `session/cancel` and + // session/connection teardown can abort an in-flight prompt — otherwise + // a disconnecting client leaves the agent running, burning model quota + // and holding the session's prompt FIFO. + const binding = conn.getOrCreateSession(sessionId); + // Abort any prior in-flight prompt for this session before replacing the + // controller — two concurrent `session/prompt`s would otherwise orphan + // the first (it runs to completion in the bridge FIFO, burning quota, + // and `session/cancel` could only reach the latest controller). + binding.promptAbort?.abort(); + const abort = new AbortController(); + binding.promptAbort = abort; + try { + const result = await this.bridge.sendPrompt( + sessionId, + // SECURITY NOTE: `params.sessionId` already equals the routing + // `sessionId` (both from the same params), so there's no routing + // divergence today. If the bridge ever trusts an additional + // `sendPrompt` field by name (e.g. a priority/temperature override), + // force-stamp it here like the REST surface does (`{ ...body, + // sessionId, prompt }`) so it can't become client-controlled. + params as unknown as Parameters[1], + abort.signal, + this.sessionCtx(conn, sessionId, fromLoopback), + ); + if (id !== undefined) this.replySession(conn, sessionId, id, result); + } catch (err) { + const { code, message, data } = toRpcError(err); + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error(id, code, message, data), + ); + } else { + // Notification-form prompt (no id): no response frame to send, so a + // failure would vanish silently — log it for the operator. + writeStderrLine( + `qwen serve: /acp prompt error (${logSafe(sessionId)}, notification): ${logSafe(errMsg(err))}`, + ); + } + } finally { + if (binding.promptAbort === abort) binding.promptAbort = undefined; + } + } + + private replyConn( + conn: AcpConnection, + id: JsonRpcId | undefined, + result: unknown, + ): void { + if (id === undefined) return; + conn.sendConn(success(id, result)); + } + + private replySession( + conn: AcpConnection, + sessionId: string, + id: JsonRpcId | undefined, + result: unknown, + errorFrame?: ReturnType, + ): void { + if (id === undefined) return; + const frame = errorFrame ?? success(id, result); + // If the session was torn down mid-flight (e.g. a concurrent + // `session/close`), the binding + session stream are gone and + // `sendSession` is lookup-only — it would SILENTLY DROP this frame, + // violating the JSON-RPC one-response-per-request contract. Fall back to + // the connection-scoped stream so an id'd request always gets its reply. + if (conn.sessions.has(sessionId)) { + conn.sendSession(sessionId, frame); + } else { + // Fallback fired — log it so an operator can correlate "reply arrived on + // the connection stream, not the session stream" with a mid-flight + // session teardown. + writeStderrLine( + `qwen serve: /acp replySession(${logSafe(sessionId)}) binding gone mid-flight, ` + + `reply routed to connection stream ${conn.connectionId.slice(0, 8)}`, + ); + conn.sendConn(frame); + } + } +} + +// Re-export so tests can reference the request type without the jsonRpc path. +export type { JsonRpcRequest }; diff --git a/packages/cli/src/serve/acpHttp/index.ts b/packages/cli/src/serve/acpHttp/index.ts new file mode 100644 index 0000000000..d3784ca9a6 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/index.ts @@ -0,0 +1,818 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash, timingSafeEqual } from 'node:crypto'; +import type { IncomingMessage } from 'node:http'; +import type { Duplex } from 'node:stream'; +import type { Application, Request, Response } from 'express'; +import { WebSocketServer, type WebSocket } from 'ws'; +import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import type { DaemonWorkspaceService } from '../workspace-service/types.js'; +import type { WorkspaceFileSystemFactory } from '../fs/index.js'; +import type { DeviceFlowRegistry } from '../auth/deviceFlow.js'; +import { AcpDispatcher } from './dispatch.js'; +import { + ConnectionRegistry, + type AcpConnection, +} from './connectionRegistry.js'; +import { SseStream } from './sseStream.js'; +import { WsStream } from './wsStream.js'; +import type { RateLimitTier } from '../rateLimit.js'; +import { RPC, error as rpcError, isRequest, parseInbound } from './jsonRpc.js'; + +export const ACP_CONNECTION_HEADER = 'acp-connection-id'; +export const ACP_SESSION_HEADER = 'acp-session-id'; + +/** + * Grace window after the connection-scoped SSE stream closes before the + * connection is reaped (if not reconnected and no session stream is live). + * Long enough to ride out a transient blip / reconnect, short enough to free + * `ownedSessions` + a `maxConnections` slot well before the 30-min idle TTL. + */ +const CONN_GRACE_MS = 10_000; + +const WS_EXEMPT_METHODS = new Set([ + '_qwen/session/heartbeat', + '_qwen/session/update_metadata', +]); + +const WS_READ_METHODS = new Set([ + 'session/list', + '_qwen/session/context', + '_qwen/session/supported_commands', + '_qwen/session/context_usage', + '_qwen/session/tasks', + '_qwen/workspace/mcp', + '_qwen/workspace/skills', + '_qwen/workspace/providers', + '_qwen/workspace/env', + '_qwen/workspace/preflight', + '_qwen/workspace/tools', + '_qwen/workspace/mcp/tools', + '_qwen/workspace/agents/list', + '_qwen/workspace/agents/get', + '_qwen/workspace/memory', + '_qwen/workspace/auth/status', + '_qwen/workspace/auth/device_flow/get', + '_qwen/file/read', + '_qwen/file/read_bytes', + '_qwen/file/stat', + '_qwen/file/list', + '_qwen/file/glob', +]); + +export interface MountAcpHttpOptions { + boundWorkspace: string; + workspace: DaemonWorkspaceService; + fsFactory?: WorkspaceFileSystemFactory; + deviceFlowRegistry?: DeviceFlowRegistry; + enabled?: boolean; + path?: string; + maxConnections?: number; + /** Bearer token for WS auth (WS bypasses Express middleware). */ + token?: string; + /** Rate limit checker for WS messages (WS bypasses Express middleware). */ + checkRate?: (key: string, tier: RateLimitTier) => boolean; +} + +export interface AcpHttpHandle { + dispose(): void; + registry: ConnectionRegistry; + /** Attach HTTP server post-listen to enable WebSocket upgrade. */ + attachServer(server: import('node:http').Server): void; +} + +/** + * Mount the official ACP Streamable HTTP transport (RFD #721) on an + * existing Express app, backed by the shared `HttpAcpBridge`. Additive: + * the REST surface (`/session/*`) is untouched (design doc §6). + * + * Wire shape (single `/acp` endpoint): + * - POST {initialize} → 200 + capabilities JSON + `Acp-Connection-Id` + * - POST {other} → 202; reply delivered on a long-lived SSE stream + * - GET (conn header) → connection-scoped SSE stream + * - GET (conn+session)→ session-scoped SSE stream + * - DELETE → 202; tears the connection down + */ +export function mountAcpHttp( + app: Application, + bridge: HttpAcpBridge, + opts: MountAcpHttpOptions, +): AcpHttpHandle | undefined { + const enabled = opts.enabled ?? process.env['QWEN_SERVE_ACP_HTTP'] !== '0'; + if (!enabled) return undefined; + + const path = opts.path ?? '/acp'; + const dispatcher = new AcpDispatcher( + bridge, + opts.boundWorkspace, + opts.workspace, + opts.fsFactory, + opts.deviceFlowRegistry, + ); + // When a session/connection tears down with a permission still pending, + // cancel it on the bridge so the agent's prompt isn't left blocked. + const registry = new ConnectionRegistry( + (req, clientId) => dispatcher.cancelAbandonedPermission(req, clientId), + // Best-effort bridge detach so a torn-down connection's bridge-stamped + // client ids don't linger in the bridge's voter/known-client sets. + (sessionId, clientId) => { + void bridge.detachClient(sessionId, clientId).catch((err: unknown) => { + writeStderrLine( + `qwen serve: /acp detachClient(${sessionId}) failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + }, + opts.maxConnections, + ); + + // ── POST /acp ────────────────────────────────────────────────────── + app.post(path, async (req: Request, res: Response) => { + // RFD: Content-Type MUST be application/json; otherwise 415. + const ct = req.headers['content-type']; + if (!ct || !ct.startsWith('application/json')) { + res.status(415).json({ error: 'Content-Type must be application/json' }); + return; + } + // RFD: batch JSON-RPC arrays → 501 Not Implemented. + if (Array.isArray(req.body)) { + res + .status(501) + .json({ error: 'Batch JSON-RPC requests are not supported' }); + return; + } + const parsed = parseInbound(req.body); + if (!parsed.ok) { + writeStderrLine( + `qwen serve: /acp malformed request from ${req.socket?.remoteAddress}: ${parsed.error.error.message}`, + ); + res.status(400).json(parsed.error); + return; + } + const message = parsed.message; + + // `initialize` mints a connection and replies inline (200 + JSON). + if (isRequest(message) && message.method === 'initialize') { + const conn = registry.create(isLoopbackReq(req)); + if (!conn) { + // Connection cap reached — shed load rather than grow unbounded. + writeStderrLine( + `qwen serve: /acp connection cap reached (max=${registry.connectionCap}), rejecting initialize`, + ); + res.setHeader('Retry-After', '5'); + res + .status(503) + .json( + rpcError( + message.id, + RPC.INTERNAL_ERROR, + 'Too many ACP connections; retry later', + ), + ); + return; + } + const requestedVersion = + message.params && + typeof message.params === 'object' && + !Array.isArray(message.params) + ? (message.params as Record)['protocolVersion'] + : undefined; + res.setHeader('Acp-Connection-Id', conn.connectionId); + res.status(200).json({ + // success envelope: clients correlate by the request id. + jsonrpc: '2.0', + id: message.id, + result: dispatcher.buildInitializeResult( + conn.connectionId, + requestedVersion, + ), + }); + writeStderrLine( + `qwen serve: /acp connection established ${conn.connectionId.slice(0, 8)} ` + + `(loopback=${conn.fromLoopback}, active=${registry.size})`, + ); + return; + } + + const connHeader = headerOf(req, ACP_CONNECTION_HEADER); + if (!connHeader) { + res + .status(400) + .json( + rpcError( + isRequest(message) ? message.id : null, + RPC.INVALID_REQUEST, + 'Missing Acp-Connection-Id', + ), + ); + return; + } + const conn = registry.get(connHeader); + if (!conn) { + res + .status(404) + .json( + rpcError( + isRequest(message) ? message.id : null, + RPC.INVALID_REQUEST, + 'Unknown Acp-Connection-Id', + ), + ); + return; + } + + // Rate limit ACP HTTP POST (mirrors the WS checkRate path). + if (opts.checkRate && isRequest(message)) { + const m = message.method; + if (!WS_EXEMPT_METHODS.has(m)) { + const tier: RateLimitTier = + m === 'session/prompt' || m === '_qwen/session/prompt' + ? 'prompt' + : WS_READ_METHODS.has(m) + ? 'read' + : 'mutation'; + const httpKey = (req.socket?.remoteAddress ?? 'http-unknown').replace( + /^::ffff:/, + '', + ); + if (!opts.checkRate(httpKey, tier)) { + res.setHeader('Retry-After', '5'); + res.status(429).json({ + error: 'Rate limit exceeded', + code: 'rate_limit_exceeded', + tier, + }); + return; + } + } + } + + // Per RFD: non-initialize POST acks 202; the reply rides an SSE stream. + res.status(202).end(); + // Response already sent — `handle` delivers everything else over SSE, so + // swallow+log any late rejection rather than let it escape as an + // unhandled rejection (which could take the daemon down). + await dispatcher + .handle( + conn, + message, + headerOf(req, ACP_SESSION_HEADER), + isLoopbackReq(req), + ) + .catch((err: unknown) => { + writeStderrLine( + `qwen serve: /acp handle error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + }); + + // ── GET /acp (SSE) ───────────────────────────────────────────────── + app.get(path, (req: Request, res: Response) => { + // RFD: Accept MUST include text/event-stream; otherwise 406. + const accept = req.headers['accept'] ?? ''; + if (!accept.includes('text/event-stream')) { + res + .status(406) + .json({ error: 'Accept header must include text/event-stream' }); + return; + } + const connHeader = headerOf(req, ACP_CONNECTION_HEADER); + if (!connHeader) { + res.status(400).json({ error: 'Missing Acp-Connection-Id' }); + return; + } + const conn = registry.get(connHeader); + if (!conn) { + res.status(404).json({ error: 'Unknown Acp-Connection-Id' }); + return; + } + const sessionId = headerOf(req, ACP_SESSION_HEADER); + + if (!sessionId) { + // Connection-scoped stream. onClose logs the disconnect so a + // half-dead connection (conn stream gone, replies silently buffering) + // leaves an operator breadcrumb. + const connId = conn.connectionId; + const stream = new SseStream( + res, + () => { + writeStderrLine( + `qwen serve: /acp connection stream closed (${connId.slice(0, 8)})`, + ); + // Grace-period reap: a dead connection otherwise locks its + // ownedSessions + counts against maxConnections for the full 30-min + // idle TTL. After the grace window, reap UNLESS a reconnect + // re-attached the conn stream (clears the timer) OR a session + // stream is still live (client is active — only the conn stream + // blipped, don't kill its sessions/prompts). + conn.clearGraceTimer(); + conn.connGraceTimer = setTimeout(() => { + if ( + registry.get(connId) === conn && + conn.connStream === stream && + !conn.hasLiveSessionStream() + ) { + writeStderrLine( + `qwen serve: /acp reaping connection ${connId.slice(0, 8)} (conn stream gone, no live session stream)`, + ); + registry.delete(connId); + } + }, CONN_GRACE_MS); + conn.connGraceTimer.unref?.(); + }, + () => conn.touch(), + ); + stream.open(); + conn.attachConnStream(stream); + return; + } + + // Session-scoped stream — only for a session THIS connection owns + // (created via session/new or attached via session/load|resume). Stops + // one connection eavesdropping on another's session event stream. + if (!conn.ownsSession(sessionId)) { + res.status(403).json({ error: 'Session not owned by this connection' }); + return; + } + + // Fresh controller per stream so a reconnect gets a live (non-aborted) + // signal; `attachSessionStream` installs it and tears down any prior + // stream/subscription. onClose aborts THIS stream's controller — a + // stale stream closing can't cancel a newer subscription. + const ac = new AbortController(); + const stream = new SseStream( + res, + () => { + // Stream closed (tab close / network drop / crash): stop the event + // pump AND abort any in-flight prompt for this session — otherwise + // the agent keeps running (quota, FIFO) until idle TTL. + ac.abort(); + // BUT only abort the prompt when THIS is still the session's live + // stream. A reconnect already installed a newer stream — the prompt + // must survive the old stream's close. CONTRACT: this identity guard + // pairs with `attachSessionStream`'s install-before-close ordering + // (connectionRegistry.ts) — keep both in lockstep. + if (conn.sessions.get(sessionId)?.stream === stream) { + conn.sessions.get(sessionId)?.promptAbort?.abort(); + } + }, + () => conn.touch(), + ); + // Open (write SSE headers + `retry:`) BEFORE attaching, so the protocol + // handshake precedes any buffered frames the attach flushes. + stream.open(); + conn.attachSessionStream(sessionId, stream, ac); + // Identity-guarded close: only tear down if THIS stream is still the + // session's current one (a reconnect between settle and this microtask + // would otherwise kill the fresh stream). + const closeIfCurrent = () => { + if (conn.sessions.get(sessionId)?.stream === stream) { + conn.closeSessionStream(sessionId); + } + }; + void dispatcher.pumpSessionEvents(conn, sessionId, ac.signal).then( + // NORMAL completion (iterator returned `done` — subprocess ended): close + // so the stream isn't a zombie heartbeating with nothing left to deliver. + closeIfCurrent, + (err: unknown) => { + writeStderrLine( + `qwen serve: /acp event pump error (${sessionId}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + closeIfCurrent(); + }, + ); + }); + + // ── DELETE /acp ──────────────────────────────────────────────────── + app.delete(path, (req: Request, res: Response) => { + const connectionId = headerOf(req, ACP_CONNECTION_HEADER); + if (!connectionId) { + res.status(400).json({ error: 'Missing Acp-Connection-Id' }); + return; + } + // NOTE: like every other route, DELETE is gated only by the bearer + // token — the daemon's trust boundary is "holds the token for this + // single-workspace daemon", so any token-holder may tear down any + // connection (same posture as the REST `DELETE /session/:id`). A + // per-connection secret would add intra-token isolation; deferred with + // the rest of the multi-tenant hardening (design §7). + const existed = registry.delete(connectionId); + if (existed) { + writeStderrLine( + `qwen serve: /acp connection deleted ${connectionId.slice(0, 8)} (remaining=${registry.size})`, + ); + } + res.status(202).end(); + }); + + // ── WebSocket upgrade (ACP RFD) ──────────────────────────────────── + let wss: WebSocketServer | undefined; + let upgradeListener: + | ((req: IncomingMessage, socket: Duplex, head: Buffer) => void) + | undefined; + let upgradeServer: import('node:http').Server | undefined; + + function setupWebSocket(httpServer: import('node:http').Server): void { + if (wss) return; + wss = new WebSocketServer({ noServer: true, maxPayload: 10 * 1024 * 1024 }); + upgradeServer = httpServer; + const expectedTokenHash = opts.token + ? createHash('sha256').update(opts.token).digest() + : undefined; + + upgradeListener = (req: IncomingMessage, socket: Duplex, head: Buffer) => { + let url: URL; + try { + url = new URL( + req.url ?? '/', + `http://${req.headers.host ?? 'localhost'}`, + ); + } catch { + socket.destroy(); + return; + } + if (url.pathname !== path) { + socket.destroy(); + return; + } + + const fromLoopback = isLoopbackSocket(socket); + + // Host allowlist: mirror REST surface's hostAllowlist middleware + // (auth.ts:196). Prevents DNS-rebinding attacks where a malicious + // domain resolves to 127.0.0.1 and the browser sends the + // attacker's Host header. Match the full host:port string like + // the REST middleware does; extract port from the socket. + if (fromLoopback) { + const host = (req.headers['host'] ?? '').toLowerCase(); + const localPort = (socket as { localPort?: number }).localPort; + const allowed = new Set([ + `localhost:${localPort}`, + `127.0.0.1:${localPort}`, + `[::1]:${localPort}`, + `host.docker.internal:${localPort}`, + ]); + if (!allowed.has(host)) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } + } + + // CSRF: reject cross-origin WS upgrades. Browser-initiated requests + // to 127.0.0.1 carry the external origin, so this check must apply + // to loopback too (CSWSH defence). + const origin = req.headers['origin']; + if (origin) { + try { + const originHost = new URL(origin).hostname.replace(/^\[|\]$/g, ''); + if ( + originHost !== '127.0.0.1' && + originHost !== 'localhost' && + originHost !== '::1' + ) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } + } catch { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } + } + + // Auth: WS bypasses Express middleware. Same posture as REST: + // loopback without token = allow; non-loopback/token-mismatch = reject. + if (opts.token) { + const authHeader = req.headers['authorization']; + if (!authHeader || !authHeader.includes(' ')) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + const scheme = authHeader + .slice(0, authHeader.indexOf(' ')) + .toLowerCase(); + const credentials = authHeader + .slice(authHeader.indexOf(' ') + 1) + .trim(); + const actual = createHash('sha256').update(credentials).digest(); + if ( + scheme !== 'bearer' || + !expectedTokenHash || + !timingSafeEqual(expectedTokenHash, actual) + ) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + } else if (!fromLoopback) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } + + wss!.handleUpgrade(req, socket, head, (ws: WebSocket) => { + let initialized = false; + const initTimer = setTimeout(() => { + if (!initialized) { + writeStderrLine( + `qwen serve: /acp WS initialize timeout (30s) from ${rawAddr}`, + ); + ws.close(1002, 'Initialize timeout'); + } + }, 30_000); + initTimer.unref?.(); + let connRef: AcpConnection | undefined; + let messageQueue = Promise.resolve(); + const rawAddr = + (socket as unknown as { remoteAddress?: string }).remoteAddress ?? + 'ws-unknown'; + const wsKey = rawAddr.startsWith('::ffff:') + ? rawAddr.slice(7) + : rawAddr; + + ws.on('error', (err) => { + writeStderrLine( + `qwen serve: /acp WS error: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + + ws.on('message', (rawData: Buffer | string) => { + messageQueue = messageQueue + .then(() => handleWsMessage(rawData)) + .catch((err) => { + writeStderrLine( + `qwen serve: /acp WS message handler error: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + }); + + async function handleWsMessage( + rawData: Buffer | string, + ): Promise { + let text: string; + try { + text = + typeof rawData === 'string' ? rawData : rawData.toString('utf8'); + } catch { + ws.close(1003, 'Only text frames supported'); + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + ws.send( + JSON.stringify(rpcError(null, RPC.PARSE_ERROR, 'Parse error')), + ); + return; + } + + if (Array.isArray(parsed)) { + ws.send( + JSON.stringify({ + error: 'Batch JSON-RPC not supported', + }), + ); + return; + } + + const inbound = parseInbound(parsed); + if (!inbound.ok) { + ws.send(JSON.stringify(inbound.error)); + return; + } + const message = inbound.message; + + if (!initialized) { + if (!isRequest(message) || message.method !== 'initialize') { + ws.send( + JSON.stringify( + rpcError( + isRequest(message) ? message.id : null, + RPC.INVALID_REQUEST, + 'First message must be initialize', + ), + ), + ); + ws.close(1002, 'Protocol error'); + return; + } + + const conn = registry.create(fromLoopback); + if (!conn) { + ws.send( + JSON.stringify( + rpcError( + message.id, + RPC.INTERNAL_ERROR, + 'Too many connections', + ), + ), + ); + ws.close(1013, 'Connection cap'); + return; + } + + const requestedVersion = + message.params && + typeof message.params === 'object' && + !Array.isArray(message.params) + ? (message.params as Record)['protocolVersion'] + : undefined; + + // WS: single socket serves as conn stream + all session streams. + const stream = new WsStream( + ws, + () => { + writeStderrLine( + `qwen serve: /acp WS closed (${conn.connectionId.slice(0, 8)})`, + ); + registry.delete(conn.connectionId); + }, + () => conn.touch(), + ); + conn.attachConnStream(stream); + + ws.send( + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: dispatcher.buildInitializeResult( + conn.connectionId, + requestedVersion, + ), + }), + ); + + initialized = true; + clearTimeout(initTimer); + connRef = conn; + writeStderrLine( + `qwen serve: /acp WS established ${conn.connectionId.slice(0, 8)} (loopback=${fromLoopback}, active=${registry.size})`, + ); + return; + } + + // Subsequent messages + const conn = connRef; + if (!conn || conn.destroyed) { + ws.send( + JSON.stringify( + rpcError(null, RPC.INTERNAL_ERROR, 'Connection lost'), + ), + ); + ws.close(1011, 'Connection lost'); + return; + } + + // Lazy session stream attachment for WS + if ( + isRequest(message) && + message.params && + typeof message.params === 'object' + ) { + const sid = (message.params as Record)[ + 'sessionId' + ]; + if (typeof sid === 'string' && conn.ownsSession(sid)) { + const binding = conn.sessions.get(sid); + if ( + binding && + !binding.stream && + conn.connStream && + !conn.connStream.isClosed + ) { + const ac = new AbortController(); + conn.attachSessionStream(sid, conn.connStream, ac); + const myAbort = ac; + const cleanupSession = () => { + const b = conn.sessions.get(sid); + if (b?.stream === conn.connStream && b?.abort === myAbort) { + conn.closeSessionStream(sid); + } + }; + void dispatcher + .pumpSessionEvents(conn, sid, ac.signal) + .then(cleanupSession, (err: unknown) => { + writeStderrLine( + `qwen serve: /acp WS pump error (${sid}): ${err instanceof Error ? err.message : String(err)}`, + ); + cleanupSession(); + }); + } + } + } + + if (opts.checkRate && isRequest(message)) { + const m = message.method; + if (WS_EXEMPT_METHODS.has(m)) { + // Heartbeat + metadata update: exempt from rate limiting + // (mirrors REST resolveTier returning null for heartbeat) + } else { + const tier: RateLimitTier = + m === 'session/prompt' || m === '_qwen/session/prompt' + ? 'prompt' + : WS_READ_METHODS.has(m) + ? 'read' + : 'mutation'; + if (!opts.checkRate(wsKey, tier)) { + ws.send( + JSON.stringify( + rpcError( + message.id, + RPC.INTERNAL_ERROR, + 'Rate limit exceeded', + ), + ), + ); + return; + } + } + } + + // Prompt is long-running (minutes); awaiting it would block + // permission votes and cancel requests queued behind it → deadlock. + // Fire-and-forget so the message queue stays unblocked. + const isPrompt = + isRequest(message) && + (message.method === 'session/prompt' || + message.method === '_qwen/session/prompt'); + const dispatchP = dispatcher + .handle(conn, message, undefined, fromLoopback) + .catch((err: unknown) => { + writeStderrLine( + `qwen serve: /acp WS handle error: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + if (!isPrompt) await dispatchP; + } + }); + }; + httpServer.on('upgrade', upgradeListener!); + + writeStderrLine(`qwen serve: /acp WebSocket transport enabled on ${path}`); + } + + return { + dispose: () => { + if (upgradeServer && upgradeListener) { + upgradeServer.removeListener('upgrade', upgradeListener); + upgradeListener = undefined; + upgradeServer = undefined; + } + registry.dispose(); + if (wss) { + wss.close(); + wss = undefined; + } + }, + registry, + attachServer(server: import('node:http').Server) { + setupWebSocket(server); + }, + }; +} + +function headerOf(req: Request, name: string): string | undefined { + const v = req.headers[name]; + return Array.isArray(v) ? v[0] : v; +} + +/** + * True when the request's KERNEL-stamped peer address is loopback. Mirrors + * the REST surface's `detectFromLoopback` (NOT derived from forgeable + * headers like `X-Forwarded-For`). Replicated here rather than imported + * from `server.ts` to avoid a server↔acpHttp import cycle. + */ +function isLoopbackSocket(socket: Duplex): boolean { + const addr = (socket as unknown as { remoteAddress?: string }).remoteAddress; + if (typeof addr !== 'string') return false; + return ( + addr === '::1' || addr.startsWith('127.') || addr.startsWith('::ffff:127.') + ); +} + +function isLoopbackReq(req: Request): boolean { + const addr = req.socket?.remoteAddress; + if (typeof addr !== 'string') return false; + // Match the REST surface's `detectFromLoopback`: the full 127.0.0.0/8 + // range + the IPv4-mapped block, not just three exact literals (a + // container peer on 127.0.0.2 is legal loopback). + return ( + addr === '::1' || addr.startsWith('127.') || addr.startsWith('::ffff:127.') + ); +} diff --git a/packages/cli/src/serve/acpHttp/jsonRpc.test.ts b/packages/cli/src/serve/acpHttp/jsonRpc.test.ts new file mode 100644 index 0000000000..43281887ad --- /dev/null +++ b/packages/cli/src/serve/acpHttp/jsonRpc.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + isNotification, + isRequest, + isResponse, + parseInbound, + QWEN_METHOD_NS, + RPC, +} from './jsonRpc.js'; + +describe('jsonRpc helpers', () => { + it('classifies a request', () => { + const m = { jsonrpc: '2.0', id: 1, method: 'initialize' }; + expect(isRequest(m)).toBe(true); + expect(isNotification(m)).toBe(false); + expect(isResponse(m)).toBe(false); + }); + + it('classifies a notification (no id)', () => { + const m = { jsonrpc: '2.0', method: 'session/cancel' }; + expect(isNotification(m)).toBe(true); + expect(isRequest(m)).toBe(false); + }); + + it('classifies a response (result, no method)', () => { + const m = { jsonrpc: '2.0', id: -1, result: { ok: true } }; + expect(isResponse(m)).toBe(true); + expect(isRequest(m)).toBe(false); + }); + + it('classifies an error response', () => { + const m = { jsonrpc: '2.0', id: 2, error: { code: -1, message: 'x' } }; + expect(isResponse(m)).toBe(true); + }); + + it('rejects a response with BOTH result and error (XOR); parseInbound → 400-shape', () => { + const m = { + jsonrpc: '2.0', + id: 3, + result: {}, + error: { code: -1, message: 'x' }, + }; + expect(isResponse(m)).toBe(false); + const r = parseInbound(m); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.error.code).toBe(RPC.INVALID_REQUEST); + }); + + it('rejects JSON-RPC batch arrays', () => { + const r = parseInbound([{ jsonrpc: '2.0', id: 1, method: 'x' }]); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.error.code).toBe(RPC.INVALID_REQUEST); + }); + + it('rejects malformed envelopes', () => { + expect(parseInbound({ foo: 'bar' }).ok).toBe(false); + expect(parseInbound(null).ok).toBe(false); + }); + + it('accepts a well-formed request', () => { + const r = parseInbound({ jsonrpc: '2.0', id: 1, method: 'session/new' }); + expect(r.ok).toBe(true); + }); + + it('exposes the qwen extension namespace', () => { + expect(QWEN_METHOD_NS).toBe('_qwen/'); + }); +}); diff --git a/packages/cli/src/serve/acpHttp/jsonRpc.ts b/packages/cli/src/serve/acpHttp/jsonRpc.ts new file mode 100644 index 0000000000..71339e69c6 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/jsonRpc.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Minimal JSON-RPC 2.0 helpers for the ACP-over-HTTP transport + * (`packages/cli/src/serve/acpHttp/`). The official ACP Streamable HTTP + * transport (RFD #721) frames every message as a JSON-RPC 2.0 object; + * this module owns the wire types + parse/validate/serialize so the + * dispatcher stays focused on bridge routing. + * + * We hand-roll framing (rather than reuse `@agentclientprotocol/sdk`'s + * `ndJsonStream`) because the RFD splits a single logical connection + * across multiple long-lived SSE streams (connection-scoped + one per + * session), so outbound frames must be demultiplexed to the right + * stream — something a single duplex `Connection` can't express. + */ + +/** + * Vendor extension namespace. ACP reserves any `_`-prefixed method for + * extensions (the ONLY hard rule); the spec's `_zed.dev/…` example shows a + * domain-style segment by convention, but `qwen` is distinctive enough that + * we use the shorter bare form `_qwen/…`. Vendor data on standard messages + * goes under `_meta` keyed by the same name (`_meta: { "qwen": … }`). + */ +export const QWEN_METHOD_NS = '_qwen/'; +/** Key for vendor `_meta` blocks (capabilities + per-message data). */ +export const QWEN_META_KEY = 'qwen'; + +export type JsonRpcId = number | string; + +export interface JsonRpcRequest { + jsonrpc: '2.0'; + id: JsonRpcId; + method: string; + params?: unknown; +} + +export interface JsonRpcNotification { + jsonrpc: '2.0'; + method: string; + params?: unknown; +} + +export interface JsonRpcSuccess { + jsonrpc: '2.0'; + id: JsonRpcId; + result: unknown; +} + +export interface JsonRpcErrorObject { + code: number; + message: string; + data?: unknown; +} + +export interface JsonRpcError { + jsonrpc: '2.0'; + id: JsonRpcId | null; + error: JsonRpcErrorObject; +} + +export type JsonRpcOutbound = JsonRpcRequest | JsonRpcNotification; +export type JsonRpcResponse = JsonRpcSuccess | JsonRpcError; +export type JsonRpcInbound = + | JsonRpcRequest + | JsonRpcNotification + | JsonRpcResponse; + +/** Standard JSON-RPC 2.0 error codes. */ +export const RPC = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, +} as const; + +export function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +export function isRequest(m: unknown): m is JsonRpcRequest { + return ( + isObject(m) && + m['jsonrpc'] === '2.0' && + typeof m['method'] === 'string' && + 'id' in m && + m['id'] !== null && + (typeof m['id'] === 'number' || typeof m['id'] === 'string') + ); +} + +export function isNotification(m: unknown): m is JsonRpcNotification { + return ( + isObject(m) && + m['jsonrpc'] === '2.0' && + typeof m['method'] === 'string' && + !('id' in m) + ); +} + +export function isResponse(m: unknown): m is JsonRpcResponse { + return ( + isObject(m) && + m['jsonrpc'] === '2.0' && + !('method' in m) && + 'id' in m && + // JSON-RPC 2.0 §5: EXACTLY one of result/error (XOR). Accepting both + // would let a buggy client's approval (result + error) be misread as a + // cancellation by the `'error' in msg` check downstream. A dual-field + // message therefore fails isRequest/isNotification/isResponse → + // `parseInbound` rejects it → the POST handler returns 400 (logged by the + // malformed-request path in index.ts), so the client is told its vote was + // not accepted (not a silent drop); it can retry with a valid response, + // and teardown still releases the pending entry if it doesn't. + 'result' in m !== 'error' in m + ); +} + +/** + * Strip C0 control chars + DEL from values interpolated into operator-facing + * stderr logs, so a client-controlled `sessionId`/`method`/error string can't + * forge or split log lines (log injection). Shared by the transport modules. + */ +export function logSafe(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/[\u0000-\u001f\u007f\u0080-\u009f]/g, ' '); +} + +export function success(id: JsonRpcId, result: unknown): JsonRpcSuccess { + return { jsonrpc: '2.0', id, result }; +} + +export function error( + id: JsonRpcId | null, + code: number, + message: string, + data?: unknown, +): JsonRpcError { + return { + jsonrpc: '2.0', + id, + error: { code, message, ...(data !== undefined ? { data } : {}) }, + }; +} + +export function notification( + method: string, + params: unknown, +): JsonRpcNotification { + return { jsonrpc: '2.0', method, params }; +} + +export function request( + id: JsonRpcId, + method: string, + params: unknown, +): JsonRpcRequest { + return { jsonrpc: '2.0', id, method, params }; +} + +/** + * Parse a request body into a JSON-RPC message. Returns `{ ok: false }` + * with a ready-to-send error on malformed JSON or a non-conforming + * envelope (batch arrays are rejected per RFD §"batch → 501", surfaced + * here as INVALID_REQUEST since we never reach the 501 path). + */ +export function parseInbound( + raw: unknown, +): { ok: true; message: JsonRpcInbound } | { ok: false; error: JsonRpcError } { + if (Array.isArray(raw)) { + return { + ok: false, + error: error(null, RPC.INVALID_REQUEST, 'JSON-RPC batch not supported'), + }; + } + if (isRequest(raw) || isNotification(raw) || isResponse(raw)) { + return { ok: true, message: raw }; + } + return { + ok: false, + error: error(null, RPC.INVALID_REQUEST, 'Malformed JSON-RPC message'), + }; +} diff --git a/packages/cli/src/serve/acpHttp/sseStream.test.ts b/packages/cli/src/serve/acpHttp/sseStream.test.ts new file mode 100644 index 0000000000..500fe27072 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/sseStream.test.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Response } from 'express'; +import { SseStream } from './sseStream.js'; + +/** + * Minimal Express `Response` mock: an EventEmitter with the `write`/`end`/ + * header surface `SseStream` touches. `writeBehavior` lets a test force + * `res.write` to return false (backpressure) or throw (socket error). + */ +function mockRes(writeBehavior?: () => boolean) { + const ee = new EventEmitter() as unknown as Response & { + chunks: string[]; + ended: boolean; + }; + const m = ee as unknown as { + chunks: string[]; + ended: boolean; + writableEnded: boolean; + status: () => unknown; + setHeader: () => void; + flushHeaders: () => void; + write: (c: string) => boolean; + end: () => void; + req: EventEmitter; + }; + m.chunks = []; + m.ended = false; + m.writableEnded = false; + m.status = () => ee; + m.setHeader = () => {}; + m.flushHeaders = () => {}; + m.req = new EventEmitter(); + m.write = (chunk: string) => { + m.chunks.push(chunk); + return writeBehavior ? writeBehavior() : true; + }; + m.end = () => { + m.ended = true; + m.writableEnded = true; + }; + return ee as unknown as Response & { chunks: string[]; ended: boolean }; +} + +describe('SseStream', () => { + afterEach(() => vi.useRealTimers()); + + it('open() writes the retry hint; send() writes a data: frame', async () => { + const res = mockRes(); + const s = new SseStream(res); + s.open(); + await s.send({ jsonrpc: '2.0', id: 1, result: { ok: true } }); + const joined = (res as unknown as { chunks: string[] }).chunks.join(''); + expect(joined).toContain('retry: 3000'); + expect(joined).toContain( + 'data: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n', + ); + }); + + it('close() ends the response once and is idempotent', () => { + const res = mockRes(); + const s = new SseStream(res); + s.open(); + s.close(); + expect((res as unknown as { ended: boolean }).ended).toBe(true); + expect(s.isClosed).toBe(true); + s.close(); // no throw on double close + }); + + it('close() swallows a throwing onClose callback', () => { + const res = mockRes(); + const s = new SseStream(res, () => { + throw new Error('onClose boom'); + }); + s.open(); + expect(() => s.close()).not.toThrow(); + expect(s.isClosed).toBe(true); + }); + + it('a write failure closes the stream and fires onClose', async () => { + let closed = false; + const res = mockRes(() => { + throw new Error('EPIPE'); + }); + const s = new SseStream(res, () => { + closed = true; + }); + s.open(); // retry write throws → chain catch closes + await new Promise((r) => setTimeout(r, 10)); + expect(s.isClosed).toBe(true); + expect(closed).toBe(true); + }); + + it('heartbeat fires onHeartbeat on the interval', () => { + vi.useFakeTimers(); + let beats = 0; + const res = mockRes(); + const s = new SseStream(res, undefined, () => { + beats++; + }); + s.open(); + vi.advanceTimersByTime(15_000); + expect(beats).toBe(1); + vi.advanceTimersByTime(15_000); + expect(beats).toBe(2); + s.close(); + }); + + it('a req "close" event auto-closes the stream and fires onClose', () => { + let closed = false; + const res = mockRes(); + const s = new SseStream(res, () => { + closed = true; + }); + s.open(); + (res as unknown as { req: EventEmitter }).req.emit('close'); + expect(s.isClosed).toBe(true); + expect(closed).toBe(true); + }); + + it('a res "error" event auto-closes the stream', () => { + const res = mockRes(); + const s = new SseStream(res); + s.open(); + (res as unknown as EventEmitter).emit('error', new Error('ECONNRESET')); + expect(s.isClosed).toBe(true); + }); + + it('doWrite resolves after drain when write() returns false (backpressure)', async () => { + let backpressured = true; + const res = mockRes(() => !backpressured); // false first → drain needed + const s = new SseStream(res); + s.open(); + const p = s.send({ id: 2 }); + let settled = false; + void p.then(() => (settled = true)); + await new Promise((r) => setTimeout(r, 10)); + expect(settled).toBe(false); // still awaiting drain + backpressured = false; + (res as unknown as EventEmitter).emit('drain'); + await p; + expect(settled).toBe(true); + }); +}); diff --git a/packages/cli/src/serve/acpHttp/sseStream.ts b/packages/cli/src/serve/acpHttp/sseStream.ts new file mode 100644 index 0000000000..a0333e9b62 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/sseStream.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Response } from 'express'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; + +/** + * A long-lived Server-Sent-Events writer for the ACP-over-HTTP transport. + * + * Unlike the REST `/session/:id/events` stream (qwen event envelopes), the + * ACP transport carries raw JSON-RPC 2.0 objects as the SSE `data:` payload + * — one object per frame. The RFD keeps these streams open for the life of + * the connection/session, so the writer must: + * - serialize writes through a single chain (heartbeat can't interleave), + * - respect backpressure (`res.write` → false ⇒ await `drain`), + * - emit periodic comment heartbeats to keep NAT/proxies alive. + * + * This mirrors the battle-tested pattern in `server.ts`'s SSE handler but + * trimmed to what the ACP transport needs (no ring-buffer `id:` sequencing — + * resumability is RFD Phase 4, deferred per the design doc §7). + */ +export class SseStream { + private writeChain: Promise = Promise.resolve(); + private heartbeat: ReturnType | undefined; + private closed = false; + private cleanupFn: (() => void) | undefined; + + constructor( + private readonly res: Response, + private readonly onClose?: () => void, + /** + * Fired on each heartbeat tick while the stream is open. Used to mark the + * connection active so a long-running prompt that emits no intermediate + * frames for >30 min isn't reaped by the idle-TTL sweep. + */ + private readonly onHeartbeat?: () => void, + ) {} + + /** Write SSE headers + retry hint and start the heartbeat. */ + open(): void { + this.res.status(200); + this.res.setHeader('Content-Type', 'text/event-stream'); + this.res.setHeader('Cache-Control', 'no-cache, no-transform'); + this.res.setHeader('Connection', 'keep-alive'); + this.res.setHeader('X-Accel-Buffering', 'no'); + this.res.flushHeaders(); + void this.writeRaw('retry: 3000\n\n'); + + this.heartbeat = setInterval(() => { + if (this.closed) return; + this.onHeartbeat?.(); + void this.writeRaw(': hb\n\n'); + }, 15_000); + this.heartbeat.unref(); + + this.cleanupFn = () => this.close(); + this.res.req.on('close', this.cleanupFn); + this.res.on('error', this.cleanupFn); + } + + /** Serialize a JSON-RPC message as one SSE frame. */ + send(message: unknown): Promise { + return this.writeRaw(`data: ${JSON.stringify(message)}\n\n`); + } + + get isClosed(): boolean { + return this.closed; + } + + close(): void { + if (this.closed) return; + this.closed = true; + if (this.heartbeat) clearInterval(this.heartbeat); + if (this.cleanupFn) { + this.res.req.off('close', this.cleanupFn); + this.res.off('error', this.cleanupFn); + this.cleanupFn = undefined; + } + try { + if (!this.res.writableEnded) this.res.end(); + } catch { + // socket already gone — nothing to flush + } + // Guard `onClose`: `close()` can run inside a socket `'error'`/`'close'` + // event handler, and a throwing callback there would escape into Node's + // emitter stack (potential crash). Swallow + log instead. + try { + this.onClose?.(); + } catch (err) { + writeStderrLine( + `qwen serve: /acp SSE onClose threw: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + private writeRaw(chunk: string): Promise { + const next = this.writeChain.then(() => this.doWrite(chunk)); + // The stream OWNS write-failure handling: callers fire-and-forget + // (`void stream.send(...)`), so a broken socket would otherwise leave a + // zombie stream (heartbeats firing, no events delivered, no log). On the + // first failure, log once and close so the subscription tears down. + this.writeChain = next.catch((err: unknown) => { + if (!this.closed) { + writeStderrLine( + `qwen serve: /acp SSE write failed, closing stream: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + this.close(); + } + return undefined; + }); + return next; + } + + private doWrite(chunk: string): Promise { + return new Promise((resolve, reject) => { + if (this.closed || this.res.writableEnded) { + resolve(); + return; + } + let ok: boolean; + try { + ok = this.res.write(chunk); + } catch (err) { + reject(err as Error); + return; + } + if (ok) { + resolve(); + return; + } + const cleanup = () => { + this.res.off('drain', onDrain); + this.res.off('close', onCloseEv); + this.res.off('error', onErrorEv); + }; + const onDrain = () => { + cleanup(); + resolve(); + }; + const onCloseEv = () => { + cleanup(); + resolve(); + }; + const onErrorEv = (err: Error) => { + cleanup(); + reject(err); + }; + this.res.once('drain', onDrain); + this.res.once('close', onCloseEv); + this.res.once('error', onErrorEv); + }); + } +} diff --git a/packages/cli/src/serve/acpHttp/transport.test.ts b/packages/cli/src/serve/acpHttp/transport.test.ts new file mode 100644 index 0000000000..72c0da78f1 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/transport.test.ts @@ -0,0 +1,2322 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import WebSocket from 'ws'; +import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; +import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; +import type { DaemonWorkspaceService } from '../workspace-service/types.js'; +import { mountAcpHttp } from './index.js'; + +const stdioMocks = vi.hoisted(() => ({ + writeStderrLine: vi.fn(), +})); + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStderrLine: stdioMocks.writeStderrLine, +})); + +/** + * End-to-end transport test: boots a real Express server with the ACP + * Streamable-HTTP transport mounted over a *fake* bridge, then drives it + * with a real HTTP client (global fetch + manual SSE parsing). This is + * the automated form of the design doc's local verification plan — it + * exercises the actual wire protocol (200/202 conventions, both SSE + * streams, JSON-RPC framing) without needing a model. + */ + +interface PushIterable { + iterable: AsyncIterable; + push: (e: Omit) => void; + end: () => void; +} + +function pushQueue(signal?: AbortSignal): PushIterable { + const buf: BridgeEvent[] = []; + let resolveNext: (() => void) | undefined; + let done = false; + let nextId = 1; + const wake = () => { + resolveNext?.(); + resolveNext = undefined; + }; + signal?.addEventListener('abort', () => { + done = true; + wake(); + }); + const iterable: AsyncIterable = { + async *[Symbol.asyncIterator]() { + while (true) { + while (buf.length) yield buf.shift()!; + if (done) return; + await new Promise((r) => (resolveNext = r)); + } + }, + }; + return { + iterable, + push: (e) => { + buf.push({ v: 1, id: nextId++, ...e } as BridgeEvent); + wake(); + }, + end: () => { + done = true; + wake(); + }, + }; +} + +// A controllable fake bridge: tests register what `sendPrompt` should do. +class FakeBridge { + queues = new Map(); + promptBehavior: + | (( + sessionId: string, + q: PushIterable, + signal?: AbortSignal, + ) => Promise) + | undefined; + lastSetModel: unknown; + lastSpawnScope: string | undefined; + closeShouldThrow = false; + killed: string[] = []; + cancelled: string[] = []; + /** When set, spawnOrAttach/loadSession await it (to simulate a slow bridge). */ + gate: Promise | undefined; + /** `attached` value loadSession returns (false = spawned-from-disk). */ + loadAttached = true; + + closedSessions: string[] = []; + + async spawnOrAttach(req: { sessionScope?: string }) { + this.lastSpawnScope = req?.sessionScope; + if (this.gate) await this.gate; + return { + sessionId: 'sess-1', + workspaceCwd: '/ws', + attached: false, + clientId: 'client-1', + }; + } + async killSession(sessionId: string) { + this.killed.push(sessionId); + } + + loadShouldThrow = false; + + async loadSession(req: { sessionId: string }) { + if (this.loadShouldThrow) throw new Error('load failed'); + if (this.gate) await this.gate; + return { + sessionId: req.sessionId, + workspaceCwd: '/ws', + attached: this.loadAttached, + clientId: 'client-load', + state: { replayed: true }, + }; + } + + async resumeSession(req: { sessionId: string }) { + return { + sessionId: req.sessionId, + workspaceCwd: '/ws', + attached: true, + clientId: 'client-resume', + state: { resumed: true }, + }; + } + + subscribeThrows = false; + + subscribeEvents(sessionId: string, opts?: { signal?: AbortSignal }) { + if (this.subscribeThrows) throw new Error('subscribe failed'); + const q = pushQueue(opts?.signal); + this.queues.set(sessionId, q); + return q.iterable; + } + + async sendPrompt(sessionId: string, _req: unknown, signal?: AbortSignal) { + const q = this.queues.get(sessionId); + if (this.promptBehavior && q) + return this.promptBehavior(sessionId, q, signal); + return { stopReason: 'end_turn' }; + } + + respondToSessionPermission() { + return true; + } + + async setSessionModel(_s: string, req: unknown) { + this.lastSetModel = req; + return { modelServiceId: 'qwen-max' }; + } + + lastApprovalMode: string | undefined; + async setSessionApprovalMode(_s: string, mode: string) { + this.lastApprovalMode = mode; + return { sessionId: 'sess-1', mode, previous: 'default', persisted: false }; + } + + // Session config options live in the child's session context state. + async getSessionContextStatus(sessionId: string) { + return { + v: 1, + sessionId, + workspaceCwd: '/ws', + state: { + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'qwen-max', + options: [], + }, + ], + }, + }; + } + async getSessionSupportedCommandsStatus(sessionId: string) { + return { v: 1, sessionId, availableCommands: [], availableSkills: [] }; + } + updateSessionMetadata(_s: string, metadata: unknown) { + return metadata; + } + + recordHeartbeat() { + return { sessionId: 'sess-1', lastSeenAt: Date.now() }; + } + + listWorkspaceSessions() { + return []; + } + + detached: Array<{ sessionId: string; clientId?: string }> = []; + + async cancelSession(sessionId: string) { + this.cancelled.push(sessionId); + } + closeGate: Promise | undefined; + async closeSession(sessionId: string) { + this.closedSessions.push(sessionId); + if (this.closeGate) await this.closeGate; + if (this.closeShouldThrow) throw new Error('bridge close failed'); + } + async detachClient(sessionId: string, clientId?: string) { + this.detached.push({ sessionId, clientId }); + } + async preheat() {} + + // Wave 1+2 stubs + async generateSessionRecap(sessionId: string) { + return { sessionId, recap: 'test recap' }; + } + async generateSessionBtw(sessionId: string, question: string) { + return { sessionId, answer: `re: ${question}` }; + } + async executeShellCommand(sessionId: string, command: string) { + return { exitCode: 0, output: `$ ${command}`, aborted: false }; + } + async getSessionContextUsageStatus(sessionId: string) { + return { sessionId, used: 100, total: 1000 }; + } + async getSessionTasksStatus(sessionId: string) { + return { sessionId, tasks: [] }; + } + async getWorkspaceToolsStatus() { + return { v: 1, tools: [] }; + } + async getWorkspaceMcpToolsStatus(serverName: string) { + return { v: 1, serverName, tools: [] }; + } + async addRuntimeMcpServer(name: string) { + return { + name, + transport: 'stdio', + replaced: false, + shadowedSettings: false, + toolCount: 0, + originatorClientId: 'c', + }; + } + async removeRuntimeMcpServer(name: string) { + return { + name, + removed: true, + wasShadowingSettings: false, + originatorClientId: 'c', + }; + } + publishWorkspaceEvent() {} + knownClientIds() { + return new Set(); + } +} + +// A minimal fake workspace service for dispatch tests. +const fakeWorkspace = { + async getWorkspaceMcpStatus() { + return { ok: true, v: 1, workspaceCwd: '/ws' }; + }, + async getWorkspaceSkillsStatus() { + return { ok: true }; + }, + async getWorkspaceProvidersStatus() { + return { ok: true }; + }, + async getWorkspaceEnvStatus() { + return { ok: true }; + }, + async getWorkspacePreflightStatus() { + return { ok: true }; + }, + async setWorkspaceToolEnabled( + _ctx: unknown, + toolName: string, + enabled: boolean, + ) { + return { toolName, enabled }; + }, + async initWorkspace() { + return { path: '/ws/QWEN.md', action: 'created' as const }; + }, + async restartMcpServer() { + return { ok: true }; + }, + async reload() { + return { + env: { updatedKeys: [], removedKeys: [] }, + changedKeys: [], + childReloaded: false, + }; + }, +} as unknown as DaemonWorkspaceService; + +// ── SSE client helper ──────────────────────────────────────────────── +async function* readSse( + res: Response, + signal: AbortSignal, +): AsyncGenerator { + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + signal.addEventListener('abort', () => void reader.cancel().catch(() => {})); + while (true) { + const { value, done } = await reader.read(); + if (done) return; + buf += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buf.indexOf('\n\n')) !== -1) { + const frame = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const dataLine = frame.split('\n').find((l) => l.startsWith('data: ')); + if (dataLine) yield JSON.parse(dataLine.slice('data: '.length)); + } + } +} + +/** Read the next N data frames from an SSE response, then abort. */ +async function takeFrames( + res: Response, + n: number, + timeoutMs = 2000, +): Promise { + const out: unknown[] = []; + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + for await (const f of readSse(res, ac.signal)) { + out.push(f); + if (out.length >= n) break; + } + } finally { + clearTimeout(timer); + ac.abort(); + } + return out; +} + +function frameReader(res: Response) { + const ac = new AbortController(); + const iterator = readSse(res, ac.signal)[Symbol.asyncIterator](); + return { + async next(timeoutMs = 2000): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + ac.abort(); + reject(new Error('Timed out waiting for SSE frame')); + }, timeoutMs); + }); + try { + const result = await Promise.race([iterator.next(), timeout]); + if (result.done) throw new Error('SSE stream ended'); + return result.value; + } finally { + if (timer) clearTimeout(timer); + } + }, + close(): void { + ac.abort(); + }, + }; +} + +async function waitUntil( + predicate: () => boolean, + timeoutMs = 2000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error('Timed out waiting for condition'); +} + +describe('ACP Streamable HTTP transport (over the wire)', () => { + let server: Server; + let base: string; + let bridge: FakeBridge; + + beforeEach(async () => { + stdioMocks.writeStderrLine.mockClear(); + bridge = new FakeBridge(); + const app = express(); + app.use(express.json()); + mountAcpHttp(app, bridge as unknown as HttpAcpBridge, { + boundWorkspace: '/ws', + workspace: fakeWorkspace, + enabled: true, + }); + await new Promise((resolve) => { + server = app.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = server.address() as AddressInfo; + base = `http://127.0.0.1:${addr.port}`; + }); + + afterEach(async () => { + // Force-close any long-lived SSE sockets a test left open so + // `server.close()` doesn't hang on them. + server.closeAllConnections?.(); + await new Promise((r) => server.close(() => r())); + }); + + async function initialize(): Promise { + const res = await fetch(`${base}/acp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }), + }); + expect(res.status).toBe(200); + const connId = res.headers.get('acp-connection-id'); + expect(connId).toBeTruthy(); + const body = (await res.json()) as { result: { protocolVersion: number } }; + expect(body.result.protocolVersion).toBe(1); + return connId!; + } + + function post(connId: string, msg: unknown) { + return fetch(`${base}/acp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'acp-connection-id': connId, + }, + body: JSON.stringify(msg), + }); + } + + function openStream(connId: string, sessionId?: string) { + const headers: Record = { + accept: 'text/event-stream', + 'acp-connection-id': connId, + }; + if (sessionId) headers['acp-session-id'] = sessionId; + return fetch(`${base}/acp`, { headers }); + } + + // Establish ownership of the fake bridge's session ('sess-1') so the + // ownership-gated session stream + per-session POSTs are allowed. + async function newSession(connId: string, id = 99): Promise { + await post(connId, { + jsonrpc: '2.0', + id, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); // let handle() register ownership + } + + it('initialize → 200 + Acp-Connection-Id; unknown conn → 404', async () => { + await initialize(); + const bad = await post('nope', { + jsonrpc: '2.0', + id: 2, + method: 'session/new', + }); + expect(bad.status).toBe(404); + }); + + it('session/new reply rides the connection-scoped stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + // Give the SSE handshake a tick before POSTing. + await new Promise((r) => setTimeout(r, 50)); + const ack = await post(connId, { + jsonrpc: '2.0', + id: 2, + method: 'session/new', + params: { cwd: '/ws' }, + }); + expect(ack.status).toBe(202); + const [frame] = (await got) as Array<{ + id: number; + result: { sessionId: string }; + }>; + expect(frame.id).toBe(2); + expect(frame.result.sessionId).toBe('sess-1'); + }); + + it('prompt streams session/update then the final result', async () => { + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'session_update', + data: { + sessionId: 'sess-1', + update: { sessionUpdate: 'agent_message_chunk' }, + }, + }); + await new Promise((r) => setTimeout(r, 20)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 2); + await new Promise((r) => setTimeout(r, 50)); + const ack = await post(connId, { + jsonrpc: '2.0', + id: 5, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + expect(ack.status).toBe(202); + const frames = (await got) as Array>; + expect(frames[0]['method']).toBe('session/update'); + expect( + (frames[1] as { id: number; result: { stopReason: string } }).id, + ).toBe(5); + expect( + (frames[1] as { result: { stopReason: string } }).result.stopReason, + ).toBe('end_turn'); + }); + + it('permission request round-trips agent→client→agent', async () => { + let resolvedWith: unknown; + bridge.respondToSessionPermission = (( + _s: string, + _r: string, + resp: unknown, + ) => { + resolvedWith = resp; + return true; + }) as never; + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'permission_request', + data: { + requestId: 'perm-1', + sessionId: 'sess-1', + toolCall: { name: 'shell' }, + options: [{ optionId: 'allow', name: 'Allow' }], + }, + }); + await new Promise((r) => setTimeout(r, 30)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const reader = frameReader(sessStream); + try { + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 7, + method: 'session/prompt', + params: { + sessionId: 'sess-1', + prompt: [{ type: 'text', text: 'rm' }], + }, + }); + const reqFrame = (await reader.next()) as { + id: number; + method: string; + params: { _meta: Record }; + }; + expect(reqFrame.method).toBe('session/request_permission'); + expect(reqFrame.params._meta['qwen'].requestId).toBe('perm-1'); + // Client answers with a JSON-RPC response echoing the issued id. + await post(connId, { + jsonrpc: '2.0', + id: reqFrame.id, + result: { outcome: { outcome: 'selected', optionId: 'allow' } }, + }); + await waitUntil(() => resolvedWith !== undefined); + expect(resolvedWith).toEqual({ + outcome: { outcome: 'selected', optionId: 'allow' }, + }); + } finally { + reader.close(); + } + }); + + it('standard session/set_config_option (model) routes to the bridge', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 9, + method: 'session/set_config_option', + params: { sessionId: 'sess-1', configId: 'model', value: 'qwen-max' }, + }); + const [frame] = (await got) as Array<{ + id: number; + result: { configOptions: unknown }; + }>; + expect(frame.id).toBe(9); + expect(bridge.lastSetModel).toMatchObject({ modelId: 'qwen-max' }); + }); + + it('session/set_config_option (mode) routes to setSessionApprovalMode', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 10, + method: 'session/set_config_option', + params: { sessionId: 'sess-1', configId: 'mode', value: 'yolo' }, + }); + await got; + expect(bridge.lastApprovalMode).toBe('yolo'); + }); + + it('_qwen/workspace/mcp introspection reaches the bridge', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 12, + method: '_qwen/workspace/mcp', + }); + const [frame] = (await got) as Array<{ + id: number; + result: { ok: boolean }; + }>; + expect(frame.id).toBe(12); + expect(frame.result.ok).toBe(true); + }); + + it('unknown method → JSON-RPC method-not-found on conn stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { jsonrpc: '2.0', id: 11, method: 'bogus/method' }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32601); + }); + + it('session stream for an unowned session → 403', async () => { + const connId = await initialize(); + // No session/new → connection does not own 'sess-1'. + const res = await openStream(connId, 'sess-1'); + expect(res.status).toBe(403); + }); + + it('prompt for an unowned session → INVALID_PARAMS on conn stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 13, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32602); + }); + + it('Acp-Session-Id header that disagrees with params.sessionId → INVALID_PARAMS', async () => { + // Cross-check fires before ownership, so no session/new needed (and + // skipping it keeps a buffered session/new reply off the conn stream). + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await fetch(`${base}/acp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'acp-connection-id': connId, + 'acp-session-id': 'sess-1', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 14, + method: 'session/prompt', + params: { sessionId: 'OTHER', prompt: [{ type: 'text', text: 'x' }] }, + }), + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32602); + }); + + it('session/load owns the session + replies state on the conn stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 20, + method: 'session/load', + params: { sessionId: 'loaded-1' }, + }); + const [frame] = (await got) as Array<{ + id: number; + result: { replayed: boolean }; + }>; + expect(frame.id).toBe(20); + expect(frame.result.replayed).toBe(true); + // Ownership was granted, so the session stream is now allowed. + const sess = await openStream(connId, 'loaded-1'); + expect(sess.status).toBe(200); + await sess.body?.cancel(); // release the long-lived SSE socket + }); + + it('session/resume owns the session + replies state', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 21, + method: 'session/resume', + params: { sessionId: 'resumed-1' }, + }); + const [frame] = (await got) as Array<{ + id: number; + result: { resumed: boolean }; + }>; + expect(frame.id).toBe(21); + expect(frame.result.resumed).toBe(true); + }); + + it('session/close reaches the bridge + replies on the conn stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + // 2 frames: the session/new reply (establishes ownership), then close. + const got = takeFrames(connStream, 2); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 22, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + const frames = (await got) as Array<{ id: number }>; + expect(frames.map((f) => f.id)).toContain(22); + expect(bridge.closedSessions).toContain('sess-1'); + }); + + it('initialize clamps protocolVersion to [1, 1]', async () => { + for (const [requested, expected] of [ + [0, 1], + [-3, 1], + [99, 1], + ['bad', 1], + ] as Array<[unknown, number]>) { + const res = await fetch(`${base}/acp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: requested }, + }), + }); + const body = (await res.json()) as { + result: { protocolVersion: number }; + }; + expect(body.result.protocolVersion).toBe(expected); + } + }); + + it('session/load failure routes the error to the connection stream', async () => { + bridge.loadShouldThrow = true; + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 30, + method: 'session/load', + params: { sessionId: 'x' }, + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.id).toBe(30); + expect(frame.error.code).toBe(-32603); + }); + + it('connection teardown detaches the session client from the bridge', async () => { + const connId = await initialize(); + await newSession(connId); + await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + await new Promise((r) => setTimeout(r, 20)); + expect(bridge.detached.some((d) => d.sessionId === 'sess-1')).toBe(true); + }); + + it('malformed permission response still releases the bridge (cancel fallback)', async () => { + const votes: Array<{ outcome?: { outcome?: string } }> = []; + // Emulate the real bridge: throw on a vote with no `outcome`. + bridge.respondToSessionPermission = (( + _s: string, + _r: string, + resp: unknown, + ) => { + const r = resp as { outcome?: { outcome?: string } }; + if (!r?.outcome?.outcome) throw new Error('invalid permission response'); + votes.push(r); + return true; + }) as never; + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'permission_request', + data: { + requestId: 'perm-x', + sessionId: 'sess-1', + toolCall: {}, + options: [{ optionId: 'allow' }], + }, + }); + await new Promise((r) => setTimeout(r, 40)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 50, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'x' }] }, + }); + const [reqFrame] = (await got) as Array<{ id: string }>; + // Client answers with a malformed result (no outcome) → bridge throws → + // fallback must still cancel so the mediator is released. + await post(connId, { jsonrpc: '2.0', id: reqFrame.id, result: {} }); + await new Promise((r) => setTimeout(r, 50)); + expect(votes).toContainEqual({ outcome: { outcome: 'cancelled' } }); + }); + + it('a second concurrent prompt aborts the first', async () => { + let firstSignal: AbortSignal | undefined; + bridge.promptBehavior = async (_s, _q, signal) => { + if (!firstSignal) { + firstSignal = signal; + await new Promise((r) => + signal?.addEventListener('abort', () => r(), { once: true }), + ); + return { stopReason: 'cancelled' }; + } + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const drain = takeFrames(sessStream, 2); // both prompt results + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 60, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'a' }] }, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'b' }] }, + }); + await drain; + expect(firstSignal?.aborted).toBe(true); + }); + + it('subscribeEvents throwing closes the session stream promptly (no zombie)', async () => { + bridge.subscribeThrows = true; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + // The guarantee is that the server CLOSES the stream (not a zombie that + // heartbeats forever). A safety abort at 3s distinguishes "server closed" + // (loop ends fast) from "zombie" (only our timeout ends it). + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 3000); + const start = Date.now(); + try { + for await (const _f of readSse(sessStream, ac.signal)) { + // drain + } + } finally { + clearTimeout(timer); + ac.abort(); + } + // Server-initiated close arrives well under the 3s safety timeout. + expect(Date.now() - start).toBeLessThan(1500); + }); + + it('concurrent session/close calls the bridge exactly once (no TOCTOU double-close)', async () => { + const connId = await initialize(); + await newSession(connId); + await Promise.all([ + post(connId, { + jsonrpc: '2.0', + id: 70, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }), + post(connId, { + jsonrpc: '2.0', + id: 71, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }), + ]); + await new Promise((r) => setTimeout(r, 50)); + expect(bridge.closedSessions.filter((s) => s === 'sess-1')).toHaveLength(1); + }); + + it('clean iterator end closes the session stream (no zombie)', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + await new Promise((r) => setTimeout(r, 50)); + // Subprocess ends cleanly → bridge event iterator returns done. + bridge.queues.get('sess-1')?.end(); + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 3000); + const start = Date.now(); + try { + for await (const _f of readSse(sessStream, ac.signal)) { + // drain + } + } finally { + clearTimeout(timer); + ac.abort(); + } + expect(Date.now() - start).toBeLessThan(1500); + }); + + it('session-stream reconnect does NOT abort the in-flight prompt', async () => { + let promptSignal: AbortSignal | undefined; + bridge.promptBehavior = async (_s, q, signal) => { + promptSignal = signal; + q.push({ + type: 'session_update', + data: { sessionId: 'sess-1', update: {} }, + }); + await new Promise((r) => setTimeout(r, 200)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const s1 = await openStream(connId, 'sess-1'); + await new Promise((r) => setTimeout(r, 40)); + await post(connId, { + jsonrpc: '2.0', + id: 80, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + await new Promise((r) => setTimeout(r, 40)); + // Reconnect: install the NEW stream and let it attach FIRST, then drop the + // old one. This deterministically exercises the invariant under test — + // the old (now-stale) stream's close must NOT abort the prompt because a + // newer stream is already the session's current one (install-before-close + // + identity-guarded onClose). (Attaching s2 before dropping s1 avoids a + // test-only race between s1.close and s2.attach under full-suite load.) + const s2 = await openStream(connId, 'sess-1'); + await new Promise((r) => setTimeout(r, 40)); + await s1.body?.cancel(); + await new Promise((r) => setTimeout(r, 40)); + // The prompt must survive the reconnect. + expect(promptSignal?.aborted).toBe(false); + await s2.body?.cancel(); + }); + + it('prompt response is delivered even if the session closes mid-flight', async () => { + // Prompt resolves only after we close the session — exercises the + // binding-gone fallback (reply must ride the connection stream). + let release: () => void = () => {}; + bridge.promptBehavior = async (_s, _q) => { + await new Promise((r) => (release = r)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const connStream = await openStream(connId); + const sessStream = await openStream(connId, 'sess-1'); + // conn stream carries: buffered session/new reply (id 99), the close + // ack (id 91), AND the fallback prompt reply (id 90). + const connFrames = takeFrames(connStream, 3); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 90, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + await new Promise((r) => setTimeout(r, 30)); + // Close the session while the prompt is still in flight, then let it resolve. + await post(connId, { + jsonrpc: '2.0', + id: 91, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 30)); + release(); + const frames = (await connFrames) as Array<{ id: number }>; + // The prompt's id-90 response must appear (on the conn stream, since the + // session binding is gone) — not silently dropped. + expect(frames.map((f) => f.id)).toContain(90); + await sessStream.body?.cancel(); + }); + + it('session/set_config_option rejects empty value (INVALID_PARAMS)', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 41, + method: 'session/set_config_option', + params: { sessionId: 'sess-1', configId: 'model', value: '' }, + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32602); + }); + + it('session/set_config_option rejects an invalid mode value', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 42, + method: 'session/set_config_option', + params: { sessionId: 'sess-1', configId: 'mode', value: 'bogus-mode' }, + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32602); + expect(bridge.lastApprovalMode).toBeUndefined(); + }); + + it('session/new forwards sessionScope; rejects invalid scope', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + // invalid scope → error on conn stream + await post(connId, { + jsonrpc: '2.0', + id: 43, + method: 'session/new', + params: { sessionScope: 'bogus' }, + }); + const [bad] = (await got) as Array<{ error: { code: number } }>; + expect(bad.error.code).toBe(-32602); + // valid scope → forwarded to bridge + const c2 = await initialize(); + await post(c2, { + jsonrpc: '2.0', + id: 44, + method: 'session/new', + params: { sessionScope: 'thread' }, + }); + await new Promise((r) => setTimeout(r, 30)); + expect(bridge.lastSpawnScope).toBe('thread'); + }); + + it('session/prompt with empty prompt → INVALID_PARAMS', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 45, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [] }, + }); + const [frame] = (await got) as Array<{ error: { code: number } }>; + expect(frame.error.code).toBe(-32602); + }); + + it('session/close runs local cleanup even if the bridge close throws', async () => { + bridge.closeShouldThrow = true; + const connId = await initialize(); + await newSession(connId); // creates + owns sess-1 + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 46, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 50)); + expect(bridge.closedSessions).toContain('sess-1'); // bridge was called (then threw) + // Local teardown ran in `finally` despite the throw → session unowned now. + const after = await openStream(connId, 'sess-1'); + expect(after.status).toBe(403); + }); + + it('connection cap → 503 on initialize', async () => { + const app2 = express(); + app2.use(express.json()); + mountAcpHttp(app2, bridge as unknown as HttpAcpBridge, { + boundWorkspace: '/ws', + workspace: fakeWorkspace, + enabled: true, + maxConnections: 1, + }); + const srv = app2.listen(0, '127.0.0.1'); + await new Promise((r) => srv.once('listening', r)); + const port = (srv.address() as AddressInfo).port; + const url = `http://127.0.0.1:${port}/acp`; + const init = (n: number) => + fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: n, method: 'initialize' }), + }); + const r1 = await init(1); + expect(r1.status).toBe(200); + const r2 = await init(2); + expect(r2.status).toBe(503); + expect(r2.headers.get('retry-after')).toBe('5'); + srv.closeAllConnections?.(); + await new Promise((r) => srv.close(() => r())); + }); + + it('session/cancel aborts the in-flight prompt and calls the bridge', async () => { + let promptSignal: AbortSignal | undefined; + bridge.promptBehavior = async (_s, _q, signal) => { + promptSignal = signal; + await new Promise((r) => setTimeout(r, 300)); + return { stopReason: 'cancelled' }; + }; + const connId = await initialize(); + await newSession(connId); + const sess = await openStream(connId, 'sess-1'); + await new Promise((r) => setTimeout(r, 40)); + await post(connId, { + jsonrpc: '2.0', + id: 50, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + await new Promise((r) => setTimeout(r, 40)); + await post(connId, { + jsonrpc: '2.0', + id: 51, + method: 'session/cancel', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 40)); + expect(promptSignal?.aborted).toBe(true); + expect(bridge.cancelled).toContain('sess-1'); + await sess.body?.cancel(); + }); + + it('session/new rejects bad cwd (non-string + relative) → INVALID_PARAMS', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 2); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 60, + method: 'session/new', + params: { cwd: 123 }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: 'session/new', + params: { cwd: 'rel/path' }, + }); + const frames = (await got) as Array<{ + id: number; + error?: { code: number }; + }>; + for (const f of frames) expect(f.error?.code).toBe(-32602); + }); + + it('session/new orphan: DELETE before spawn resolves → bridge.killSession', async () => { + let release: () => void = () => {}; + bridge.gate = new Promise((r) => (release = r)); + const connId = await initialize(); + await post(connId, { + jsonrpc: '2.0', + id: 70, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); // spawnOrAttach now awaiting the gate + await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + release(); // spawn resolves AFTER destroy + await new Promise((r) => setTimeout(r, 40)); + expect(bridge.killed).toContain('sess-1'); + }); + + it('session/load orphan (attached:false) → killSession, not detach', async () => { + let release: () => void = () => {}; + bridge.gate = new Promise((r) => (release = r)); + bridge.loadAttached = false; // restore SPAWNED from disk → must be killed + const connId = await initialize(); + await post(connId, { + jsonrpc: '2.0', + id: 80, + method: 'session/load', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 30)); + await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + release(); + await new Promise((r) => setTimeout(r, 40)); + expect(bridge.killed).toContain('sess-1'); + expect(bridge.detached.some((d) => d.sessionId === 'sess-1')).toBe(false); + }); + + it('_qwen/* introspection methods reach the bridge (conn-routed)', async () => { + const connId = await initialize(); + await newSession(connId); + const connStream = await openStream(connId); + // 4 frames: buffered session/new reply (id 99) + the 3 below. + const got = takeFrames(connStream, 4); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 200, + method: '_qwen/session/context', + params: { sessionId: 'sess-1' }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 201, + method: '_qwen/session/heartbeat', + params: { sessionId: 'sess-1' }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 202, + method: '_qwen/workspace/skills', + }); + const ids = ((await got) as Array<{ id?: number }>).map((f) => f.id); + expect(ids).toEqual(expect.arrayContaining([200, 201, 202])); + }); + + it('_qwen/workspace/set_tool_enabled + restart_mcp_server validate name', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 3); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 210, + method: '_qwen/workspace/set_tool_enabled', + params: { toolName: '', enabled: true }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 211, + method: '_qwen/workspace/restart_mcp_server', + params: { serverName: '' }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 212, + method: '_qwen/workspace/set_tool_enabled', + params: { toolName: 'shell', enabled: false }, + }); + const frames = (await got) as Array<{ + id: number; + error?: { code: number }; + result?: unknown; + }>; + const byId = Object.fromEntries(frames.map((f) => [f.id, f])); + expect(byId[210].error?.code).toBe(-32602); + expect(byId[211].error?.code).toBe(-32602); + expect(byId[212].result).toBeDefined(); + }); + + it('translateEvent: stream_error + client_evicted → _qwen/notify with kind', async () => { + const connId = await initialize(); + await newSession(connId); + const sess = await openStream(connId, 'sess-1'); + const got = takeFrames(sess, 2); + await new Promise((r) => setTimeout(r, 50)); + const q = bridge.queues.get('sess-1'); + q?.push({ type: 'stream_error', data: { error: 'boom' } }); + q?.push({ type: 'client_evicted', data: { reason: 'slow' } }); + const frames = (await got) as Array<{ + method: string; + params: { kind: string }; + }>; + expect(frames.every((f) => f.method === '_qwen/notify')).toBe(true); + const kinds = frames.map((f) => f.params.kind); + expect(kinds).toEqual( + expect.arrayContaining(['stream_error', 'client_evicted']), + ); + // (takeFrames already locked + aborted `sess`; afterEach force-closes.) + }); + + it('session/load while a session/close is in-flight → rejected (TOCTOU guard)', async () => { + let releaseClose: () => void = () => {}; + bridge.closeGate = new Promise((r) => (releaseClose = r)); + const connId = await initialize(); + await newSession(connId); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 2); // session/new reply + load reject + await new Promise((r) => setTimeout(r, 50)); + // close is now in flight (awaiting closeGate) → sess-1 is "closing". + void post(connId, { + jsonrpc: '2.0', + id: 300, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 301, + method: 'session/load', + params: { sessionId: 'sess-1' }, + }); + const frames = (await got) as Array<{ + id: number; + error?: { code: number; message: string }; + }>; + const loadReply = frames.find((f) => f.id === 301); + // Transient server-side race → INTERNAL_ERROR (-32603), not INVALID_PARAMS. + expect(loadReply?.error?.code).toBe(-32603); // "being closed; retry" + expect(loadReply?.error?.message).toContain('being closed'); + releaseClose(); + }); + + it('session/load while close races DURING loadSession → post-await reject + rollback', async () => { + // Distinct from the pre-await guard above: here the pre-await + // `closingSessions` check passes, then a `session/close` for the same id + // starts WHILE `loadSession` is awaiting. The post-await re-check + // (dispatch.ts) must detect `closeRaced`, roll back the just-restored + // attach (detachClient, since loadAttached=true), and reply INTERNAL_ERROR. + let releaseLoad: () => void = () => {}; + let releaseClose: () => void = () => {}; + const connId = await initialize(); + await newSession(connId); // own sess-1 so session/close passes requireOwned + // Arm the gates only AFTER ownership is established — otherwise newSession's + // own spawnOrAttach would block on bridge.gate and never grant ownership. + bridge.gate = new Promise((r) => (releaseLoad = r)); + bridge.closeGate = new Promise((r) => (releaseClose = r)); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 2); // buffered session/new reply + load reject + await new Promise((r) => setTimeout(r, 50)); + // Load goes in-flight (awaits bridge.gate); pre-await closingSessions empty. + void post(connId, { + jsonrpc: '2.0', + id: 340, + method: 'session/load', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 20)); + // Close starts DURING the load → marks sess-1 closing (awaits closeGate). + void post(connId, { + jsonrpc: '2.0', + id: 341, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 20)); + releaseLoad(); // loadSession resolves → post-await sees closeRaced + const frames = (await got) as Array<{ + id: number; + error?: { code: number; message: string }; + }>; + const loadReply = frames.find((f) => f.id === 340); + expect(loadReply?.error?.code).toBe(-32603); + expect(loadReply?.error?.message).toContain('closed during load'); + // attached:true → rollback is a detach, NOT a kill. + expect(bridge.detached.some((d) => d.sessionId === 'sess-1')).toBe(true); + expect(bridge.killed).not.toContain('sess-1'); + releaseClose(); + }); + + it('double-failure permission vote → pending retained + retried on teardown', async () => { + // Core R14 invariant: when BOTH the vote and the immediate cancel throw a + // non-"not found" error, resolveClientResponse must RETAIN the pending + // entry so connection teardown's abandonPendingForSession can retry the + // cancel (otherwise the bridge mediator is stuck forever). Retention is + // observable as a SECOND cancel attempt during teardown. + const calls: unknown[] = []; + bridge.respondToSessionPermission = (( + _s: string, + _r: string, + resp: unknown, + ) => { + calls.push(resp); + throw new Error('mediator unavailable'); // vote AND every cancel fail + }) as never; + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'permission_request', + data: { + requestId: 'perm-d', + sessionId: 'sess-1', + toolCall: {}, + options: [{ optionId: 'allow' }], + }, + }); + await new Promise((r) => setTimeout(r, 100)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sess = await openStream(connId, 'sess-1'); + const reader = frameReader(sess); + try { + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 350, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'x' }] }, + }); + const reqFrame = (await reader.next()) as { id: string }; + // Vote → respondToSessionPermission throws → immediate cancel ALSO throws. + await post(connId, { + jsonrpc: '2.0', + id: reqFrame.id, + result: { outcome: { outcome: 'selected', optionId: 'allow' } }, + }); + await waitUntil( + () => + calls.filter((c) => JSON.stringify(c).includes('cancelled')).length >= + 1, + ); + // Teardown retries the cancel. This only happens if the entry was + // retained after the immediate cancel failed. + await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + await waitUntil(() => { + const cancels = calls.filter((c) => + JSON.stringify(c).includes('cancelled'), + ); + return cancels.length >= 2 && calls.length >= 3; + }); + const cancels = calls.filter((c) => + JSON.stringify(c).includes('cancelled'), + ); + // 1 vote + ≥2 cancels (immediate fail + teardown retry). If the entry + // were dropped unconditionally after the failed immediate cancel, there + // would be exactly ONE cancel — so ≥2 is the retention invariant. + expect(cancels.length).toBeGreaterThanOrEqual(2); + expect(calls.length).toBeGreaterThanOrEqual(3); + } finally { + reader.close(); + } + }); + + it('client error response to a permission request → cancellation', async () => { + let resolvedWith: unknown; + bridge.respondToSessionPermission = (( + _s: string, + _r: string, + resp: unknown, + ) => { + resolvedWith = resp; + return true; + }) as never; + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'permission_request', + data: { + requestId: 'perm-e', + sessionId: 'sess-1', + toolCall: {}, + options: [{ optionId: 'allow' }], + }, + }); + await new Promise((r) => setTimeout(r, 40)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sess = await openStream(connId, 'sess-1'); + const got = takeFrames(sess, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 310, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'x' }] }, + }); + const [reqFrame] = (await got) as Array<{ id: string }>; + // Client answers with a JSON-RPC ERROR (not result) → treated as cancel. + await post(connId, { + jsonrpc: '2.0', + id: reqFrame.id, + error: { code: -32000, message: 'user declined' }, + }); + await new Promise((r) => setTimeout(r, 50)); + expect(resolvedWith).toEqual({ outcome: { outcome: 'cancelled' } }); + }); + + it('DELETE without a connection id → 400', async () => { + const res = await fetch(`${base}/acp`, { method: 'DELETE' }); + expect(res.status).toBe(400); + }); + + it('DELETE tears the connection down (subsequent POST 404)', async () => { + const connId = await initialize(); + const del = await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + expect(del.status).toBe(202); + const after = await post(connId, { + jsonrpc: '2.0', + id: 12, + method: 'session/new', + }); + expect(after.status).toBe(404); + }); + + // ── Wave 1+2: new _qwen/* method tests ────────────────────────── + + describe('protocol compliance', () => { + it('POST non-JSON Content-Type → 415', async () => { + const res = await fetch(`${base}/acp`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: '{}', + }); + expect(res.status).toBe(415); + }); + + it('POST batch JSON-RPC array → 501', async () => { + const res = await fetch(`${base}/acp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify([{ jsonrpc: '2.0', id: 1, method: 'foo' }]), + }); + expect(res.status).toBe(501); + }); + + it('GET without text/event-stream Accept → 406', async () => { + const connId = await initialize(); + const res = await fetch(`${base}/acp`, { + headers: { + accept: 'application/json', + 'acp-connection-id': connId, + }, + }); + expect(res.status).toBe(406); + }); + + it('POST missing Acp-Connection-Id → 400', async () => { + const res = await fetch(`${base}/acp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'session/list', + }), + }); + expect(res.status).toBe(400); + }); + }); + + describe('session extension methods', () => { + it('_qwen/session/recap returns recap', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 50, + method: '_qwen/session/recap', + params: { sessionId: 'sess-1' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { sessionId: 'sess-1', recap: 'test recap' }, + }); + }); + + it('_qwen/session/btw validates question length', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 51, + method: '_qwen/session/btw', + params: { sessionId: 'sess-1', question: '' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ error: { code: -32602 } }); + }); + + it('_qwen/session/btw returns answer', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 52, + method: '_qwen/session/btw', + params: { sessionId: 'sess-1', question: 'what?' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { answer: 're: what?' }, + }); + }); + + it('_qwen/session/shell rejects empty command', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 53, + method: '_qwen/session/shell', + params: { sessionId: 'sess-1', command: '' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ error: { code: -32602 } }); + }); + + it('_qwen/session/shell returns result', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + const command = 'ls\nFAKE\r\x1b[31m'; + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 54, + method: '_qwen/session/shell', + params: { sessionId: 'sess-1', command }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { exitCode: 0, output: `$ ${command}` }, + }); + const shellLog = stdioMocks.writeStderrLine.mock.calls + .map(([line]) => line) + .find((line) => line.includes('session/shell')); + expect(shellLog).toContain('cmd=ls FAKE [31m'); + expect(shellLog).not.toContain('\n'); + expect(shellLog).not.toContain('\r'); + expect(shellLog).not.toContain('\x1b'); + }); + + it('_qwen/session/detach succeeds', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 55, + method: '_qwen/session/detach', + params: { sessionId: 'sess-1' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ result: { ok: true } }); + expect(bridge.detached.length).toBeGreaterThan(0); + }); + + it('_qwen/session/context_usage returns usage', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 56, + method: '_qwen/session/context_usage', + params: { sessionId: 'sess-1' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { sessionId: 'sess-1', used: 100 }, + }); + }); + + it('_qwen/session/tasks returns tasks', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 57, + method: '_qwen/session/tasks', + params: { sessionId: 'sess-1' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { sessionId: 'sess-1', tasks: [] }, + }); + }); + + it('session methods reject unowned session', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 58, + method: '_qwen/session/recap', + params: { sessionId: 'unknown-session' }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + }); + + describe('workspace methods', () => { + it('_qwen/workspace/tools returns tools', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 60, + method: '_qwen/workspace/tools', + params: {}, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ result: { v: 1, tools: [] } }); + }); + + it('_qwen/workspace/mcp/tools rejects missing serverName', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: '_qwen/workspace/mcp/tools', + params: {}, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + + it('_qwen/workspace/mcp/tools returns tools', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 62, + method: '_qwen/workspace/mcp/tools', + params: { serverName: 'fs' }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ + result: { serverName: 'fs', tools: [] }, + }); + }); + + it('_qwen/workspace/mcp/servers/add rejects missing name', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 63, + method: '_qwen/workspace/mcp/servers/add', + params: { config: {} }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + + it('_qwen/workspace/mcp/servers/remove rejects missing name', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 64, + method: '_qwen/workspace/mcp/servers/remove', + params: {}, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + + it('_qwen/sessions/delete rejects non-array', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 65, + method: '_qwen/sessions/delete', + params: { sessionIds: 'not-array' }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + + it('_qwen/sessions/delete rejects >100 ids', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + const ids = Array.from({ length: 101 }, (_, i) => `s${i}`); + await post(connId, { + jsonrpc: '2.0', + id: 66, + method: '_qwen/sessions/delete', + params: { sessionIds: ids }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + }); + + describe('auth methods', () => { + it('_qwen/workspace/auth/status returns empty when no registry', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 70, + method: '_qwen/workspace/auth/status', + params: {}, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ + result: { pendingDeviceFlows: [] }, + }); + }); + + it('_qwen/workspace/auth/device_flow/start rejects without registry', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 71, + method: '_qwen/workspace/auth/device_flow/start', + params: { providerId: 'test' }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32603 } }); + }); + }); + + describe('memory methods', () => { + it('_qwen/workspace/memory/write rejects non-string content', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 80, + method: '_qwen/workspace/memory/write', + params: { content: 123 }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + + it('_qwen/workspace/memory/write rejects invalid scope', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 81, + method: '_qwen/workspace/memory/write', + params: { content: 'hi', scope: 'invalid' }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + + it('_qwen/workspace/memory/write rejects invalid mode', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 82, + method: '_qwen/workspace/memory/write', + params: { content: 'hi', mode: 'invalid' }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + }); + + describe('file methods', () => { + it('_qwen/file/read rejects without fsFactory (503-equivalent)', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 90, + method: '_qwen/file/read', + params: { path: 'test.txt' }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32603 } }); + }); + + it('_qwen/file/read rejects missing path', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 91, + method: '_qwen/file/read', + params: {}, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + + it('_qwen/file/write rejects missing content', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 92, + method: '_qwen/file/write', + params: { path: 'test.txt' }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + + it('_qwen/file/edit rejects missing oldText/newText', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 93, + method: '_qwen/file/edit', + params: { path: 'test.txt' }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + + it('_qwen/file/glob rejects missing pattern', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 94, + method: '_qwen/file/glob', + params: {}, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ error: { code: -32602 } }); + }); + }); +}); + +// ── WebSocket transport security tests ──────────────────────────────── +describe('ACP WebSocket transport security', () => { + let server: Server; + let port: number; + let bridge: FakeBridge; + + function startServer( + opts: { + token?: string; + checkRate?: (key: string, tier: string) => boolean; + } = {}, + ) { + return new Promise((resolve) => { + bridge = new FakeBridge(); + const app = express(); + app.use(express.json()); + const handle = mountAcpHttp(app, bridge as unknown as HttpAcpBridge, { + boundWorkspace: '/ws', + workspace: fakeWorkspace, + enabled: true, + token: opts.token, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + checkRate: opts.checkRate as any, + }); + server = app.listen(0, '127.0.0.1', () => { + port = (server.address() as AddressInfo).port; + handle?.attachServer(server); + resolve(); + }); + }); + } + + afterEach(async () => { + server?.closeAllConnections?.(); + await new Promise((r) => server?.close(() => r()) ?? r()); + }); + + function wsConnect( + opts: { headers?: Record } = {}, + ): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/acp`, { + headers: opts.headers, + }); + ws.once('open', () => resolve(ws)); + ws.once('error', reject); + }); + } + + function wsConnectRaw( + host: string, + origin?: string, + ): Promise<{ code: number }> { + return new Promise((resolve) => { + const headers: Record = {}; + if (origin) headers['Origin'] = origin; + const ws = new WebSocket(`ws://${host}:${port}/acp`, { + headers, + handshakeTimeout: 2000, + }); + ws.once('open', () => { + ws.close(); + resolve({ code: 101 }); + }); + ws.once('unexpected-response', (_req, res) => { + resolve({ code: res.statusCode ?? 0 }); + }); + ws.once('error', () => resolve({ code: 0 })); + }); + } + + function sendRpc(ws: WebSocket, msg: unknown): Promise { + return new Promise((resolve) => { + ws.once('message', (data) => resolve(JSON.parse(data.toString()))); + ws.send(JSON.stringify(msg)); + }); + } + + // ── Host allowlist ────────────────────────────────────────────────── + it('accepts WS upgrade with loopback Host header', async () => { + await startServer(); + const result = await wsConnectRaw('127.0.0.1', undefined); + // The Host header will be 127.0.0.1:PORT which is in the allowlist + expect(result.code).toBe(101); + }); + + // ── CSWSH origin check ───────────────────────────────────────────── + it('rejects WS upgrade with cross-origin Origin header', async () => { + await startServer(); + const result = await wsConnectRaw('127.0.0.1', 'https://evil.com'); + expect(result.code).toBe(403); + }); + + it('allows WS upgrade with loopback Origin header', async () => { + await startServer(); + const result = await wsConnectRaw('127.0.0.1', 'http://localhost:3000'); + expect(result.code).toBe(101); + }); + + // ── Bearer token auth ────────────────────────────────────────────── + it('rejects WS upgrade without token when token is configured', async () => { + await startServer({ token: 'secret-token-123' }); + const result = await wsConnectRaw('127.0.0.1'); + expect(result.code).toBe(401); + }); + + it('rejects WS upgrade with wrong token', async () => { + await startServer({ token: 'secret-token-123' }); + const result = await new Promise<{ code: number }>((resolve) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/acp`, { + headers: { Authorization: 'Bearer wrong-token' }, + handshakeTimeout: 2000, + }); + ws.once('open', () => { + ws.close(); + resolve({ code: 101 }); + }); + ws.once('unexpected-response', (_req, res) => + resolve({ code: res.statusCode ?? 0 }), + ); + ws.once('error', () => resolve({ code: 0 })); + }); + expect(result.code).toBe(401); + }); + + it('allows WS upgrade with correct token', async () => { + await startServer({ token: 'secret-token-123' }); + const ws = await wsConnect({ + headers: { Authorization: 'Bearer secret-token-123' }, + }); + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + }); + + // ── maxPayload ───────────────────────────────────────────────────── + it('closes WS on oversized frame (>10MB)', async () => { + await startServer(); + const ws = await wsConnect(); + const closed = new Promise((resolve) => { + ws.once('close', (code) => resolve(code)); + ws.once('error', () => {}); + }); + try { + ws.send('x'.repeat(10 * 1024 * 1024 + 1)); + } catch { + // ws may throw synchronously for oversized payloads + } + const code = await closed; + expect(code).toBe(1009); // 1009 = message too big + }); + + // ── Initialize timeout ───────────────────────────────────────────── + it('requires initialize as first message', async () => { + await startServer(); + const ws = await wsConnect(); + const reply = await sendRpc(ws, { + jsonrpc: '2.0', + id: 1, + method: 'session/new', + params: {}, + }); + expect(reply).toMatchObject({ error: { code: -32600 } }); + ws.close(); + }); + + // ── Message serialization ────────────────────────────────────────── + it('serializes concurrent WS messages (no race)', async () => { + await startServer(); + const ws = await wsConnect(); + // Initialize first + const initReply = await sendRpc(ws, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: {}, + }); + expect(initReply).toMatchObject({ result: { protocolVersion: 1 } }); + // Send two messages rapidly — both should succeed without race + const replies: unknown[] = []; + const done = new Promise((resolve) => { + ws.on('message', (data) => { + replies.push(JSON.parse(data.toString())); + if (replies.length >= 2) resolve(); + }); + }); + ws.send( + JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'session/new', + params: {}, + }), + ); + ws.send( + JSON.stringify({ + jsonrpc: '2.0', + id: 3, + method: 'session/list', + params: {}, + }), + ); + await done; + const ids = replies.map((r) => (r as { id: number }).id).sort(); + expect(ids).toEqual([2, 3]); + ws.close(); + }); + + // ── Rate limiter ─────────────────────────────────────────────────── + it('enforces rate limits on WS messages', async () => { + let callCount = 0; + await startServer({ + checkRate: () => { + callCount++; + return callCount <= 2; + }, + }); + const ws = await wsConnect(); + await sendRpc(ws, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: {}, + }); + // First two post-init messages should pass + const r1 = await sendRpc(ws, { + jsonrpc: '2.0', + id: 2, + method: 'session/list', + params: {}, + }); + expect(r1).toMatchObject({ id: 2 }); + const r2 = await sendRpc(ws, { + jsonrpc: '2.0', + id: 3, + method: 'session/list', + params: {}, + }); + expect(r2).toMatchObject({ id: 3 }); + // Third should be rate-limited + const r3 = await sendRpc(ws, { + jsonrpc: '2.0', + id: 4, + method: 'session/list', + params: {}, + }); + expect(r3).toMatchObject({ error: { message: 'Rate limit exceeded' } }); + ws.close(); + }); +}); diff --git a/packages/cli/src/serve/acpHttp/transportStream.ts b/packages/cli/src/serve/acpHttp/transportStream.ts new file mode 100644 index 0000000000..5a797718f5 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/transportStream.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Transport-agnostic stream interface consumed by `AcpConnection`. + * Both `SseStream` (HTTP SSE) and `WsStream` (WebSocket) implement this. + */ +export interface TransportStream { + send(message: unknown): Promise; + close(): void; + readonly isClosed: boolean; +} diff --git a/packages/cli/src/serve/acpHttp/wsStream.test.ts b/packages/cli/src/serve/acpHttp/wsStream.test.ts new file mode 100644 index 0000000000..1df504ff69 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/wsStream.test.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { WsStream } from './wsStream.js'; + +// Minimal WebSocket mock that implements the surface WsStream uses. +class MockWebSocket extends EventEmitter { + readonly OPEN = 1; + readyState = 1; // OPEN + sent: string[] = []; + pinged = 0; + closed = false; + closeCode?: number; + + send(data: string, cb?: (err?: Error) => void) { + this.sent.push(data); + cb?.(); + } + + ping() { + this.pinged++; + } + + close(code?: number) { + this.closed = true; + this.closeCode = code; + } +} + +describe('WsStream', () => { + let ws: MockWebSocket; + + beforeEach(() => { + ws = new MockWebSocket(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('send() serializes message as JSON and delivers via ws.send', async () => { + const stream = new WsStream(ws as never); + await stream.send({ hello: 'world' }); + expect(ws.sent).toEqual(['{"hello":"world"}']); + stream.close(); + }); + + it('send() serializes writes sequentially (no interleaving)', async () => { + const stream = new WsStream(ws as never); + const p1 = stream.send({ seq: 1 }); + const p2 = stream.send({ seq: 2 }); + await Promise.all([p1, p2]); + expect(ws.sent).toEqual(['{"seq":1}', '{"seq":2}']); + stream.close(); + }); + + it('send() resolves even after close (no hang)', async () => { + const stream = new WsStream(ws as never); + stream.close(); + // Should not hang or throw + await stream.send({ after: 'close' }); + // Message not delivered (closed) + expect(ws.sent).toEqual([]); + }); + + it('isClosed starts false, becomes true after close()', () => { + const stream = new WsStream(ws as never); + expect(stream.isClosed).toBe(false); + stream.close(); + expect(stream.isClosed).toBe(true); + }); + + it('close() is idempotent', () => { + const onClose = vi.fn(); + const stream = new WsStream(ws as never, onClose); + stream.close(); + stream.close(); + stream.close(); + expect(onClose).toHaveBeenCalledTimes(1); + expect(ws.closeCode).toBe(1000); + }); + + it('close() calls onClose callback', () => { + const onClose = vi.fn(); + const stream = new WsStream(ws as never, onClose); + stream.close(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('close() does not call ws.close if not OPEN', () => { + ws.readyState = 3; // CLOSED + const stream = new WsStream(ws as never); + stream.close(); + expect(ws.closed).toBe(false); + }); + + it('ws "close" event triggers stream close', () => { + const onClose = vi.fn(); + void new WsStream(ws as never, onClose); + ws.emit('close'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('ws "error" event triggers stream close', () => { + const onClose = vi.fn(); + void new WsStream(ws as never, onClose); + ws.emit('error', new Error('test error')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('heartbeat sends ping every 15s and calls onHeartbeat', () => { + const onHeartbeat = vi.fn(); + const _stream = new WsStream(ws as never, undefined, onHeartbeat); + expect(ws.pinged).toBe(0); + vi.advanceTimersByTime(15_000); + expect(ws.pinged).toBe(1); + expect(onHeartbeat).toHaveBeenCalledTimes(1); + // Simulate pong to keep alive for next tick + ws.emit('pong'); + vi.advanceTimersByTime(15_000); + expect(ws.pinged).toBe(2); + _stream.close(); + }); + + it('heartbeat stops after close', () => { + const stream = new WsStream(ws as never); + stream.close(); + vi.advanceTimersByTime(30_000); + expect(ws.pinged).toBe(0); + }); + + it('dead connection detected via ping/pong (no pong → close)', () => { + const onClose = vi.fn(); + void new WsStream(ws as never, onClose); + + // First tick: ping sent, alive flag set to false + vi.advanceTimersByTime(15_000); + expect(ws.pinged).toBe(1); + + // No pong received → second tick closes + vi.advanceTimersByTime(15_000); + expect(onClose).toHaveBeenCalled(); + }); + + it('pong keeps connection alive', () => { + const onClose = vi.fn(); + const _stream = new WsStream(ws as never, onClose); + + vi.advanceTimersByTime(15_000); + expect(ws.pinged).toBe(1); + + // Simulate pong + ws.emit('pong'); + + vi.advanceTimersByTime(15_000); + // Should NOT close — pong was received + expect(onClose).not.toHaveBeenCalled(); + expect(ws.pinged).toBe(2); + + _stream.close(); + }); + + it('send() failure closes stream', async () => { + const onClose = vi.fn(); + ws.send = (_data: string, cb?: (err?: Error) => void) => { + cb?.(new Error('write failed')); + }; + const stream = new WsStream(ws as never, onClose); + await stream.send({ fail: true }); + expect(onClose).toHaveBeenCalled(); + expect(stream.isClosed).toBe(true); + }); +}); diff --git a/packages/cli/src/serve/acpHttp/wsStream.ts b/packages/cli/src/serve/acpHttp/wsStream.ts new file mode 100644 index 0000000000..e376da7e79 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/wsStream.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WebSocket } from 'ws'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import type { TransportStream } from './transportStream.js'; + +export class WsStream implements TransportStream { + private writeChain: Promise = Promise.resolve(); + private _closed = false; + private heartbeat: ReturnType | undefined; + + constructor( + private readonly ws: WebSocket, + private readonly onClose?: () => void, + private readonly onHeartbeat?: () => void, + ) { + ws.on('close', () => this.close()); + ws.on('error', (err) => { + writeStderrLine( + `qwen serve: /acp WS error: ${err instanceof Error ? err.message : String(err)}`, + ); + this.close(); + }); + let alive = true; + ws.on('pong', () => { + alive = true; + }); + this.heartbeat = setInterval(() => { + if (this._closed) return; + if (!alive) { + this.close(); + return; + } + alive = false; + try { + this.onHeartbeat?.(); + } catch { + /* swallow — heartbeat callback must not crash the interval */ + } + try { + this.ws.ping(); + } catch { + /* socket may be gone */ + } + }, 15_000); + this.heartbeat.unref(); + } + + send(message: unknown): Promise { + const data = JSON.stringify(message); + const next = this.writeChain.then( + () => + new Promise((resolve, reject) => { + if (this._closed) { + resolve(); + return; + } + this.ws.send(data, (err) => { + if (err) reject(err); + else resolve(); + }); + }), + ); + this.writeChain = next.catch((err: unknown) => { + if (!this._closed) { + writeStderrLine( + `qwen serve: /acp WS write failed: ${err instanceof Error ? err.message : String(err)}`, + ); + this.close(); + } + }); + return this.writeChain; + } + + get isClosed(): boolean { + return this._closed; + } + + close(): void { + if (this._closed) return; + this._closed = true; + if (this.heartbeat) clearInterval(this.heartbeat); + try { + if (this.ws.readyState === this.ws.OPEN) this.ws.close(1000); + } catch { + /* socket gone */ + } + try { + this.onClose?.(); + } catch (err) { + writeStderrLine( + `qwen serve: /acp WS onClose threw: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } +} diff --git a/packages/cli/src/serve/acpSessionBridge.ts b/packages/cli/src/serve/acpSessionBridge.ts new file mode 100644 index 0000000000..9b5d0d6448 --- /dev/null +++ b/packages/cli/src/serve/acpSessionBridge.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Stage 1 HTTP→ACP bridge — backward-compat re-export shim. + * + * #4175 PR F1 lifted the bridge core (`BridgeClient`, + * `defaultSpawnChannelFactory`, `createAcpSessionBridge` factory closure, + * plus the supporting types/errors/options/status) to + * `@qwen-code/acp-bridge`. This shim preserves every existing relative + * import path (`./acpSessionBridge.js`) so `server.ts`, `runQwenServe.ts`, + * `workspaceAgents.ts`, `workspaceMemory.ts`, `index.ts`, plus the + * bridge test suite, keep resolving without any call-site changes. + * + * The implementation now lives at: + * - `@qwen-code/acp-bridge/bridge` — `createAcpSessionBridge` factory + * - `@qwen-code/acp-bridge/bridgeClient` — `BridgeClient` class + + * permission record types + * - `@qwen-code/acp-bridge/spawnChannel` — `defaultSpawnChannelFactory` + * - `@qwen-code/acp-bridge/bridgeOptions` — `BridgeOptions` + + * `DaemonStatusProvider` interfaces + * - `@qwen-code/acp-bridge/bridgeTypes` — bridge session + heartbeat + * types + `AcpSessionBridge` interface + * - `@qwen-code/acp-bridge/bridgeErrors` — typed bridge error classes + * - `@qwen-code/acp-bridge/workspacePaths` — `canonicalizeWorkspace` + * + `MAX_WORKSPACE_PATH_LENGTH` + * - `@qwen-code/acp-bridge/status` — protocol-versioned status types + * + idle envelope helpers + * - `@qwen-code/acp-bridge/channel` — `AcpChannel` + `ChannelFactory` + * + * The bridge is bound to a single canonical workspace + * (`BridgeOptions.boundWorkspace`); multi-workspace deployments use + * multiple daemon processes. See the module docstring on `bridge.ts` + * in the lifted package for the full Stage 1/Stage 2 contract. + */ + +export { + createAcpSessionBridge, + createHttpAcpBridge, +} from '@qwen-code/acp-bridge/bridge'; +export { defaultSpawnChannelFactory } from '@qwen-code/acp-bridge/spawnChannel'; +// `MAX_RESOLVED_PERMISSION_RECORDS`, `PendingPermission`, +// `PermissionResolutionRecord` re-exports were removed alongside the +// source definitions — the mediator now owns pending+resolved state. +export { BridgeClient } from '@qwen-code/acp-bridge/bridgeClient'; +export type { BridgeClientSessionEntry } from '@qwen-code/acp-bridge/bridgeClient'; + +export type { + AcpChannel, + AcpChannelExitInfo, + ChannelFactory, +} from '@qwen-code/acp-bridge'; + +export type { + BridgeOptions, + DaemonStatusProvider, +} from '@qwen-code/acp-bridge/bridgeOptions'; + +export type { BridgeFileSystem } from '@qwen-code/acp-bridge/bridgeFileSystem'; + +export type { + BridgeSpawnRequest, + BridgeSession, + BridgeRestoreSessionRequest, + BridgeSessionState, + BridgeRestoredSession, + BridgeSessionSummary, + SessionMetadataUpdate, + BridgeClientRequestContext, + BridgeHeartbeatResult, + BridgeHeartbeatState, + AcpSessionBridge, + HttpAcpBridge, +} from '@qwen-code/acp-bridge/bridgeTypes'; + +export { + BranchWhilePromptActiveError, + SessionNotFoundError, + RestoreInProgressError, + InvalidSessionScopeError, + SessionLimitExceededError, + WorkspaceMismatchError, + InvalidClientIdError, + InvalidPermissionOptionError, + InvalidSessionMetadataError, + WorkspaceInitConflictError, + WorkspaceInitPathEscapeError, + WorkspaceInitSymlinkError, + WorkspaceInitRaceError, + McpServerNotFoundError, + McpServerRestartFailedError, + SessionBusyError, + InvalidRewindTargetError, + NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE, + // Multi-client permission coordination errors. + CancelSentinelCollisionError, + PermissionForbiddenError, + PermissionPolicyNotImplementedError, +} from '@qwen-code/acp-bridge/bridgeErrors'; + +export { + MAX_WORKSPACE_PATH_LENGTH, + canonicalizeWorkspace, +} from '@qwen-code/acp-bridge/workspacePaths'; diff --git a/packages/cli/src/serve/auth.test.ts b/packages/cli/src/serve/auth.test.ts index f83aa92578..93138f1b74 100644 --- a/packages/cli/src/serve/auth.test.ts +++ b/packages/cli/src/serve/auth.test.ts @@ -6,18 +6,29 @@ import type { NextFunction, Request, RequestHandler, Response } from 'express'; import { describe, expect, it } from 'vitest'; -import { createMutationGate } from './auth.js'; +import { + allowOriginCors, + createMutationGate, + denyBrowserOriginCors, + InvalidAllowOriginPatternError, + parseAllowOriginPatterns, +} from './auth.js'; interface GateResult { status?: number; body?: unknown; + headers: Map; nextCalled: boolean; } -function invokeGate(handler: RequestHandler): GateResult { +function invokeGate( + handler: RequestHandler, + req: { headers?: Record } = {}, +): GateResult { let status: number | undefined; let body: unknown; let nextCalled = false; + const headers = new Map(); const response = {} as Response; response.status = ((code: number): Response => { status = code; @@ -27,12 +38,16 @@ function invokeGate(handler: RequestHandler): GateResult { body = payload; return response; }) as Response['json']; + response.setHeader = ((name: string, value: string | number): Response => { + headers.set(name.toLowerCase(), String(value)); + return response; + }) as Response['setHeader']; const next: NextFunction = () => { nextCalled = true; }; - handler({} as Request, response, next); - return { status, body, nextCalled }; + handler({ headers: req.headers ?? {} } as Request, response, next); + return { status, body, headers, nextCalled }; } function invokeGatedRoute( @@ -43,6 +58,17 @@ function invokeGatedRoute( return invokeGate(gate(gateOpts)); } +describe('denyBrowserOriginCors', () => { + it('sets Vary: Origin when rejecting browser Origin requests', () => { + const res = invokeGate(denyBrowserOriginCors, { + headers: { origin: 'https://evil.example.com' }, + }); + expect(res.nextCalled).toBe(false); + expect(res.status).toBe(403); + expect(res.headers.get('vary')).toBe('Origin'); + }); +}); + describe('createMutationGate (#4175 PR 15)', () => { it('passes through when --require-auth is on (global bearerAuth handles enforcement)', () => { // `requireAuth: true` is paired with a mandatory token at boot, so @@ -141,3 +167,267 @@ describe('createMutationGate (#4175 PR 15)', () => { expect(passA).not.toBe(strictA); }); }); + +interface AllowOriginResult { + status?: number; + body?: unknown; + headers: Map; + nextCalled: boolean; + ended: boolean; +} + +function invokeAllowOrigin( + handler: RequestHandler, + req: { + method?: string; + headers?: Record; + } = {}, +): AllowOriginResult { + let status: number | undefined; + let body: unknown; + let nextCalled = false; + let ended = false; + const headers = new Map(); + const response = {} as Response; + response.status = ((code: number): Response => { + status = code; + return response; + }) as Response['status']; + response.json = ((payload: unknown): Response => { + body = payload; + return response; + }) as Response['json']; + response.setHeader = ((name: string, value: string | number): Response => { + headers.set(name.toLowerCase(), String(value)); + return response; + }) as Response['setHeader']; + response.end = ((): Response => { + ended = true; + return response; + }) as Response['end']; + const next: NextFunction = () => { + nextCalled = true; + }; + handler( + { + method: req.method ?? 'GET', + headers: req.headers ?? {}, + } as unknown as Request, + response, + next, + ); + return { status, body, headers, nextCalled, ended }; +} + +describe('parseAllowOriginPatterns (T2.4 #4514)', () => { + it('parses an empty list to an empty allowlist with no wildcard', () => { + const out = parseAllowOriginPatterns([]); + expect(out.allowAny).toBe(false); + expect(out.origins.size).toBe(0); + }); + + it('rejects mixed-case host in the input (URL.origin normalizes, so the round-trip fails)', () => { + // Documents the strict-by-intent rejection: operators must write + // the canonical (lowercased) origin. Auto-normalizing would + // silently accept ambiguous input — explicit failure is clearer. + expect(() => parseAllowOriginPatterns(['http://Localhost:3000'])).toThrow( + InvalidAllowOriginPatternError, + ); + }); + + it('accepts a clean canonical origin and stores it lowercased', () => { + const out = parseAllowOriginPatterns(['http://localhost:3000']); + expect(out.allowAny).toBe(false); + expect(out.origins.has('http://localhost:3000')).toBe(true); + }); + + it('accepts the `*` literal and sets allowAny', () => { + const out = parseAllowOriginPatterns(['*']); + expect(out.allowAny).toBe(true); + expect(out.origins.size).toBe(0); + }); + + it('accepts a mix of `*` and concrete origins', () => { + const out = parseAllowOriginPatterns(['*', 'https://app.example.com']); + expect(out.allowAny).toBe(true); + expect(out.origins.has('https://app.example.com')).toBe(true); + }); + + it('rejects trailing slash — operators must write the canonical origin', () => { + expect(() => parseAllowOriginPatterns(['http://localhost:3000/'])).toThrow( + InvalidAllowOriginPatternError, + ); + }); + + it('rejects path components — origins do not carry paths', () => { + expect(() => + parseAllowOriginPatterns(['https://app.example.com/foo']), + ).toThrow(InvalidAllowOriginPatternError); + }); + + it('rejects userinfo — leaks credentials in capability metadata', () => { + expect(() => + parseAllowOriginPatterns(['http://user:pass@example.com']), + ).toThrow(InvalidAllowOriginPatternError); + }); + + it('rejects values that are not parseable URLs', () => { + expect(() => parseAllowOriginPatterns(['not-a-url'])).toThrow( + InvalidAllowOriginPatternError, + ); + }); + + it('rejects URLs with empty hostname (http://:3000)', () => { + // Defensive lock against a future Node URL-parser change that + // accepts the no-host form. Today it throws `Invalid URL`, which + // the parser-error branch in `parseAllowOriginPatterns` catches. + expect(() => parseAllowOriginPatterns(['http://:3000'])).toThrow( + InvalidAllowOriginPatternError, + ); + }); + + it('throws on the first malformed entry, naming it for the operator', () => { + try { + parseAllowOriginPatterns(['http://localhost:3000', 'http://broken/']); + throw new Error('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(InvalidAllowOriginPatternError); + const e = err as InvalidAllowOriginPatternError; + expect(e.pattern).toBe('http://broken/'); + expect(e.message).toContain('http://broken/'); + } + }); +}); + +describe('allowOriginCors (T2.4 #4514)', () => { + const middleware = allowOriginCors( + parseAllowOriginPatterns(['http://localhost:3000']), + ); + const wildcardMiddleware = allowOriginCors(parseAllowOriginPatterns(['*'])); + + it('passes through requests with no Origin header (CLI / SDK callers)', () => { + const res = invokeAllowOrigin(middleware, {}); + expect(res.nextCalled).toBe(true); + expect(res.status).toBeUndefined(); + expect(res.headers.size).toBe(0); + }); + + it('matches an allowlisted origin, sets CORS headers, and calls next()', () => { + const res = invokeAllowOrigin(middleware, { + method: 'GET', + headers: { origin: 'http://localhost:3000' }, + }); + expect(res.nextCalled).toBe(true); + expect(res.status).toBeUndefined(); + expect(res.headers.get('access-control-allow-origin')).toBe( + 'http://localhost:3000', + ); + expect(res.headers.get('vary')).toBe('Origin'); + expect(res.headers.get('access-control-allow-methods')).toMatch(/GET/); + expect(res.headers.get('access-control-allow-headers')).toMatch( + /Authorization/, + ); + expect(res.headers.get('access-control-max-age')).toBe('86400'); + expect(res.headers.get('access-control-expose-headers')).toBe( + 'Retry-After', + ); + }); + + it('short-circuits OPTIONS preflight with 204 + CORS headers (no chain continuation)', () => { + const res = invokeAllowOrigin(middleware, { + method: 'OPTIONS', + headers: { + origin: 'http://localhost:3000', + 'access-control-request-method': 'POST', + }, + }); + expect(res.nextCalled).toBe(false); + expect(res.ended).toBe(true); + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-origin')).toBe( + 'http://localhost:3000', + ); + }); + + it('lets plain OPTIONS requests continue after setting CORS headers', () => { + const res = invokeAllowOrigin(middleware, { + method: 'OPTIONS', + headers: { origin: 'http://localhost:3000' }, + }); + expect(res.nextCalled).toBe(true); + expect(res.ended).toBe(false); + expect(res.status).toBeUndefined(); + expect(res.headers.get('access-control-allow-origin')).toBe( + 'http://localhost:3000', + ); + }); + + it('matches case-insensitively on scheme/host (RFC 6454 §4)', () => { + const res = invokeAllowOrigin(middleware, { + method: 'GET', + headers: { origin: 'HTTP://LOCALHOST:3000' }, + }); + expect(res.nextCalled).toBe(true); + // Echo the request's origin verbatim — browser caches use it as a + // key paired with `Vary: Origin`, so we must echo the exact value + // the client sent, not a normalized form. + expect(res.headers.get('access-control-allow-origin')).toBe( + 'HTTP://LOCALHOST:3000', + ); + }); + + it('rejects unmatched origins with the same 403 envelope as denyBrowserOriginCors', () => { + const res = invokeAllowOrigin(middleware, { + method: 'POST', + headers: { origin: 'https://evil.example.com' }, + }); + expect(res.nextCalled).toBe(false); + expect(res.status).toBe(403); + expect((res.body as { error?: string }).error).toBe( + 'Request denied by CORS policy', + ); + // No CORS response headers leak on the reject path — the browser + // would have nothing to do with them anyway (it's about to block + // the response), but emitting them would advertise the allowlist + // size indirectly through header presence. + expect(res.headers.has('access-control-allow-origin')).toBe(false); + }); + + it('`*` admits any origin and echoes the request value', () => { + const res = invokeAllowOrigin(wildcardMiddleware, { + method: 'GET', + headers: { origin: 'https://anywhere.example.com' }, + }); + expect(res.nextCalled).toBe(true); + expect(res.headers.get('access-control-allow-origin')).toBe( + 'https://anywhere.example.com', + ); + }); + + it('`Origin: null` (sandboxed iframes, file:// docs) is rejected even under `*`', () => { + // Defense against a sandboxed-iframe attack: a malicious page can + // spawn an `