basant307/AI_Governance_Project
045
1# `qwen serve` HTTP protocol reference2 3Stage 1 of the [qwen-code daemon design](https://github.com/QwenLM/qwen-code/issues/3803). All routes live under the daemon's base URL (default `http://127.0.0.1:4170`).4 5## Authentication6 7When the daemon was started with `--token` or `QWEN_SERVER_TOKEN`, **every route except `/health` on loopback binds** must carry:8 9```10Authorization: Bearer <token>11```12 13Without a configured token (loopback dev default) the header is optional. Token comparison is constant-time. 401 responses are uniform across `missing header` / `wrong scheme` / `wrong token`.14 15**`/health` exemption** (Bctum): on loopback binds (`127.0.0.1` / `localhost` / `::1` / `[::1]`) `/health` is registered BEFORE the bearer middleware, so liveness probes inside the pod don't need to carry the token even when the daemon was started with `--token`. Non-loopback binds (`--hostname 0.0.0.0` etc.) gate `/health` behind the bearer like every other route — see the [`GET /health`](#get-health) section for the rationale.16 17**`--require-auth` (#4175 PR 15).** Pass this flag at boot to extend the "must have a token" rule to loopback as well. Boot fails without a token; the `/health` exemption is dropped (so `/health` also requires `Authorization: Bearer …`).18 19When 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.20 21**`--allow-origin <pattern>` (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 <pattern>` (repeatable) at boot to install an allowlist instead of the wall. Each pattern is either:22 23- 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.24- A canonical URL origin — `<scheme>://<host>[:<port>]`. **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.25 26Matched origins receive the standard CORS response headers on every request:27 28```29Access-Control-Allow-Origin: <echoed origin>30Vary: Origin31Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS32Access-Control-Allow-Headers: Authorization, Content-Type, X-Qwen-Client-Id, Last-Event-ID33Access-Control-Max-Age: 8640034Access-Control-Expose-Headers: Retry-After35```36 37`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'`.38 39OPTIONS 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.40 41Origins 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).42 43The 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.44 45Loopback 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.46 47## Common error shape48 495xx responses carry the original error's `code` and `data` when present (JSON-RPC style — the ACP SDK forwards `{code, message, data}` from the agent):50 51```json52{53 "error": "Internal error",54 "code": -32000,55 "data": { "reason": "model quota exceeded" }56}57```58 59Malformed JSON in a request body returns:60 61```json62{ "error": "Invalid JSON in request body" }63```64 65with status `400`.66 67`SessionNotFoundError` for an unknown session id returns:68 69```json70{ "error": "No session with id \"<sid>\"", "sessionId": "<sid>" }71```72 73with status `404`.74 75`WorkspaceMismatchError` for a `POST /session` whose `cwd` doesn't canonicalize to the daemon's bound workspace (#3803 §02 — 1 daemon = 1 workspace) returns `400` with:76 77```json78{79 "error": "Workspace mismatch: daemon is bound to \"…\" but request asked for \"…\". …",80 "code": "workspace_mismatch",81 "boundWorkspace": "/path/the/daemon/binds",82 "requestedWorkspace": "/path/in/the/request"83}84```85 86Use this to detect mismatch pre-flight: read `workspaceCwd` off `/capabilities` and omit `cwd` from `POST /session` (it falls back to the bound workspace), or route the request to a daemon bound to `requestedWorkspace`.87 88`POST /session` past the daemon's `--max-sessions` cap returns `503` with a `Retry-After: 5` header and:89 90```json91{92 "error": "Session limit reached (20)",93 "code": "session_limit_exceeded",94 "limit": 20,95 "scope": "workspace"96}97```98 99When `--max-total-sessions` rejects a fresh session, the same response shape is returned with `"scope": "total"`.100 101Attaches to existing sessions are NOT counted toward the cap, so an idle daemon's reconnects keep working even when at-capacity.102 103`RestoreInProgressError` — only emitted by `POST /session/:id/load` and `POST /session/:id/resume` — returns `409` with a `Retry-After: 5` header (matching `session_limit_exceeded`) and:104 105```json106{107 "error": "Session \"<sid>\" is already being restored via session/<resume|load>; retry session/<load|resume> after it completes",108 "code": "restore_in_progress",109 "sessionId": "<sid>",110 "activeAction": "load",111 "requestedAction": "resume"112}113```114 115Fired when a `session/load` is issued for an id that already has a `session/resume` in flight (or vice versa). Wait at least `Retry-After` seconds and retry — the underlying restore completes within `initTimeoutMs` (default 10s). Same-action races (`load` vs `load`, `resume` vs `resume`) coalesce instead of erroring.116 117`SessionArchivedError` is emitted when a caller tries to load or resume a session whose JSONL is under `chats/archive/`:118 119```json120{121 "error": "Session \"<sid>\" is archived. Unarchive it before loading.",122 "code": "session_archived",123 "sessionId": "<sid>"124}125```126 127with status `409`.128 129`SessionArchivingError` is emitted when a session archive or unarchive transition is already in flight for the same id:130 131```json132{133 "error": "Session \"<sid>\" is being archived or unarchived; retry later.",134 "code": "session_archiving",135 "sessionId": "<sid>"136}137```138 139with status `409` and `Retry-After: 5`.140 141## Capabilities142 143The daemon advertises its supported feature tags from the serve capability144registry. Clients **must** gate UI off `features`, not off `mode` (per design145§10).146 147```148['health', 'capabilities', 'session_create', 'session_scope_override',149 'session_load', 'session_resume',150 'unstable_session_resume',151 'session_list', 'session_prompt', 'session_cancel', 'session_events',152 'slow_client_warning', 'typed_event_schema',153 'session_set_model', 'client_identity', 'client_heartbeat',154 'session_permission_vote', 'permission_vote', 'workspace_mcp', 'workspace_skills',155 'workspace_providers', 'auth_provider_install', 'workspace_memory',156 'workspace_agents', 'workspace_agent_generate', 'workspace_env',157 'workspace_preflight', 'session_context', 'session_context_usage',158 'session_supported_commands', 'session_tasks', 'session_stats',159 'session_lsp', 'session_status',160 'session_close', 'session_metadata', 'session_organization',161 'session_archive', 'mcp_guardrails',162 'workspace_mcp_manage', 'mcp_guardrail_events',163 'mcp_server_runtime_mutation',164 'workspace_file_read', 'workspace_file_bytes', 'workspace_file_write',165 'session_approval_mode_control', 'workspace_tool_toggle',166 'workspace_settings', 'workspace_init', 'workspace_mcp_restart',167 'session_recap', 'session_btw', 'session_shell_command',168 'mcp_workspace_pool', 'mcp_pool_restart',169 'require_auth', 'allow_origin', 'auth_device_flow',170 'permission_mediation', 'prompt_absolute_deadline', 'writer_idle_timeout',171 'non_blocking_prompt', 'session_language', 'session_rewind',172 'workspace_hooks', 'session_hooks', 'workspace_extensions',173 'session_branch', 'rate_limit', 'workspace_reload']174```175 176> Conditional tags appear only when their matching deployment toggle is on (see the table below). 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`.177 178`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.179 180`session_load` and `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. `unstable_session_resume` is still advertised as a deprecated alias for compatibility with SDKs that shipped while the underlying ACP method was named `connection.unstable_resumeSession`; new clients should gate on `session_resume`.181 182`slow_client_warning` covers SSE backpressure behavior: (a) the daemon emits a `slow_client_warning` synthetic event-stream frame when a subscriber's live frame backlog or live serialized-byte backlog crosses 75% full, once per overflow episode (rearmed after both measurements drain below 37.5%); (b) `GET /session/:id/events` accepts a `?maxQueued=N` query param (range `[16, 2048]`) to pre-size the per-subscriber frame backlog for cold reconnects against a large replay ring. The serialized-byte cap is daemon-owned (default **2 MiB** per subscriber), live-only, and intentionally has no query parameter. The daemon-wide ring size is controlled by `--event-ring-size` (default **8000**, per #3803 §02). Old daemons silently lack the warning/query behavior — pre-flight this tag before opting in.183 184`typed_event_schema` advertises daemon event payloads that match the SDK's `KnownDaemonEvent` schema. Older daemons may still stream compatible frames, but SDK clients should pre-flight this tag before assuming typed event coverage.185 186`client_heartbeat` advertises `POST /session/:id/heartbeat`. Older daemons return `404`; pre-flight this tag before issuing periodic heartbeats.187 188`session_close` and `session_metadata` advertise `DELETE /session/:id` and `PATCH /session/:id/metadata`. Older daemons return `404`; pre-flight these tags before exposing close or rename affordances.189 190`session_organization` advertises custom session groups and pinning. It adds `GET/POST/PATCH/DELETE /workspace/:id/session-groups`, `PATCH /session/:id/organization`, and the opt-in organized list view `GET /workspace/:id/sessions?view=organized`. Older daemons return `404` for the mutation/group routes and ignore the organized view contract, so WebShell/SDK clients must pre-flight this tag before showing grouping or pinning UI.191 192`session_archive` advertises the v1 directory-state archive API: `POST /sessions/archive`, `POST /sessions/unarchive`, and `GET /workspace/:id/sessions?archiveState=active|archived`. Archived sessions cannot be loaded or resumed until they are unarchived.193 194`session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status.195 196`session_status` advertises `GET /session/:id/status`, the live bridge summary for a single session by id (`clientCount` / `hasActivePrompt` and the core fields). Older daemons return `404`; pre-flight this tag before polling a single session's status instead of scanning the full session list.197 198`session_approval_mode_control`, `workspace_tool_toggle`, `workspace_init`, and `workspace_mcp_restart` (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 17) advertise the four mutation control routes documented under "Mutation: approval, tools, init, MCP restart" below. All four are strict-gated by the PR 15 mutation gate (a daemon configured without a bearer token rejects them with 401 `token_required`). Older daemons return `404`; pre-flight each tag before exposing the corresponding affordance.199 200`mcp_guardrails` (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14) covers the MCP budget surface: the `clientCount` / `clientBudget` / `budgetMode` / `budgets[]` fields on `GET /workspace/mcp`, the `disabledReason` field on per-server cells, and the `--mcp-client-budget` / `--mcp-budget-mode` CLI flags. Older daemons omit the new fields entirely; SDK clients pre-flight this tag before relying on `budgets[]` semantics. The registry descriptor also carries `modes: ['warn', 'enforce']` for future feature-modes exposure — for now, clients infer mode from the snapshot's `budgetMode` field. Server refusal under `enforce` mode is deterministic by `Object.entries(mcpServers)` declaration order; a future scope-precedence layer (if qwen-code adopts one) would shift this to "lowest-precedence first" to mirror claude-code's `plugin < user < project < local` convention.201 202> ⚠️ **PR 14 v1 scope: per-session, not per-workspace.** Each ACP session inside the daemon constructs its own `Config` + `McpClientManager` (via `acpAgent.newSessionConfig`). The budget caps live MCP clients **per session**; each session independently reads `QWEN_SERVE_MCP_CLIENT_BUDGET` from the forwarded env. With `--mcp-client-budget=10` and 5 concurrent ACP sessions, the actual live MCP client count can reach 5 × 10 = 50 across the daemon. The `GET /workspace/mcp` snapshot reads the **bootstrap session's** `McpClientManager` accounting only — the `budgets[0].scope: 'session'` value is the honest signal that this is per-session, not aggregated. **Wave 5 PR 23 (shared MCP pool)** will introduce a workspace-scoped manager and add a `scope: 'workspace'` cell alongside the per-session cell for true cross-session aggregation. v1 is the in-process counter + soft enforcement foundation that PR 23 builds on.203 204`workspace_file_read` covers the text/list/stat/glob workspace file routes205(`GET /file`, `GET /list`, `GET /glob`, `GET /stat`). `workspace_file_bytes`206covers `GET /file/bytes`, which was added later so clients can pre-flight raw207byte-window support against PR19-era daemons. `workspace_file_write` covers208the hash-aware text mutation routes (`POST /file/write`, `POST /file/edit`).209The write tag means the route contract exists; it does not mean the current210deployment is open for anonymous mutation. Write/edit are strict mutation211routes and require a configured bearer token even on loopback.212 213`daemon_status` advertises `GET /daemon/status`, the consolidated read-only214operator diagnostic snapshot documented below.215 216**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:217 218| Tag | Advertised when … |219| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |220| `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. |221| `mcp_workspace_pool` | the shared MCP transport pool is active. Omitted when `QWEN_SERVE_NO_MCP_POOL=1` disables the pool. |222| `mcp_pool_restart` | the shared MCP transport pool is active; restart responses may include pool-aware multi-entry shapes. |223| `allow_origin` | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). The daemon was started with at least one `--allow-origin <pattern>` (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. |224| `prompt_absolute_deadline` | `--prompt-deadline-ms` / `QWEN_SERVE_PROMPT_DEADLINE_MS` / `ServeOptions.promptDeadlineMs` is set to a positive integer. |225| `writer_idle_timeout` | `--writer-idle-timeout-ms` / `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` / `ServeOptions.writerIdleTimeoutMs` is set to a positive integer. |226| `workspace_settings` | the daemon was created with settings persistence available. |227| `session_shell_command` | session shell execution is explicitly enabled. |228| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. |229| `workspace_reload` | workspace reload support is available in the embedded route configuration. |230 231`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: []`).232 233`mcp_guardrail_events` (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14b) advertises the typed SSE push events that surface MCP budget state crossings without a poll loop. Two frame types arrive on `GET /session/:id/events`:234 235- `mcp_budget_warning` — fires once on the upward 75% crossing of `reservedSlots.size / clientBudget`. Re-arms only after the ratio drops below 37.5% (`MCP_BUDGET_REARM_FRACTION`). Mirrors PR 10's `slow_client_warning` hysteresis, but at the manager level rather than the per-subscriber backlog level. Payload: `{ liveCount, reservedCount, budget, thresholdRatio: 0.75, mode: 'warn' | 'enforce' }`. Fires under both `warn` and `enforce` modes; never under `off`.236- `mcp_child_refused_batch` — fires at end of each `discoverAllMcpTools*` pass when one or more servers were refused, AND as a length-1 batch on the `readResource` lazy-spawn refusal path. Payload: `{ refusedServers: [{ name, transport, reason: 'budget_exhausted' }, ...], budget, liveCount, reservedCount, mode: 'enforce' }`. `mode` is the literal `'enforce'` because `warn` mode never refuses.237 238Both events live in the per-session SSE replay ring (they carry an `id`) so a client reconnecting with `Last-Event-ID` resumes through them; the snapshot at `GET /workspace/mcp` is still the source-of-truth for state-after-extended-disconnect. Always-on once advertised — there is no conditional toggle. SDK reducer state (`DaemonSessionViewState`) exposes `mcpBudgetWarningCount`, `lastMcpBudgetWarning`, `mcpChildRefusedBatchCount`, `lastMcpChildRefusedBatch` for adapters that want simple lag-style UI.239 240## Routes241 242### `GET /health`243 244Liveness probe. Default form returns `200 {"status":"ok"}` if the listener is up — cheap, no bridge access, suitable for high-frequency k8s/Compose liveness probes.245 246Pass `?deep=1` (also accepts `?deep=true` or bare `?deep`) for a probe that exposes bridge **counters** (informational only, not a true liveness check):247 248```json249{ "status": "ok", "sessions": 3, "pendingPermissions": 1 }250```251 252> ⚠️ The deep probe is **informational**, not a real liveness verification. It reads counter accessors (`bridge.sessionCount`, `bridge.pendingPermissionCount`) which are simple Map-size getters; they don't ping individual child processes / channels and so won't detect a wedged-but-still-counted session. Use it for capacity dashboards (current concurrency vs. `--max-sessions`, queue depth) rather than as the trigger for "pull this daemon out of rotation". A `503 {"status":"degraded"}` response is theoretically possible if a custom bridge implementation's getters throw, but the real bridge's getters never do — under normal operation the deep probe always returns 200. For real liveness, rely on whether the listener accepts a TCP connection at all (i.e. the default `/health` without `?deep`).253 254**Auth:** required **only on non-loopback binds**. On loopback (`127.0.0.1`, `::1`, `[::1]`) `/health` is registered before the bearer middleware so k8s/Compose probes inside the pod don't need to carry the token. On non-loopback (`--hostname 0.0.0.0` etc.) the route is registered after the bearer middleware and returns 401 without a valid token — otherwise an unauthenticated caller could probe arbitrary addresses to confirm a `qwen serve` exists, a low-severity info leak that combines poorly with port scanning. CORS deny + Host allowlist still apply on the loopback exemption.255 256### `GET /daemon/status`257 258Read-only operator diagnostics. Unlike `/health`, this is a normal daemon API:259it is registered after bearer auth and rate limiting, including on loopback260binds. Query parameter:261 262- `detail=summary` (default) reads only in-memory daemon state.263- `detail=full` also includes live session diagnostics, ACP connection264 diagnostics, auth device-flow counts, and workspace status sections.265- any other `detail` returns `400 { "code": "invalid_detail" }`.266 267`summary` intentionally does not query workspace status methods, start an ACP268child, or spawn a session. `full` queries each workspace section independently;269a timeout or exception marks only that section as `unavailable` and adds a270`workspace_status_unavailable` issue.271 272Response shape:273 274```json275{276 "v": 1,277 "detail": "summary",278 "generatedAt": "2026-06-16T00:00:00.000Z",279 "status": "ok",280 "issues": [],281 "daemon": {282 "pid": 12345,283 "uptimeMs": 3600000,284 "mode": "http-bridge",285 "workspaceCwd": "/repo",286 "qwenCodeVersion": "0.18.1",287 "daemonId": "serve-..."288 },289 "security": {290 "tokenConfigured": true,291 "requireAuth": false,292 "loopbackBind": true,293 "allowOriginConfigured": false,294 "allowOriginMode": "none",295 "sessionShellCommandEnabled": false296 },297 "limits": {298 "maxSessions": 20,299 "maxTotalSessions": null,300 "maxPendingPromptsPerSession": 5,301 "listenerMaxConnections": 256,302 "eventRingSize": 8000,303 "promptDeadlineMs": null,304 "writerIdleTimeoutMs": null,305 "channelIdleTimeoutMs": 0,306 "sessionIdleTimeoutMs": 1800000,307 "acpConnectionCap": 64308 },309 "runtime": {310 "sessions": { "active": 0 },311 "permissions": { "pending": 0, "policy": "first-responder" },312 "channel": { "live": false },313 "channelWorker": {314 "enabled": false,315 "state": "disabled",316 "channels": []317 },318 "transport": {319 "restSseActive": 0,320 "acp": {321 "enabled": true,322 "connections": 0,323 "connectionStreams": 0,324 "sessionStreams": 0,325 "sseStreams": 0,326 "wsStreams": 0,327 "pendingClientRequests": 0328 }329 },330 "perf": {331 "eventLoop": { "meanMs": 0, "p50Ms": 0, "p99Ms": 0, "maxMs": 0 },332 "promptQueueWait": {333 "count": 0,334 "meanMs": 0,335 "maxMs": 0,336 "lastMs": null337 },338 "pipe": {339 "inbound": { "count": 0, "totalBytes": 0, "maxBytes": 0 },340 "outbound": { "count": 0, "totalBytes": 0, "maxBytes": 0 }341 }342 },343 "activity": {344 "activePrompts": 0,345 "pendingPrompts": 0,346 "queuedPrompts": 0,347 "lastActivityAt": null,348 "idleSinceMs": null349 }350 }351}352```353 354`runtime.perf` is optional. When present, it reports daemon-process event loop355lag, prompt FIFO queue wait samples, and daemon-child pipe byte counters only;356ACP child event loop lag is not included in `/daemon/status`.357 358`status` is `error` if any issue has error severity, `warning` if any issue has359warning severity, otherwise `ok`. Issue codes are stable and include360`session_capacity_high`, `connection_capacity_high`, `pending_permissions`,361`acp_channel_down`, `preflight_error`, `mcp_budget_warning`,362`mcp_budget_exhausted`, `rate_limit_hits`, `channel_worker_exited`, and363`channel_worker_partial_connect`, and `workspace_status_unavailable`. During364the short window after the listener is ready but before the full runtime is365mounted, `/daemon/status` may report `daemon_runtime_starting`; if the async366runtime mount fails, it reports `daemon_runtime_failed` while non-status367runtime routes return `503`.368 369`runtime.activity` reports daemon-wide prompt activity. `activePrompts` counts sessions with an in-flight prompt. `pendingPrompts` counts all accepted prompts that have not settled yet, including the running prompt and FIFO-waiting prompts. `queuedPrompts` counts FIFO-waiting prompts that have been accepted but not dispatched. `lastActivityAt` is the ISO 8601 timestamp of the last prompt start/end or session spawn; `null` when the daemon has never processed any activity since boot. `idleSinceMs` is computed from `lastActivityAt` at response generation time.370 371`limits.maxTotalSessions` is additive. `null` means the daemon-wide fresh-session cap is disabled. When set, it limits fresh session creation across the daemon and reports total-limit failures with the existing `session_limit_exceeded` error shape plus `scope: "total"`. It does not change `/capabilities`, does not advertise `workspaces[]`, and does not enable multi-workspace routing by itself.372 373`runtime.channel.live` reports the ACP bridge channel inside the daemon. It is374not the channel-adapter worker. Daemon-managed channels use375`runtime.channelWorker`, whose `state` is one of `disabled`, `starting`,376`running`, `exited`, `failed`, or `stopped`. When a worker reaches `running`377and then exits, `/daemon/status` keeps the daemon online and reports warning378issue code `channel_worker_exited`.379 380Daemon-managed channel worker startup remains fail-fast: if `qwen serve381--channel ...` cannot start a worker that reaches ready, serve startup fails.382After a worker has reached ready, unexpected exits are restarted by the serve383supervisor within a bounded policy: up to 3 restart attempts in a 5 minute384window, with 1s, 5s, then 15s backoff. The worker sends IPC heartbeats every38515s; if no heartbeat is observed for 45s, the supervisor treats the worker as386stale, kills it, records `staleHeartbeatAt`, and uses the same restart path.387 388`runtime.channelWorker` may include additive operational fields:389`requestedChannels`, `pid`, `startedAt`, `exitCode`, `signal`, `error`,390`restartCount`, `lastExitAt`, `lastRestartAt`, `nextRestartAt`,391`lastHeartbeatAt`, and `staleHeartbeatAt`. `restartCount` is the lifetime392number of restart attempts made by this serve process; a running worker with393`restartCount > 0` is healthy unless another issue applies. A running worker394whose `requestedChannels` include names missing from `channels` reports395`channel_worker_partial_connect`.396 397`qwen channel status` continues to read pidfile metadata. During a restart398window the serve-owned pidfile remains reserved, but `workerPid` is omitted so399clients do not display a stale worker process. Worker stdout/stderr are400forwarded into the daemon log with bearer tokens, sensitive worker environment401values, and proxy URL credentials redacted.402 403Security: the response never includes bearer tokens, client ids, full ACP404connection ids, device-flow user codes, or verification URLs. `summary` omits405the daemon log path; `full` may include it for authenticated operators.406 407### `GET /capabilities`408 409```json410{411 "v": 1,412 "protocolVersions": {413 "current": "v1",414 "supported": ["v1"]415 },416 "mode": "http-bridge",417 "features": ["health", "daemon_status", "capabilities", "..."],418 "modelServices": [],419 "workspaceCwd": "/canonical/path/to/workspace"420}421```422 423Stable contract: when `v` increments the frame layout has changed in a backwards-incompatible way.424 425> **`protocolVersions`** describes the serve protocol versions the daemon can speak. `current` is the daemon's preferred protocol version and `supported` is the compatible set. Clients that require a specific protocol should check `supported`; feature-specific UI should still gate on `features`. Additive to v=1: older v=1 daemons omit this field, so SDK clients that target older builds should treat it as optional.426 427> **`modelServices` is always `[]` in Stage 1.** The agent uses its single default model service and doesn't enumerate it over the wire. Stage 2 will populate this from registered model adapters so SDK clients can build service-pickers; until then, do NOT rely on this field being non-empty.428 429> **`workspaceCwd`** is the canonical absolute path this daemon binds to (#3803 §02 — 1 daemon = 1 workspace). Use it to (a) detect mismatch before posting `/session` and (b) omit `cwd` on `POST /session` (the route falls back to this path). Multi-workspace deployments expose multiple daemons on different ports, each with its own `workspaceCwd`. Additive to v=1: pre-§02 v=1 daemons omit the field — clients that target older builds should null-check before consuming it.430 431### Read-only runtime status routes432 433These routes report daemon-side runtime snapshots. They are additive v1 routes,434do not mutate state, and do not change the serve protocol version. Workspace435status routes intentionally do **not** start the ACP child process just because436a client polls a GET route: if the daemon is idle, they return437`initialized: false` with an empty snapshot. Session status routes require a438live session and use the standard `404 SessionNotFoundError` shape for unknown439ids.440 441Capability tags:442 443- `workspace_mcp` → `GET /workspace/mcp`444- `workspace_skills` → `GET /workspace/skills`445- `workspace_providers` → `GET /workspace/providers`446- `workspace_env` → `GET /workspace/env`447- `workspace_preflight` → `GET /workspace/preflight`448- `session_context` → `GET /session/:id/context`449- `session_supported_commands` → `GET /session/:id/supported-commands`450- `session_tasks` → `GET /session/:id/tasks`451- `session_status` → `GET /session/:id/status`452 453Common status cell:454 455```ts456type DaemonStatus =457 | 'ok'458 | 'warning'459 | 'error'460 | 'disabled'461 | 'not_started'462 | 'unknown';463 464type DaemonErrorKind =465 | 'missing_binary'466 | 'blocked_egress'467 | 'auth_env_error'468 | 'init_timeout'469 | 'protocol_error'470 | 'missing_file'471 | 'parse_error';472 473interface DaemonStatusCell {474 kind: string;475 status: DaemonStatus;476 error?: string;477 errorKind?: DaemonErrorKind;478 hint?: string;479}480```481 482`errorKind` is a closed enum shared by `/workspace/preflight`,483`/workspace/env`, and (eventually) MCP guardrails so SDK clients can render484remediation per category instead of parsing free-form messages. PR 13485(#4175) introduced the seven literals listed above; PR 14 will populate486`blocked_egress` once the egress probe lands.487 488Status payloads never expose MCP env values, headers, OAuth/service-account489details, provider API keys, provider `baseUrl` / `envKey`, skill body, skill490filesystem paths, hook definitions, or values of secret environment491variables. `/workspace/env` reports the **presence** of whitelisted env492vars only; proxy URLs are stripped of credentials and reduced to493`host:port` before they hit the wire.494 495### `GET /workspace/mcp`496 497```json498{499 "v": 1,500 "workspaceCwd": "/canonical/path",501 "initialized": true,502 "discoveryState": "completed",503 "servers": [504 {505 "kind": "mcp_server",506 "status": "ok",507 "name": "docs",508 "mcpStatus": "connected",509 "transport": "stdio",510 "disabled": false,511 "description": "Documentation server",512 "extensionName": "docs-ext"513 }514 ]515}516```517 518`discoveryState` is one of `not_started`, `in_progress`, or `completed`.519`transport` is one of `stdio`, `sse`, `http`, `websocket`, `sdk`, or520`unknown`. `errors` is omitted when discovery succeeds.521 522**MCP client guardrails (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14).** Post-PR-14 daemons extend the payload with four additive fields and one workspace-level cell:523 524```jsonc525{526 "v": 1,527 "workspaceCwd": "/canonical/path",528 "initialized": true,529 "discoveryState": "completed",530 "clientCount": 3,531 "clientBudget": 2,532 "budgetMode": "enforce",533 "budgets": [534 {535 "kind": "mcp_budget",536 "scope": "session",537 "status": "error",538 "errorKind": "budget_exhausted",539 "hint": "Raise --mcp-client-budget or remove servers from mcpServers config.",540 "liveCount": 2,541 "budget": 2,542 "mode": "enforce",543 "refusedCount": 1,544 },545 ],546 "servers": [547 {548 "kind": "mcp_server",549 "status": "ok",550 "name": "a",551 "mcpStatus": "connected",552 "transport": "stdio",553 "disabled": false,554 },555 {556 "kind": "mcp_server",557 "status": "ok",558 "name": "b",559 "mcpStatus": "connected",560 "transport": "stdio",561 "disabled": false,562 },563 {564 "kind": "mcp_server",565 "status": "error",566 "name": "c",567 "mcpStatus": "disconnected",568 "transport": "stdio",569 "disabled": false,570 "disabledReason": "budget",571 "errorKind": "budget_exhausted",572 "hint": "...",573 },574 ],575}576```577 578`budgetMode` is one of `enforce`, `warn`, or `off`. `clientBudget` is absent when no budget was set. `budgets[]` is **always an array** on post-PR-14 daemons (possibly empty when `budgetMode === 'off'`); pre-PR-14 daemons omit the field entirely. v1 emits one cell with `scope: 'session'` (per-session enforcement — see the capabilities section above for why). Consumers MUST tolerate additional `budgets[]` entries with unrecognized `scope` values — Wave 5 PR 23 will add `scope: 'workspace'` (or `'pool'`) alongside the per-session cell without a schema bump.579 580`disabledReason` on per-server cells distinguishes operator-disabled (`'config'` — `disabledMcpServers` config list) from budget-refused (`'budget'` — discovered but never connected due to `enforce` mode). Refusals are deterministic by `Object.entries(mcpServers)` declaration order. The per-server `status: 'error', errorKind: 'budget_exhausted'` shadows the raw `mcpStatus: 'disconnected'` (which is true but not the operator-facing severity).581 582Budget enforcement in PR 14 v1 is **per-session, not per-workspace**. Although Mode B daemons are `1 daemon = 1 workspace × N sessions` post-#4113 at the process level, the `McpClientManager` is constructed inside each ACP session's `Config` via `acpAgent.newSessionConfig`, so N sessions each enforce their own copy of the cap. The snapshot represents the bootstrap session's view. Wave 5 PR 23 introduces a workspace-scoped shared MCP pool that graduates this to true per-workspace enforcement.583 584**Detecting budget pressure.** Two surfaces, both populated post-PR-14b:585 586- **Push events** (advertised via `mcp_guardrail_events`): subscribe to `GET /session/:id/events` and narrow `mcp_budget_warning` / `mcp_child_refused_batch` frames through `KnownDaemonEvent`. The state machine fires once per upward 75% crossing (re-armed below 37.5%); refusals are coalesced once per discovery pass under `enforce` mode.587- **Snapshot poll** (advertised via `mcp_guardrails`): `GET /workspace/mcp` and inspect the per-session budget cell (`budgets[0]`):588 589- `budgets[0].status === 'warning'` ⇔ `liveCount >= 0.75 * clientBudget` (matches the hysteresis threshold PR 14b's push event will use).590- `budgets[0].status === 'error'` ⇔ `refusedCount > 0` (one or more servers refused this discovery pass).591- `budgets[0].status === 'ok'` ⇔ below the 75% threshold AND no refusals.592 593Recommended poll cadence: aligned with whatever already polls `/workspace/mcp`; the snapshot is cheap and the budget cell carries no extra discovery cost. SDK clients that subscribe to push events still benefit from the snapshot for state-after-extended-disconnect (the SSE replay ring depth is finite — `--event-ring-size`, default 8000 — so a client offline longer than the ring's coverage falls back to snapshot resync).594 595### `GET /workspace/skills`596 597```json598{599 "v": 1,600 "workspaceCwd": "/canonical/path",601 "initialized": true,602 "skills": [603 {604 "kind": "skill",605 "status": "ok",606 "name": "review",607 "description": "Review code",608 "level": "project",609 "modelInvocable": true,610 "argumentHint": "[path]"611 }612 ]613}614```615 616`level` is one of `project`, `user`, `extension`, or `bundled`. `errors` is617omitted when discovery succeeds.618 619### `GET /workspace/providers`620 621```json622{623 "v": 1,624 "workspaceCwd": "/canonical/path",625 "initialized": true,626 "current": { "authType": "qwen", "modelId": "qwen3(qwen)" },627 "providers": [628 {629 "kind": "model_provider",630 "status": "ok",631 "authType": "qwen",632 "current": true,633 "models": [634 {635 "modelId": "qwen3(qwen)",636 "baseModelId": "qwen3",637 "name": "Qwen 3",638 "description": null,639 "contextLimit": 4096,640 "isCurrent": true,641 "isRuntime": false642 }643 ]644 }645 ]646}647```648 649Models are grouped by auth type. Provider connection diagnostics live on650`/workspace/preflight`'s `providers` cell; environment preflight lives on651`/workspace/preflight` and `/workspace/env` (below). `errors` is omitted652when snapshot construction succeeds.653 654### `GET /workspace/env`655 656Reports the daemon process's runtime, platform, sandbox, proxy, and the657**presence** of whitelisted secret environment variables. Always answers658from `process.*` state — the daemon never spawns an ACP child to serve659this route, and the response is identical whether ACP is up or idle. The660`acpChannelLive` field is informational only.661 662```json663{664 "v": 1,665 "workspaceCwd": "/canonical/path",666 "initialized": true,667 "acpChannelLive": false,668 "cells": [669 { "kind": "runtime", "name": "node", "status": "ok", "value": "22.4.0" },670 { "kind": "platform", "name": "darwin", "status": "ok", "value": "arm64" },671 {672 "kind": "sandbox",673 "name": "SANDBOX",674 "status": "disabled",675 "present": false676 },677 {678 "kind": "proxy",679 "name": "HTTPS_PROXY",680 "status": "ok",681 "present": true,682 "value": "proxy.internal:1080"683 },684 {685 "kind": "proxy",686 "name": "NO_PROXY",687 "status": "disabled",688 "present": false689 },690 {691 "kind": "env_var",692 "name": "OPENAI_API_KEY",693 "status": "ok",694 "present": true695 },696 {697 "kind": "env_var",698 "name": "ANTHROPIC_BASE_URL",699 "status": "disabled",700 "present": false701 }702 ]703}704```705 706Cell shape:707 708```ts709type DaemonEnvKind =710 | 'runtime' // name: 'node' | 'bun' | 'unknown'; value: process.versions.node711 | 'platform' // name: process.platform; value: process.arch712 | 'sandbox' // name: 'SANDBOX' | 'SEATBELT_PROFILE'; value optional713 | 'proxy' // name: HTTP_PROXY | HTTPS_PROXY | NO_PROXY | ALL_PROXY; value: redacted host714 | 'env_var'; // presence-only; value field is ALWAYS omitted715 716interface DaemonEnvCell extends DaemonStatusCell {717 kind: DaemonEnvKind;718 name: string;719 present?: boolean;720 value?: string;721}722```723 724**Redaction policy.** `kind: 'env_var'` cells never include a `value`725field; clients see `present: boolean` only. `kind: 'proxy'` cells run the726raw env value through credential redaction (`redactProxyCredentials`) and727then through `URL` parsing so the wire only carries `host:port`. `NO_PROXY`728is passed through redaction verbatim because it is a host list rather than729a URL. The whitelist of enumerated secret env vars currently includes730`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `GOOGLE_API_KEY`,731`DASHSCOPE_API_KEY`, `OPENROUTER_API_KEY`, and `QWEN_SERVER_TOKEN`. Other732env vars are not enumerated, so accidentally-set secrets stay invisible.733 734### `GET /workspace/preflight`735 736Reports daemon readiness checks. **Daemon-level cells** (`node_version`,737`cli_entry`, `workspace_dir`, `ripgrep`, `git`, `npm`) are always738populated from `process.*` and `node:fs`. **ACP-level cells** (`auth`,739`mcp_discovery`, `skills`, `providers`, `tool_registry`, `egress`)740require a live ACP child — when the daemon is idle they emit741`status: 'not_started'` placeholders. The route never spawns ACP solely742to populate cells; the corresponding cells fall back to `not_started`.743 744Idle response (no ACP child):745 746```json747{748 "v": 1,749 "workspaceCwd": "/canonical/path",750 "initialized": true,751 "acpChannelLive": false,752 "cells": [753 {754 "kind": "node_version",755 "status": "ok",756 "locality": "daemon",757 "detail": { "version": "22.4.0", "required": ">=22" }758 },759 {760 "kind": "cli_entry",761 "status": "ok",762 "locality": "daemon",763 "detail": { "path": "/usr/local/bin/qwen", "source": "process.argv[1]" }764 },765 {766 "kind": "workspace_dir",767 "status": "ok",768 "locality": "daemon",769 "detail": { "path": "/canonical/path" }770 },771 { "kind": "ripgrep", "status": "ok", "locality": "daemon" },772 {773 "kind": "git",774 "status": "ok",775 "locality": "daemon",776 "detail": { "version": "2.45.0" }777 },778 {779 "kind": "npm",780 "status": "ok",781 "locality": "daemon",782 "detail": { "version": "10.7.0" }783 },784 {785 "kind": "auth",786 "status": "not_started",787 "locality": "acp",788 "hint": "spawn a session to populate"789 },790 {791 "kind": "mcp_discovery",792 "status": "not_started",793 "locality": "acp",794 "hint": "spawn a session to populate"795 },796 {797 "kind": "skills",798 "status": "not_started",799 "locality": "acp",800 "hint": "spawn a session to populate"801 },802 {803 "kind": "providers",804 "status": "not_started",805 "locality": "acp",806 "hint": "spawn a session to populate"807 },808 {809 "kind": "tool_registry",810 "status": "not_started",811 "locality": "acp",812 "hint": "spawn a session to populate"813 },814 {815 "kind": "egress",816 "status": "not_started",817 "locality": "acp",818 "hint": "egress probing lands in PR 14 (#4175)"819 }820 ]821}822```823 824Cell shape:825 826```ts827type DaemonPreflightKind =828 | 'node_version'829 | 'cli_entry'830 | 'workspace_dir'831 | 'ripgrep'832 | 'git'833 | 'npm'834 | 'auth'835 | 'mcp_discovery'836 | 'skills'837 | 'providers'838 | 'tool_registry'839 | 'egress';840 841interface DaemonPreflightCell extends DaemonStatusCell {842 kind: DaemonPreflightKind;843 locality: 'daemon' | 'acp';844 detail?: Record<string, unknown>;845}846```847 848`errorKind` semantics:849 850- `missing_binary` — Node version below required, missing `QWEN_CLI_ENTRY`,851 ripgrep / git / npm not on PATH (warnings rather than errors for the852 optional binaries).853- `missing_file` — `boundWorkspace` does not exist or is not a directory;854 skill parse error pointing at a missing or unreadable file.855- `parse_error` — `SKILL.md` parse failure, malformed config JSON.856- `auth_env_error` — `validateAuthMethod` returned a non-null failure857 string, or a `ModelConfigError` subclass propagated from provider858 resolution.859- `init_timeout` — `withTimeout` reject in the bridge (an actual timeout860 while waiting on an ACP roundtrip). Recognized via the861 `BridgeTimeoutError` typed class. Note: a transient `mcp_discovery`862 `warning` cell with `connecting > 0` does NOT carry this kind — that's863 a normal handshake-in-progress state, distinct from a real timeout.864- `protocol_error` — ACP `extMethod` rejected because the channel closed865 mid-request, or because tool registry was unexpectedly absent.866- `blocked_egress` — reserved for PR 14 (#4175). PR 13 leaves the867 `egress` cell as `status: 'not_started'`.868 869If the bridge fails to reach the ACP child while serving a preflight870request (e.g. a mid-request channel close), the envelope's `errors` array871carries a single `ServeStatusCell` describing the failure and the cells872fall back to `not_started` ACP placeholders. Daemon-level cells are still873returned.874 875### Workspace file routes876 877All file paths are resolved through the daemon's bound workspace. Responses use878workspace-relative paths and never return absolute filesystem paths for normal879success cases. Successful file responses include:880 881```http882Cache-Control: no-store883X-Content-Type-Options: nosniff884```885 886Filesystem errors use this JSON shape:887 888```json889{890 "errorKind": "hash_mismatch",891 "error": "expected sha256:..., found sha256:...",892 "hint": "re-read the file and retry with the latest hash",893 "status": 409894}895```896 897`errorKind` values include `path_outside_workspace`, `symlink_escape`,898`path_not_found`, `binary_file`, `file_too_large`, `untrusted_workspace`,899`permission_denied`, `parse_error`, `hash_mismatch`,900`file_already_exists`, `text_not_found`, and `ambiguous_text_match`.901 902#### `GET /file`903 904Reads a text file. Query params: `path` (required), `maxBytes`, `line`, and905`limit`. The daemon rejects binary files and files above the text read cap.906The response includes `hash`, a SHA-256 digest over the raw on-disk bytes for907the whole file, even when `line`, `limit`, or `maxBytes` returned a slice.908 909```json910{911 "kind": "file",912 "path": "src/index.ts",913 "content": "export {};\n",914 "encoding": "utf-8",915 "bom": false,916 "lineEnding": "lf",917 "sizeBytes": 11,918 "returnedBytes": 11,919 "truncated": false,920 "hash": "sha256:...",921 "matchedIgnore": null,922 "originalLineCount": null923}924```925 926#### `GET /file/bytes`927 928Reads raw bytes from a file without decoding. Query params: `path` (required),929`offset` (default `0`), and `maxBytes` (default `65536`, max `262144`). This930route supports bounded windows on large binary files without slurping the whole931file. The response includes `hash` only when the returned window covers the932entire file.933 934```json935{936 "kind": "file_bytes",937 "path": "assets/logo.png",938 "offset": 0,939 "sizeBytes": 3912,940 "returnedBytes": 3912,941 "truncated": false,942 "contentBase64": "...",943 "hash": "sha256:..."944}945```946 947#### `POST /file/write`948 949Creates or replaces a text file. This is a strict mutation route: on loopback950without a configured token it returns `401 { "code": "token_required" }`.951With `--require-auth`, the global bearer middleware rejects unauthenticated952requests before the route runs.953 954Body:955 956```json957{958 "path": "src/new.ts",959 "content": "export const value = 1;\n",960 "mode": "create"961}962```963 964```json965{966 "path": "src/existing.ts",967 "content": "export const value = 2;\n",968 "mode": "replace",969 "expectedHash": "sha256:..."970}971```972 973`mode` must be `create` or `replace`. `create` never overwrites an existing974file (`409 file_already_exists`). `replace` requires `expectedHash`; missing or975malformed hashes are `400 parse_error`, and stale hashes are976`409 hash_mismatch`. `expectedHash` is `sha256:` plus 64 lowercase hex977characters, computed over raw on-disk bytes.978 979`bom`, `encoding`, and `lineEnding` may be supplied. Replacement preserves the980existing file's encoding profile by default; explicit fields override it.981Binary writes are out of scope.982 983The daemon writes to a random temp file in the target directory, fsyncs where984supported, re-checks the current hash immediately before `rename()`, then985renames into place. This prevents partial-file observation and serializes986daemon-originated writes to the same file, but it is not a cross-process987kernel compare-and-swap: an external editor can still race in the tiny window988between final hash check and rename.989 990```json991{992 "kind": "file_write",993 "path": "src/existing.ts",994 "mode": "replace",995 "created": false,996 "sizeBytes": 24,997 "hash": "sha256:...",998 "encoding": "utf-8",999 "bom": false,1000 "lineEnding": "lf",1001 "matchedIgnore": null1002}1003```1004 1005#### `POST /file/edit`1006 1007Applies one exact text replacement to an existing text file. This is also a1008strict mutation route and requires `expectedHash`.1009 1010```json1011{1012 "path": "src/config.ts",1013 "oldText": "timeout: 30000",1014 "newText": "timeout: 60000",1015 "expectedHash": "sha256:..."1016}1017```1018 1019`oldText` must be non-empty and occur exactly once. No match returns1020`422 text_not_found`; multiple matches return `422 ambiguous_text_match`.1021The route preserves encoding, BOM, and line endings, and re-checks1022`expectedHash` immediately before the atomic rename.1023 1024Explicit writes/edits to ignored paths are allowed because the authenticated1025caller named the path. Success responses and audit events include1026`matchedIgnore: "file" | "directory" | null`.1027 1028```json1029{1030 "kind": "file_edit",1031 "path": "src/config.ts",1032 "replacements": 1,1033 "sizeBytes": 128,1034 "hash": "sha256:...",1035 "encoding": "utf-8",1036 "bom": false,1037 "lineEnding": "lf",1038 "matchedIgnore": null1039}1040```1041 1042### `GET /session/:id/context`1043 1044```json1045{1046 "v": 1,1047 "sessionId": "<sid>",1048 "workspaceCwd": "/canonical/path",1049 "state": {1050 "models": {},1051 "modes": {},1052 "configOptions": []1053 }1054}1055```1056 1057`state` mirrors the same ACP model/mode/config-option shapes used by1058`POST /session`, `POST /session/:id/load`, and `POST /session/:id/resume`.1059 1060### `GET /session/:id/supported-commands`1061 1062```json1063{1064 "v": 1,1065 "sessionId": "<sid>",1066 "availableCommands": [1067 {1068 "name": "init",1069 "description": "Initialize the project",1070 "input": null,1071 "_meta": { "source": "builtin" }1072 }1073 ],1074 "availableSkills": ["review"]1075}1076```1077 1078`availableCommands` is the same command snapshot used by the1079`available_commands_update` SSE notification. `availableSkills` lists skill1080names only; clients must not expect skill bodies or paths over this route.1081 1082### `GET /session/:id/tasks`1083 1084```json1085{1086 "v": 1,1087 "sessionId": "<sid>",1088 "now": 1700000000000,1089 "tasks": [1090 {1091 "kind": "agent",1092 "id": "agent-1",1093 "label": "reviewer: check failure",1094 "description": "check failure",1095 "status": "running",1096 "startTime": 1699999999000,1097 "runtimeMs": 1000,1098 "outputFile": "/tmp/agent-1.jsonl",1099 "isBackgrounded": true,1100 "subagentType": "reviewer"1101 },1102 {1103 "kind": "agent",1104 "id": "agent-2",1105 "label": "general-purpose: run the failing test",1106 "description": "run the failing test",1107 "status": "running",1108 "startTime": 1699999999500,1109 "runtimeMs": 500,1110 "outputFile": "/tmp/agent-2.jsonl",1111 "isBackgrounded": false,1112 "subagentType": "general-purpose",1113 "parentAgentId": "agent-1",1114 "parentName": "reviewer",1115 "depth": 11116 }1117 ]1118}1119```1120 1121This route is a read-only out-of-band snapshot. It is intentionally not a1122prompt and can be queried while the session is streaming. The response only1123contains whitelisted metadata from the agent, shell, and monitor task1124registries; controllers, timers, offsets, pending messages, and raw registry1125objects are never exposed.1126 1127Agent tasks spawned by another sub-agent (nested sub-agents, bounded by1128`maxSubagentDepth`) carry three optional lineage fields: `parentAgentId` (the1129spawning agent task's `id`), `parentName` (the spawning agent's1130`subagentType`, captured at registration so it survives the parent's eviction1131from the registry), and `depth` (0-based launch depth; 0 = spawned by the1132top-level session). Agents launched by the top-level session omit1133`parentAgentId` and `parentName`; clients should treat all three fields as1134optional and fall back to a flat list when they are absent.1135 1136### `GET /session/:id/lsp`1137 1138```json1139{1140 "v": 1,1141 "sessionId": "<sid>",1142 "workspaceCwd": "/canonical/path",1143 "enabled": true,1144 "configuredServers": 1,1145 "readyServers": 1,1146 "failedServers": 0,1147 "inProgressServers": 0,1148 "notStartedServers": 0,1149 "servers": [1150 {1151 "name": "typescript",1152 "status": "READY",1153 "languages": ["typescript", "javascript"],1154 "transport": "stdio",1155 "command": "typescript-language-server"1156 }1157 ]1158}1159```1160 1161`status` is one of `NOT_STARTED`, `IN_PROGRESS`, `READY`, or `FAILED`.1162Optional `error` is present on failed servers when available. Disabled LSP1163(including bare mode) returns HTTP 200 with `enabled: false`, zero counts, and1164`servers: []`. LSP enabled with no configured servers returns `enabled: true`,1165`configuredServers: 0`, and `servers: []`. If initialization fails before the1166client exists, the response may include `initializationError`; if a live client1167cannot provide a snapshot, the response includes `statusUnavailable: true`.1168 1169This route exposes only stable client-facing fields. It intentionally omits1170debug internals such as process IDs, spawn args, stderr tails, root URIs, and1171workspace-folder paths.1172 1173### `POST /session`1174 1175Spawn a new agent or attach to an existing one (under `sessionScope: 'single'`, the default).1176 1177Request:1178 1179```json1180{1181 "cwd": "/absolute/path/to/workspace",1182 "modelServiceId": "qwen-prod",1183 "sessionScope": "thread"1184}1185```1186 1187| Field | Required | Notes |1188| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |1189| `cwd` | no | Absolute path matching the daemon's bound workspace. If omitted, the route falls back to `boundWorkspace` (read it off `/capabilities.workspaceCwd`). A mismatched non-empty `cwd` returns `400 workspace_mismatch` (#3803 §02 — 1 daemon = 1 workspace). Workspace paths are canonicalized via `realpathSync.native` (with a resolve-only fallback for non-existent paths) so case-insensitive filesystems don't reject sessions per spelling. |1190| `modelServiceId` | no | Selects which configured _model service_ the agent will route through (the back-end provider — Alibaba ModelStudio, OpenRouter, etc). If omitted the agent uses its default. If the workspace already has a session, this calls `setSessionModel` on the existing one and broadcasts `model_switched`. Distinct from `modelId` on `POST /session/:id/model`, which selects the model **within** an already-bound service. The `modelServices` array on `/capabilities` is reserved for advertising configured services; in Stage 1 it is always `[]` (the agent's default service is used and not enumerated over HTTP). |1191| `sessionScope` | no | Per-request override for session sharing. `'single'` (the daemon-wide default) makes a second same-workspace `POST /session` reuse the existing session (`attached: true`); `'thread'` forces a fresh distinct session every call. Omit to inherit the daemon-wide default. Values outside the enum return `400 { code: 'invalid_session_scope' }`. Old daemons (pre-#4175 PR 5) silently ignore the field — pre-flight `caps.features.session_scope_override` before sending. The daemon-wide default is hardcoded to `'single'` in production today; #4175 may add a `--sessionScope` CLI flag in a follow-up. |1192 1193Response:1194 1195```json1196{1197 "sessionId": "<uuid>",1198 "workspaceCwd": "/canonical/path",1199 "attached": false1200}