qwen-code/docs/developers/daemon/01-architecture.md
易良 04043e555d
feat: consolidate Local Control into one daemon-owned implementation (#9106)
* feat(cli): add daemon-owned Local Control service

Local Control is implemented twice today — once in the CLI, once as an
830-line Rust TCP proxy in the Tauri shell — with two divergent security
models. This adds the daemon-side service both can collapse onto.

The Rust proxy exists only because `qwen serve` fixes its bind address at
startup and cannot add a listener later; everything it does (Host/Origin
rewriting, CRLF rejection, connection caps) is compensation for that one
fact. `LocalControlService` attaches a second `http.Server` over the same
Express app at runtime, so there is no hop to rewrite.

Phases 1-3 of docs/plans/2026-08-13-local-control-consolidation.md:

- Listener identity tagged on the `http.Server`, resolved per request, so
  credentials scope to the listener a request arrived on.
- `CredentialStore` replaces `bearerAuth`'s single pre-hashed token. The
  runtime token is rejected on the LAN listener and the pairing token on
  loopback — the invariant the Rust proxy enforced by rejecting requests
  carrying the runtime token. Fixes the CLI path handing the LAN the
  full-strength daemon token with no revocation short of restart.
- `hostAllowlist` now gates the LAN listener against its advertised
  authority. Previously it opted out entirely off loopback, leaving the
  CLI path with no DNS-rebinding defense.
- `MutableOriginAllowlist` lets the LAN origin be added and removed at
  runtime; the middleware is still installed once. Empty-allowlist
  behavior is identical to the `denyBrowserOriginCors` wall it replaces.
- ACP WS upgrade tracks a set of servers instead of one, and scopes the
  subprotocol credential the same way as the REST gate.
- LAN selection advertises private/link-local IPv4 only, and surfaces
  ambiguity to the caller instead of failing (Rust) or emitting a QR per
  interface (CLI).

Refs #9075

* feat(cli): wire Local Control into the daemon boot sequence

Constructs the service in `createServeApp`, where the credential store and
the CORS allowlist it mutates already live, and publishes it on
`app.locals` alongside `acpHandle` — the channel `runQwenServe` already
uses to reach into the built app for lifecycle work.

- `bearerAuth` now takes the listener-scoped `CredentialStore`, and the
  ACP WS mount takes the same one so the `qwen-bearer.*` subprotocol
  cannot sidestep the scoping the REST gate enforces.
- The CORS middleware is installed unconditionally over a
  `MutableOriginAllowlist`. With no `--allow-origin` this returns the same
  403 envelope as the `denyBrowserOriginCors` wall it replaces, so the
  default posture is unchanged.
- Daemon teardown disables Local Control before disposing the ACP handle,
  since detaching the LAN listener's upgrade registration goes through it.
  Token revocation and origin removal are synchronous, so they complete
  even though the enclosing dispose scope cannot await the socket close.
- The LAN listener honors `--tls-cert` / `--tls-key`, reading them at
  enable time so a renewed certificate is picked up. Serving plaintext off
  a daemon deliberately put behind TLS would downgrade the more exposed of
  the two surfaces; `status.encrypted` reports which it is.

Refs #9075

* feat(cli): repoint --local-control at the daemon service

The flag stops being a second implementation and becomes a caller.

Previously `--local-control` commandeered the daemon: it bound to
`0.0.0.0`, generated a token that WAS the daemon token, and rewrote the
origin allowlist — which is why it conflicted with `--token`,
`--hostname`, `--allow-origin`, and an ephemeral port. The daemon now owns
a separate LAN listener with a separate revocable credential, so none of
those are in tension. A daemon can serve authenticated loopback and run a
Local Control session at the same time, and `--no-web` is the only
remaining conflict.

- `localControlUrls` is deleted. Its "every non-internal IPv4" policy is
  the bug the service's private/link-local selection replaces; it would
  put a VPN or public address in a QR code.
- Ambiguous multi-network hosts get `--local-control-address <ip>` instead
  of a QR per interface.
- Sleep inhibition moves into the service, so it is held while the LAN
  listener is up and released when it goes down rather than for the
  lifetime of the process.
- The pairing line now reports actual sleep-inhibition and encryption
  state instead of asserting the common case.
- `RunHandle.getLocalControl()` reaches the service; a getter because the
  runtime app is mounted after the listener is up.

Refs #9075

* fix(cli): harden daemon-owned Local Control

* fix(cli): flush Local Control disable response

* feat(desktop): move Local Control into Settings

* fix(local-control): close listener lifecycle gaps

* fix(cli): resolve local control review comments

* fix(local-control): align route lifecycle

* fix(serve): close local control review gaps

* fix(serve): close Local Control QR and bridge-filter review blockers

QR rendering in the Local Control routes is now best-effort: an
over-capacity pairing URL (the target deep-link is caller-influenced)
no longer turns enable/status into a 500 while the LAN listener stays
live, which wedged the Web Shell card with no disable path. The
interface denylist also stops rejecting physical LAN bridges (br0,
Windows "Network Bridge") and only filters the virtual bridge shapes
(Docker br-<hex>, macOS bridge<N>), matching the deleted Rust filter's
per-platform behavior. Adds regression tests for both.

* fix(serve): close round-5 Local Control review findings

- Card: reconcile the selected LAN address on every status update, so a
  stale selection cannot survive a network change when only one candidate
  remains (the selector is hidden in that case and gave no affordance).
- Interface filter: fold the hex run into the Docker bridge token
  (br-[0-9a-f]+) so bridge IDs starting with a letter stop escaping the
  shared boundary check.
- LAN listener: drop the whole-request timeout budget; Node never resets
  it on body chunks, so it 408'd phones trickling large uploads through
  the shared Express app. Header and keep-alive timeouts stay.
- Copy: Ctrl+C ends the whole daemon, not just Local Control (design
  doc, terminal banner, --local-control description).
- Accessibility: aria-live on the card, role=alert on its error line.
- Tests: QR happy path, listen-error handler cleanup, strict
  error-handler count after enable, letter-starting Docker bridge.

* fix(web-shell): preserve local control base paths

* fix(local-control): close round-7 review findings

- card: keep the 409 candidate list on the error path — requestLocalControl
  attaches the parsed payload to the thrown error and toggle reconciles
  status/selection from it, so a stale address after a DHCP change recovers
  without a page remount (R7-3)
- lan-interfaces: match `vpn` as a substring and add a `wintun` token,
  closing the OpenVPN Wintun escape (boundary semantics let `vpn` sit
  inside "openvpn" unmatched) plus mid-word names like vpnkit; regression
  test covers the adapter family (R7-1 demonstrated entrance; structural
  per-platform classification stays a follow-up)
- drop the orphaned strictPort ServeOptions field, the EADDRINUSE-bump
  condition reading it, and its test — no production entry point sets it
  anymore (R7-5)
- docs: refresh 12-auth-security.md / 02-serve-runtime.md for the new
  middleware topology — unconditional allowOriginCors over the mutable
  allowlist on the runtime app, deny wall only in the bootstrap app,
  listener-scoped pairing credential on the LAN listener, and the new
  mutation-gate row (R7-2)
- remove the dead selectLanAddress barrel re-export

* fix(local-control): enforce the loopback-bind precondition on runtime enable

The LAN listener binds the primary listener's port on the selected LAN
address, so a wildcard or LAN primary bind already owns it — the
`--local-control` CLI flag refuses that configuration at boot, but the
runtime enable route (driven by the Web Shell Settings card) skipped the
check and surfaced a 500 `listen EADDRINUSE` with no remediation,
silently unusable for the whole class of non-loopback deployments.

Return 409 `local_control_non_loopback_bind` with the actionable
restart hint instead; loopback binds (127.0.0.1/localhost/::1) stay
enabled via the shared `isLoopbackBind` helper.

* fix(local-control): close round-8 demonstrated escapes + doc/test gaps

R7-1 (demonstrated false negatives): Docker veth peer IDs are
veth<hex> and may be letter-led (vethd4a1b2c), which the bare token's
digit boundary let escape — the token takes the same shape as br-<hex>.
Corporate SSL-VPN adapters (Cisco AnyConnect, GlobalProtect, Pulse
Secure, FortiClient, Cloudflare WARP) carry no `vpn` substring, so
their vendor names are listed explicitly; a sole-candidate VPN address
is no longer silently auto-advertised in the QR. Regression tests cover
all six shapes. The vEthernet-external false positive and the class fix
(structural classification instead of name matching) remain under #9158.

Also: the settings card's status-fetch effect clears a stale error on
re-run and ignores superseded responses; the detach test now connects a
primary-listener client and asserts it survives detachServer (the
per-server filter previously survived a mutation probe); the flags table
gains the --local-control-address row; the design doc states that
--allow-origin origins stay admitted alongside the LAN origin; the three
Host-gate doc surfaces note that the LAN listener always enforces its
advertised-authority Host check.

* fix(local-control): bound slow-body slots + close round-7 adapter escapes (#9106)

- service: replace requestTimeout=0 with a bounded 30-minute whole-request
  budget; an unlimited budget let an unauthenticated LAN client trickle
  bodies and hold every pre-auth connection slot open indefinitely
  (headersTimeout covers only headers, keepAliveTimeout only idle sockets)
- lan-interfaces: add interim vendor tokens for post-rename SSL-VPN
  successors (ivanti, cisco secure, citrix, sonicwall); Ivanti Connect
  Secure (Pulse Secure renamed) escaped the enumerated list and was
  auto-advertised in the QR. Class fix stays tracked in #9158
- auth: document the MutationGateOptions caveat that on a no-token daemon
  the Local Control pairing credential admits loopback callers to the
  strict surface (round-7 design decision still open)

* fix(local-control): stop serving the pairing secret to unauthenticated callers (#9106)

Probe-verified hole: on a tokenless daemon any local process could POST
/workspace/local-control/enable (or GET the unguarded status route) and
read status.url — the pairing token in the fragment — then present it on
the LAN listener, where the strictDenier passthrough admitted it to the
whole strict mutation surface (file writes, memory CRUD, git push/pull,
extension/MCP control) without the operator ever scanning anything.

Close the acquisition step:
- GET status / POST enable / POST disable now return url + qrText only to
  requests bearerAuth actually authenticated (requestWasAuthenticated);
  unauthenticated callers get the status with the secret stripped and
  urlRedacted: true while active
- on an unauthenticated enable the pairing URL is printed to the daemon's
  own terminal instead — the one channel a local attacker cannot read over
  HTTP
- web-shell Settings card renders a terminal hint when urlRedacted (en/zh)
- MutationGateOptions caveat rewritten to the resolved state

Authenticated callers (daemon token) are unchanged. Route tests: redaction
for unauthenticated GET/enable, full payload for authenticated callers,
terminal print on enable; suites 42/42, eslint/prettier clean, Codex
security review CLEAN.

* fix(local-control): close round-9/10/11 review findings (#9106)

- write the pairing URL with writeStdoutLineSafe so a dead/full stdout
  cannot wedge enable into a false 500
- reject an empty --local-control-address instead of silently dropping it
- pin --token/--allow-origin composition through to runQwenServe
- drop stale serve.ts file:line references in credentials/lan-interfaces
- correct the CORS caveat and the design doc's flag/origin claims

* test(serve): drop stale strict-port assertion

* fix(local-control): close round-12 review findings (#9106)

- give the composition test a full enable payload and a handle.close so
  the detached handler cannot leak a real process.exit(1)
- wrap the QR dynamic import + setErrorLevel in withUiData's fault
  isolation so a broken qrcode-terminal degrades to the raw URL instead
  of 500ing every status/enable
- fix the ZH urlRedacted copy (it prints a URL, not a QR)
- finish the denyBrowserOriginCors -> allowOriginCors doc sweep in
  01-architecture.md and 18-error-taxonomy.md

---------

Co-authored-by: yiliang114 <yiliang114@users.noreply.github.com>
2026-08-17 16:44:48 +00:00

14 KiB

Daemon Architecture

Overview

A qwen serve process hosts one Express HTTP server and one primary workspace by default. With multi_workspace_sessions enabled it may also host additional workspace runtimes for the live session closed loop; each registered workspace owns its own @qwen-code/acp-bridge / qwen --acp child pair. Multiple clients (CLI TUI, IDE companion, IM channel bots, web BFFs, custom scripts) connect over HTTP + SSE and either share one ACP session (sessionScope: 'single', default) or split sessions by conversation thread (sessionScope: 'thread').

Inside the ACP child, MCP servers are shared workspace-wide through McpTransportPool (F2): a single (server-name + config-fingerprint) tuple maps to one MCP transport, regardless of how many sessions discover it. The bridge's MultiClientPermissionMediator (F3) coordinates permission votes across all connected clients under one of four policies.

This doc gives the system-level picture that the rest of this documentation set builds on. Each critical flow is shown as a Mermaid sequence diagram; per-component implementation details live in the other 18 docs.

Process topology

flowchart LR
    subgraph clients["Clients"]
        WUI["Web UI<br/>(packages/webui/src/daemon)"]
        TUI["CLI TUI<br/>(packages/cli/src/ui/daemon)"]
        IDE["VS Code IDE<br/>(packages/vscode-ide-companion)"]
        CH["Channel bots<br/>(DingTalk / WeChat / Telegram / Feishu)"]
        SDK["Any SDK consumer<br/>(packages/sdk-typescript/src/daemon)"]
    end

    subgraph daemon["qwen serve process (primary workspace plus optional session runtimes)"]
        EXP["Express app<br/>(packages/cli/src/serve/server.ts)"]
        BR["AcpBridge<br/>(packages/acp-bridge/src/bridge.ts)"]
        MED["MultiClientPermissionMediator<br/>(F3)"]
        EB["EventBus per session<br/>(eventBus.ts)"]
        FS["WorkspaceFileSystem<br/>(cli/src/serve/fs/)"]
    end

    subgraph child["ACP child process (qwen --acp)"]
        AGT["QwenAgent runtime"]
        POOL["McpTransportPool<br/>(F2, core/src/tools)"]
        BDG["WorkspaceMcpBudget"]
    end

    subgraph external["External"]
        MCP1["MCP server A<br/>(stdio)"]
        MCP2["MCP server B<br/>(websocket)"]
    end

    WUI -- "HTTP+SSE" --> EXP
    TUI -- "HTTP+SSE" --> EXP
    IDE -- "HTTP+SSE (loopback)" --> EXP
    CH -- "HTTP+SSE" --> EXP
    SDK -- "HTTP+SSE" --> EXP

    EXP --> BR
    BR --> MED
    BR --> EB
    EXP --> FS

    BR -- "ACP NDJSON over stdio" --> AGT
    AGT --> POOL
    POOL --> BDG
    POOL -- "shared transport" --> MCP1
    POOL -- "shared transport" --> MCP2

The daemon process and the ACP child are connected by an AcpChannel (default: a real subprocess stdio pipe pair; inMemoryChannel for tests). Everything the daemon does is shaped by this split: HTTP and SSE traffic terminate in the daemon, agent decisions and tool invocations happen in the child, and the bridge connects the two.

Package map

flowchart TB
    subgraph serve["packages/cli/src/serve"]
        RQS["run-qwen-serve.ts<br/>(bootstrap)"]
        SRV["server.ts (Express)"]
        CAP["capabilities.ts"]
        AUTH["auth.ts"]
        FSM["fs/ (sandbox)"]
        DSP["daemon-status-provider.ts"]
    end

    subgraph br["packages/acp-bridge"]
        BR2["bridge.ts"]
        BC2["bridgeClient.ts"]
        EB2["eventBus.ts"]
        MED2["permissionMediator.ts"]
        ST2["status.ts"]
        CH2["channel.ts / spawnChannel.ts"]
    end

    subgraph core["packages/core/src/tools"]
        POOL2["mcp-transport-pool.ts"]
        ENT["mcp-pool-entry.ts"]
        WBG["mcp-workspace-budget.ts"]
        SMV["session-mcp-view.ts"]
    end

    subgraph sdk["packages/sdk-typescript/src/daemon"]
        DC["DaemonClient.ts"]
        DSC["DaemonSessionClient.ts"]
        EVT["events.ts"]
        SSE["sse.ts"]
        AUTHF["DaemonAuthFlow.ts"]
        UI["ui/* (#4328 + #4353)<br/>normalizer / transcript / store / render"]
    end

    subgraph adapters["Adapters"]
        WUIP["webui/src/daemon/<br/>DaemonSessionProvider.tsx"]
        TUIA["cli/src/ui/daemon/<br/>daemon-tui-adapter.ts"]
        CHB["channels/base/<br/>DaemonChannelBridge.ts"]
        DT["channels/dingtalk"]
        WX["channels/weixin"]
        TG["channels/telegram"]
        FS["channels/feishu"]
        IDEA["vscode-ide-companion/<br/>daemonIdeConnection.ts"]
    end

    RQS --> SRV
    RQS --> CAP
    RQS --> AUTH
    RQS --> FSM
    RQS --> BR2

    BR2 --> BC2
    BR2 --> EB2
    BR2 --> MED2
    BR2 --> CH2

    BR2 -.spawns.-> core
    POOL2 --> ENT
    POOL2 --> WBG
    POOL2 --> SMV

    WUIP --> DSC
    WUIP --> UI
    TUIA --> DSC
    CHB --> DSC
    DT --> CHB
    WX --> CHB
    TG --> CHB
    IDEA --> DSC

    DSC --> DC
    DC --> EVT
    DC --> SSE
    DC --> AUTHF

Three trust boundaries matter: the HTTP edge (serve/auth.ts middleware chain), the bridge-to-ACP-child boundary (NDJSON over stdio, no auth; the child trusts the bridge implicitly), and the agent-to-MCP-server boundary (the agent may invoke tools that touch the host).

Workflow 1: HTTP request lifecycle

sequenceDiagram
    autonumber
    participant C as Client (SDK)
    participant MW as Middleware<br/>(CORS→host→log→bearer→rate-limit→JSON→telemetry→mutationGate)
    participant R as Route handler
    participant BR as AcpBridge
    participant BC as BridgeClient
    participant CH as ACP child

    C->>MW: POST /session/:id/prompt<br/>Authorization: Bearer …<br/>X-Qwen-Client-Id: …
    MW->>MW: allowOriginCors (mutable allowlist; unmatched Origin -> 403)
    MW->>MW: hostAllowlist (DNS rebinding guard)
    MW->>MW: access-log hook
    MW->>MW: bearerAuth (constant-time compare)
    MW->>MW: rateLimit (when enabled)
    MW->>MW: express.json body parser
    MW->>MW: daemonTelemetryMiddleware
    MW->>MW: mutationGate (strict on mutating routes)
    MW->>R: req validated
    R->>BR: bridge.sendPrompt(sessionId, body, clientId)
    BR->>BC: client.sendPrompt(sessionId, …)
    BC->>CH: ACP JSON-RPC over stdin
    CH-->>BC: ACP response / notifications
    BC-->>BR: result
    BR-->>R: result
    R-->>C: 200 JSON

Non-streaming routes (prompt, cancel, model switch, metadata, workspace CRUD) terminate as a single JSON reply. Streaming output is delivered out-of-band on the SSE channel, not as a chunked HTTP body on this connection. See workflow 2.

Workflow 2: SSE event delivery and replay

sequenceDiagram
    autonumber
    participant C as Client
    participant SR as GET /session/:id/events
    participant EB as EventBus<br/>(per session)
    participant BC as BridgeClient
    participant CH as ACP child

    C->>SR: GET …/events<br/>Last-Event-ID: 42 (optional)
    SR->>EB: subscribe(lastSeenId=42, maxQueued=N)
    EB-->>SR: replay frames 43..currentTail<br/>(from ring buffer)
    SR-->>C: NDJSON: id=43, type=session_update, …
    CH-->>BC: ACP notification (e.g. agent_message_chunk)
    BC->>EB: publish({type, data})
    EB-->>SR: enqueue id=N
    SR-->>C: id=N, type=…, data=…
    Note over EB,SR: If subscriber queue >= maxQueued,<br/>EventBus emits client_evicted terminal frame<br/>and closes subscriber.

The ring buffer is bounded (eventRingSize, default 8000). A reconnecting client whose Last-Event-ID is older than the ring's head receives state_resync_required and must rebuild from loadSession's bounded replay snapshot window or use resumeSession when it already has local history. Slow clients trigger slow_client_warning at 75% queue fill and client_evicted at the cap.

Workflow 3: Multi-client permission mediation

sequenceDiagram
    autonumber
    participant CH as ACP child (agent)
    participant BC as BridgeClient.requestPermission
    participant MED as Mediator (policy)
    participant EB as EventBus
    participant C1 as Client A<br/>(originator)
    participant C2 as Client B

    CH->>BC: ACP requestPermission(requestId, options)
    BC->>MED: request({requestId, sessionId, originatorClientId, allowedOptionIds}, timeoutMs)
    MED->>EB: publish permission_request<br/>(broadcast to subscribers)
    EB-->>C1: SSE permission_request
    EB-->>C2: SSE permission_request

    alt first-responder
        C2->>MED: POST /permission/:requestId optionId=allow
        MED-->>BC: resolved
        BC-->>CH: ACP response
        MED->>EB: permission_resolved
        C1->>MED: POST /permission/:requestId (late vote)
        MED-->>C1: 409 permission_already_resolved
    else designated
        C2->>MED: vote (clientId != originatorClientId)
        MED-->>C2: 403 permission_forbidden
        C1->>MED: vote (matches originator)
        MED-->>BC: resolved
    else consensus (N-of-M)
        C1->>MED: vote
        MED->>EB: permission_partial_vote (1/N)
        C2->>MED: vote
        MED->>EB: permission_partial_vote (2/N)
        Note over MED: when tally reaches quorum on one option, resolve
    else local-only
        C2->>MED: vote (remote)
        MED-->>C2: 403 permission_forbidden (remote_not_allowed)
        Note over MED,CH: blocks until a loopback voter resolves it
    end

Cross-policy escape hatch: any client may vote CANCEL_VOTE_SENTINEL to short-circuit the request as cancelled / agent_cancelled. The bridge guards against wire callers smuggling the sentinel via the normal optionId field (InvalidPermissionOptionError).

Workflow 4: MCP transport pool acquire / release / restart

sequenceDiagram
    autonumber
    participant S as Session in ACP child
    participant P as McpTransportPool
    participant SIF as spawnInFlight (dedup)
    participant E as PoolEntry
    participant BDG as WorkspaceMcpBudget
    participant SRV as MCP server

    S->>P: acquire(name, cfg, sessionId)
    P->>SIF: check inflight for (name+fingerprint)
    alt cached inflight
        SIF-->>P: existing promise
    else cold start
        P->>BDG: tryReserve(name)
        BDG-->>P: ok / refused
        alt refused
            P-->>S: BudgetExhaustedError
        else ok
            P->>E: new PoolEntry(...)
            E->>SRV: connect transport
            SRV-->>E: ready
            E-->>P: connected
        end
    end
    P->>P: sessionToEntries.add(sessionId, id)
    P-->>S: PooledConnection

    Note over S,P: Session uses entry, then…

    S->>P: release(id, sessionId)
    P->>E: detach session
    E->>E: arm drain timer (default 30s)
    Note over E: refs==0 → drain timer fires → close transport<br/>(MAX_IDLE_MS 5min hard cap survives attach/detach churn)

    Note over S,P: Operator restart flow…
    S->>P: restartByName(name, opts?)
    P->>E: drain + close
    P->>E: spawn replacement
    E->>SRV: reconnect
    P->>EB: publish mcp_server_restarted<br/>with stable entryIndex
    P-->>S: single result or {entries: RestartResult[]}

releaseSession(sessionId) uses the reverse sessionToEntries index to release every entry the session holds in O(refs). On daemon shutdown, drainAll() sets the draining flag (refusing new acquires) and waits for every entry to close under a configurable timeout.

Workflow 5: Lifecycle — startup and graceful shutdown

sequenceDiagram
    autonumber
    participant Op as Operator (signal)
    participant RQS as runQwenServe
    participant APP as Express app
    participant BR as AcpBridge
    participant CH as ACP child

    Op->>RQS: qwen serve --workspace … --token …
    RQS->>RQS: validate flags + canonicalize workspace
    RQS->>RQS: allocate PermissionAuditRing
    RQS->>BR: createHttpAcpBridge(options)
    RQS->>APP: createServeApp(bridge, …)
    RQS->>APP: listen(host, port)
    RQS->>RQS: arm SIGINT / SIGTERM handlers

    Op->>RQS: SIGTERM
    RQS->>BR: dispose device-flow registry
    RQS->>BR: bridge.shutdown()
    BR->>CH: send graceful close (10s deadline)
    CH-->>BR: exit
    RQS->>APP: server.close() (5s force-close timer)
    APP->>APP: closeAllConnections() (+2s secondary)
    Note over Op,RQS: Second SIGTERM during shutdown →<br/>bridge.killAllSync() + process.exit(1) (orphan prevention)

The two-phase shutdown matters because in-flight HTTP requests, in-flight SSE subscribers, and the ACP child's in-flight tool calls all need bounded teardown windows. If anything blocks past those deadlines, the force-close path takes over so a stuck child cannot keep the daemon process alive.

Critical files

Concern File
Bootstrap packages/cli/src/serve/run-qwen-serve.ts
Express app packages/cli/src/serve/server.ts
Capability registry packages/cli/src/serve/capabilities.ts
Auth middleware packages/cli/src/serve/auth.ts
Bridge packages/acp-bridge/src/bridge.ts
BridgeClient packages/acp-bridge/src/bridgeClient.ts
Permission mediator packages/acp-bridge/src/permissionMediator.ts
EventBus packages/acp-bridge/src/eventBus.ts
MCP transport pool packages/core/src/tools/mcp-transport-pool.ts
Workspace MCP budget packages/core/src/tools/mcp-workspace-budget.ts
Workspace FS packages/cli/src/serve/fs/
SDK DaemonClient packages/sdk-typescript/src/daemon/DaemonClient.ts
SDK SessionClient packages/sdk-typescript/src/daemon/DaemonSessionClient.ts
Event schema packages/sdk-typescript/src/daemon/events.ts

References