You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Stage 1's qwen serve (#3889) shipped a headless daemon with HTTP+SSE for remote clients; #4113 is consolidating the "1 daemon = 1 workspace" architecture. But today only Mode B (headless) exists: with a TUI process running, you cannot also run a daemon — so while a local user has the TUI open, mobile / IDE / IM bot / web clients cannot connect.
The design proposal at qwen-code-daemon-design §04 / §06 names this work Stage 1.5b — Mode A qwen --serve flag: attach an HttpServer inside a normal TUI process; the TUI acts as a super-client over the in-process EventBus, and remote clients connect via HTTP+SSE sharing the same daemon and same session pool.
⚠️Strongly recommended to start this issue after #4113 merges. APIs introduced by #4113 are dependencies: boundWorkspace is required, canonicalizeWorkspace and WorkspaceMismatchError are exported. Sub-task A0 (extract a workspace-validation helper) is itself a small refactor of #4113's code; sub-task A1 (extract the in-memory channel helper) has no #4113 dependency and can start immediately.
1 (additional remote sessions exist over HTTP but TUI cannot see them)
N/A
Crash isolation
⚠️ daemon exception kills TUI (same process)
✅ child isolated
MCP child count
TUI's set + daemon's per-session set (N×2 amplification)
daemon's per-session set only
Key technical finding
createServeApp(opts, deps) (packages/cli/src/serve/server.ts:53) already supports deps.bridge?: HttpAcpBridge injection, and HttpAcpBridge (packages/cli/src/serve/httpAcpBridge.ts:87) is a transport-agnostic interface. So Mode A does not need to change the routing / SSE / EventBus layers — it only needs a createInProcessAcpBridge(agent) implementation that wraps the in-process QwenAgent as a bridge to inject. SSE / Last-Event-ID / 15s heartbeat / ring replay all reuse #3889 directly.
⚠️Scope clarification: the design proposal's "TUI startup auto-POST /session, remote attaches to the same X" full semantics requires refactoring the TUI to flow through QwenAgent — that's a Phase D-class refactor (see Phase A detailed design §1 below). This issue's Phase A/B/C does NOT include it.
Three-phase plan overview (3 stacked PRs)
Each phase reviews and merges independently; B/C have no hard dependency between them. Phase A detailed design is in its own section below.
Phase A — Loopback-only minimal skeleton (~4.3 days, split into A0/A1/A2/A3 four stacked PRs)
qwen --serve [--workspace /path] [--serve-port N] brings up TUI + local daemon, with remote curl over loopback exercising the full prompt + SSE flow. Local debugging only — no auth, no graceful shutdown, TUI and remote sessions not shared, remote cannot call authenticate, daemon exception will kill TUI. Detailed design below.
Phase B — Remote bind + auth/CORS defaults (~1 day)
Goal: qwen --serve --serve-host 0.0.0.0 is reachable from LAN/container; bearer token required for non-loopback; loopback still no-token. Team / container alpha-quality.
Changes:
Add --serve-host flag (default 127.0.0.1) and --serve-token flag + QWEN_SERVER_TOKEN env fallback
Loopback detection via isLoopbackBind() (packages/cli/src/serve/loopbackBinds.ts); non-loopback without token → boot fails with same stderr message as Mode B's "Refusing to bind X without a bearer token"
Auto-generate token written to ~/.qwen/serve/token, TUI banner shows the path (matches Mode B existing behavior)
CORS / Host allowlist fully reuses packages/cli/src/serve/auth.ts three middlewares (bearerAuth / denyBrowserOriginCors / hostAllowlist); they are transparent to the in-process bridge
Disallow --serve-port 0 + non-loopback host combination (OS ephemeral port + remote = operator can't tell what they exposed)
qwen.tsx top-level single SIGINT/SIGTERM handler, ordered: ① EventBus pushes daemon_shutting_down to all SSE → ② server.close() rejects new connections, awaits in-flight → ③ bridge.shutdown() → ④ ink unmount → ⑤ process.exit(0)
/quit reuses the same shutdown() function — avoid two divergent exit paths
Coordinate with ink's default SIGINT handler: must remove/wrap ink's handler at TUI startup, otherwise ink unmounts first and HTTP drain doesn't run (only likely-trap point in this phase, leave 0.5d buffer)
E2E: a) TUI + remote SSE connected, TUI process SIGINT → remote sees close event + 200 response within 5s, TUI exit code 0; b) remote kill -9 SSE client → TUI still running, /help still responds
Phase C does NOT do: Daemon hot-reload (restart daemon without restarting TUI), Persistent SSE replay across restart (Stage 2 durable scope)
Three-phase characteristics
Property
Phase A
Phase B
Phase C
User-facing
Local debug only + remote independent sessions
Team / container alpha
Production stable
Shippable midstate?
⚠️ internal dogfooding
✅ alpha
✅ stable
Dependencies
Strongly recommended after #4113 (sub-task A1 can start independently)
qwen-code's TUI entry is more deeply coupled today; refactoring to "injectable transport" is Phase D's work
Core borrow: opencode proves "TUI + server in same process" is viable + 5s shutdown is a sensible constant. Key difference: opencode's TUI already abstracts data access behind fetch/events injection; qwen-code's TUI does not. So Phase A cannot simply copy "TUI as daemon client" — that's Phase D's work.
1. Honest scope statement ⚠️
Phase A actually achieves:
✅ qwen --serve brings up TUI with HttpServer attached
✅ Remote clients can curl /capabilities, POST /session to get an independent session in the same workspace, receive SSE events, send prompts
✅ TUI's own session continues normally without regression
Known limitations (differences from Mode B; must be documented for users):
Limitation
Cause
Impact
Mitigation
TUI and daemon sessions not shared
TUI does not flow through QwenAgent
Remote cannot see TUI's conversation; TUI cannot see remote's
Phase A daemon --max-sessions defaults to 5; long-term via chiga0 finding #3 (MCP per-daemon shared state)
Process-level state contention (OAuth refresh / FileReadCache / quota)
TUI/daemon share singletons
Concurrent token refresh may race
Phase A documents it; later add process-level Mutex
"TUI ↔ daemon session unification" is broken out as Phase D (not in this issue's scope).
2. File-level change list
#
File
Type
Description
0
extract from packages/cli/src/serve/runQwenServe.ts + export from packages/cli/src/serve/index.ts
modify ~50 lines (A0 sub-PR)
Extract a shared helper validateAndCanonicalizeWorkspace(rawPath: string): string: bundle runQwenServe.ts:121-160's path.isAbsolute / fs.statSync / isDirectory / ENOENT/EACCES/EPERM validation + canonicalizeWorkspace call; export for Mode A reuse. Rationale: #4113's server.ts docblock explicitly calls out "If a future entry point binds createServeApp directly to user input, it MUST replicate the runQwenServe validation (or call into a shared helper if one is extracted)" — Mode A is exactly that scenario; extracting the helper is the clean path
1
packages/cli/src/index.ts or yargs middleware layer
modify
Add top-level --serve (boolean) + --serve-port (number, default 0 — OS-assigned to avoid clashing with Mode B 4170) flags; mutex check against serve subcommand / --acp / -p. --workspace <path> flag reuses the same-named flag added by #4113 (do NOT introduce --serve-workspace)
2
packages/cli/src/serve/inMemoryChannel.ts
new ~80 lines
Extract the paired NDJSON channel pattern existing at httpAcpBridge.test.ts:151-154 as production code — createPairedChannel(): { clientStream, agentStream }, two TransformStream<Uint8Array, Uint8Array> pairs back-to-back + SDK's existing ndJsonStream (NOT PassThrough)
3
packages/cli/src/serve/inProcessAcpBridge.ts
new ~200 lines (an order of magnitude smaller than #4113's httpAcpBridge.ts ~2400 lines, because in-process has no child / spawn race / SIGTERM grace)
Implement HttpAcpBridge interface; inline a side-effect-free equivalent of runAcpAgent: ① create paired channel ② new QwenAgent(sharedConfig, sharedSettings, fabricatedArgv, agentSideConnection) ③ do NOT redirect console.log/info/debug ④ do NOT wrap process.stdout/stdin ⑤ do NOT register SIGINT/SIGTERM (left for Phase C) ⑥ do NOTrunExitCleanup + process.exit on stream end ⑦ at the authenticate request forwarding point, return ACP error directly: "remote authenticate disabled in Mode A" ⑧ wrap try/catch around sendPrompt / newSession calls to catch uncaughtException
4
packages/cli/src/gemini.tsx around line ~705
modify ~40 lines
In the if (config.isInteractive()) branch, before render(), if argv.serve: lazy importawait import('./serve/inProcessDaemon.js') → call A0's extracted validateAndCanonicalizeWorkspace(argv.workspace ?? process.cwd()) to obtain boundWorkspace → start daemon → listen URL goes to writeStderrLine (not writeStdoutLine, to avoid polluting ink's stdout)
5
packages/cli/src/serve/inProcessAcpBridge.test.ts
new ~450 lines
Interface contract tests: all HttpAcpBridge methods aligned with httpAcpBridge.test.ts; reuse #4113's makeBridge() helper pattern (default boundWorkspace: WS_A, override explicitly when needed); add 5 cases: remote authenticate rejected, remote prompt-throw doesn't kill process, port conflict exit 1, lazy import verification, omitting cwd falls back to boundWorkspace
6
packages/cli/src/serve/serveFlag.test.ts
new ~200 lines
E2E: spawn qwen --serve --serve-port 0 subprocess, parse listen port from stderr, curl /capabilities and POST /session, verify --serve + serve / --acp / -p three mutex combinations all exit 1
7
docs/users/qwen-serve.md
modify ~50 lines (add chapter to existing file, not new)
In the file already updated by #4113, add "Mode A — qwen --serve" chapter + Mode A vs Mode B selection table, document §1's 5 known limitations. Do NOT modifydocs/developers/qwen-serve-protocol.md (HTTP protocol, ACP wire, workspace_mismatch body shape are identical for Mode A/B). docs/developers/examples/daemon-client-quickstart.md only needs a top-of-file note: "examples apply to both Mode A and Mode B; daemon startup differs but client integration is identical"
3. Key technical decisions
Decision 1: in-process bridge uses paired channel + full ACP
Dimension
paired channel + ACP (chosen)
direct method calls
server.ts / eventBus.ts changes
0
large
ACP protocol evolution
auto-follow
dual maintenance
Implementation complexity
medium (~200 + 80 lines)
low (~150 lines)
Debuggability
good (ACP frames dumpable)
average
The TransformStream pair + ndJsonStream pattern at httpAcpBridge.test.ts:151-154already exists; just extract to inMemoryChannel.ts.
Simplification advantage (vs #4113's httpAcpBridge.ts): in-process has no child process / spawn race / child crash race / SIGTERM grace window, so it does not need ChannelInfo.isDying state, aliveChannels set, killAllSync path, or tanzhenxin's BkUyD double-SIGINT invariant. Estimated in-process bridge ~180-220 lines, an order of magnitude smaller than #4113's httpAcpBridge.ts (~2400 lines).
See §1. The daemon runs an independent QwenAgent in the same process; TUI session and daemon session are mutually invisible.
Decision 3: when to listen + boot failure + log direction
Position: gemini.tsx, after initializeApp(config, settings) (ensures settings/auth are ready), before render(<App ...>)
Failure handling: port conflict / bind error / workspace validation failure → TUI does not start, writeStderrLine prints error, process.exit(1)
Listen URL printing: only via writeStderrLine (stdout is occupied by ink; writing to stdout would pollute TUI rendering)
Default port: 0 (OS-assigned), to avoid clashing with Mode B's default 4170; users must check stderr for the actual port
Decision 4: EventBus instance sharing
createServeApp instantiates an EventBus internally for SSE. Phase A: in-process QwenAgent flows via paired channel → ACP sessionUpdate notification → bridge forwards to EventBus → SSE fan-out. Does not do "TUI directly subscribes to EventBus" — that's Phase D.
Decision 5: --serve mutex with existing flags
Combination
Behavior
qwen --serve
Mode A: TUI + daemon
qwen --serve --workspace /path
Mode A, daemon binds to /path instead of cwd (legal, reuses #4113's --workspace semantics)
qwen --serve --continue / --resume X / --prompt-interactive "..." / --model X
Legal (still interactive TUI)
qwen serve
Mode B (existing, mutex)
qwen --serve serve
Boot fails
qwen --acp --serve
Boot fails (ACP uses stdio, mutex with daemon)
qwen -p "hello" --serve
Boot fails (headless prompt mode mutex with interactive daemon)
Boot fails (gemini.tsx:738 already errors for interactive mode, implicit mutex)
qwen --serve on non-TTY (nohup ... &)
Boot fails + hint "use qwen serve for headless"
Validation lives near gemini.tsx:417, alongside --bare / --prompt-interactive mutex checks.
Decision 6: Phase A forces loopback
--serve-host is not exposed in Phase A; hard-coded 127.0.0.1. Remote bind must come with token — that's Phase B's work.
Decision 7: boundWorkspace is boot-time snapshot + canonical
Mode A's boundWorkspace = validateAndCanonicalizeWorkspace(argv.workspace ?? process.cwd()) at --serve boot time, never changes. Even if TUI calls process.chdir() during runtime (triggered by some commands), the daemon still serves the original workspace. This matches #4113's 1-daemon-1-workspace semantics.
Critical: canonicalizeWorkspace is the idempotent helper already exported from httpAcpBridge.ts; handles symlinks + case-insensitive FS. Phase A must pre-canonicalize — otherwise /capabilities.workspaceCwd may drift from the bridge's internal canonical form (although #4113 has its own re-canonicalize fallback, aligning with runQwenServe's pattern is cleaner).
Empirical: grepping the entire codebase for process.chdir() in ui/commands/, services/, acp-integration/ — zero calls, so snapshot vs dynamic is equivalent today. But future sub-agent tools (EnterWorktree PR #4073) involve directory switching; dynamic mode would silently change boundWorkspace, causing all connected clients to receive workspace_mismatch without notification — a sliding error. Snapshot is strictly superior to dynamic.
The in-process bridge intercepts the authenticate method at the ACP request routing layer and returns ACP error directly: { code: -32601, message: "remote authenticate disabled in Mode A; use TUI /auth instead" }. Rationale per §1: QwenAgent.authenticate() clears TUI credentials.
The daemon's QwenAgent uses the TUI's already-constructedconfig + settings instances (avoiding settings drift), but argvis freshly built as a clean CliArgs (containing only daemon-relevant fields like cwd, mode), to avoid TUI's flags like --prompt-interactive poisoning daemon behavior.
InProcessBridgeOptions TS signature (aligned with #4113's BridgeOptions):
interfaceInProcessBridgeOptions{boundWorkspace: string;// required (matches #4113 BridgeOptions.boundWorkspace)sharedConfig: Config;// requiredsharedSettings: LoadedSettings;// requireddaemonArgv: CliArgs;// required (fabricated)maxSessions?: number;// optional, defaults to 5 (per D2 decision)// No maxConnections needed (in-process has no listener layer)// No sessionScope override needed (sticks with single default)}exportfunctioncreateInProcessAcpBridge(opts: InProcessBridgeOptions): HttpAcpBridge;// Note: matches #4113's createHttpAcpBridge(opts: BridgeOptions), opts has no default
fabricateDaemonArgv(orig: CliArgs) field disposition (keep / drop):
NDJSON boundaries, backpressure, close propagation
inProcessAcpBridge 8-method contract
inProcessAcpBridge.test.ts
Same assertions as httpAcpBridge.test.ts (spawnOrAttachsendPromptcancelSessionsubscribeEventsrespondToPermissionlistWorkspaceSessionssetSessionModelkillSession), without spawning child; reuse #4113's makeBridge() helper pattern
Omitting cwd still creates session
inProcessAcpBridge.test.ts
POST /session body without cwd → gets sessionId, response workspaceCwd = boundWorkspace (verifies #4113-introduced fallback works for in-process bridge too)
TUI mode existing startup smoke test still passes with --serve enabled
--serve mutex validation
serveFlag.test.ts
--serve serve / --serve --acp / --serve -p / --serve --bare four combinations all exit 1
Port already in use → exit 1 + error message includes port number
serveFlag.test.ts
After occupying a port, --serve --serve-port <that port> → stderr contains port number + exit 1
Remote creates independent session
serveFlag.test.ts
Remote POST /session gets sessionId Y, GET /session/Y/events SSE works; TUI's session sessionId X does not appear in daemon's listWorkspaceSessions view (validates §1 limitation)
6. Acceptance criteria
qwen --serve starts TUI; /help etc. work normally inside TUI
Same machine curl http://127.0.0.1:N/capabilities returns workspaceCwd = TUI's cwd (N read from stderr)
Same machine curl -X POST http://127.0.0.1:N/session -d '{}' returns sessionId (omitting cwd takes fallback)
Same machine curl -X POST http://127.0.0.1:N/session -d '{"cwd":"/wrong"}' returns 400 + code: workspace_mismatch
Telemetry root span merge with TUI (currently each independent root)
Phase D
mDNS discovery (opencode has it, useful for LAN deployment)
Stage 2c (in roadmap)
Port discovery file ~/.qwen/serve/instances/<pid>.json
Phase B
8. PR split recommendation
A 4.3-day diff in a single PR is too much review burden; split into 4 stacked PRs:
Sub-PR
Estimate
Content
Dependencies
A0
~0.3d
Extract validateAndCanonicalizeWorkspace shared helper, refactor runQwenServe.ts:121-160 to call it (this is a small refactor PR over #4113's code, does not introduce Mode A by itself)
§1 known limitations documentation + uncaughtException wrapping: ~0.5d
A0 helper extraction: ~0.3d
ROI is positive: in exchange for zero invasion of server layer + ACP protocol auto-follow + no security/resource amplification problems + alignment with #4113 workflow.
Routing decision point ⚠️
#3929 / #3930 / #3931 (chiga0) are an alternative route that overlaps in functionality but uses a different protocol: qwen remote-control + qwen --remote-control, WebSocket + stream-json. Both routes do "TUI as super-client + mobile/browser thin client". Before implementing 1.5b, design alignment is needed:
Phase D — TUI ↔ daemon session unification (make TUI a real daemon client pre-release: fix ci #1, requires refactoring TUI to flow through QwenAgent; also unlocks remote authenticate / process-level credential mediator / telemetry span merge) — separate issue
Phase E — Stage 2e in-process reverse refactor (process-level quarantine so daemon exceptions don't kill TUI) — separate issue
Loopback 检测调 isLoopbackBind() (packages/cli/src/serve/loopbackBinds.ts);非 loopback 且无 token → 启动失败,stderr 打印同 Mode B 的 "Refusing to bind X without a bearer token"
自动生成 token 写盘 ~/.qwen/serve/token,TUI banner 显示路径(对齐 Mode B 现有行为)
抽出共享 helper validateAndCanonicalizeWorkspace(rawPath: string): string:把 runQwenServe.ts:121-160 的 path.isAbsolute / fs.statSync / isDirectory / ENOENT/EACCES/EPERM 校验 + canonicalizeWorkspace 调用打包;导出供 Mode A 复用。理由:#4113 在 server.ts docblock 明确点名 "If a future entry point binds createServeApp directly to user input, it MUST replicate the runQwenServe validation (or call into a shared helper if one is extracted)" —— Mode A 正是此场景,抽 helper 是干净路径
1
packages/cli/src/index.ts 或 yargs middleware 层
改
顶层加 --serve (boolean) + --serve-port (number, default 0 —— OS 分配避免与 Mode B 4170 冲突) flag;与 serve 子命令 / --acp / -p 互斥校验。--workspace <path> flag 复用 #4113 已加的同名 flag(不要 --serve-workspace)
Background
Stage 1's
qwen serve(#3889) shipped a headless daemon with HTTP+SSE for remote clients; #4113 is consolidating the "1 daemon = 1 workspace" architecture. But today only Mode B (headless) exists: with a TUI process running, you cannot also run a daemon — so while a local user has the TUI open, mobile / IDE / IM bot / web clients cannot connect.The design proposal at
qwen-code-daemon-design§04 / §06 names this work Stage 1.5b — Mode Aqwen --serveflag: attach an HttpServer inside a normal TUI process; the TUI acts as a super-client over the in-process EventBus, and remote clients connect via HTTP+SSE sharing the same daemon and same session pool.Architecture comparison
qwen --serve) — this issueqwen serve) — already shippedQwenAgent(same process)qwen --acpchild/quit→ drain HTTP → exit daemonKey technical finding
createServeApp(opts, deps)(packages/cli/src/serve/server.ts:53) already supportsdeps.bridge?: HttpAcpBridgeinjection, andHttpAcpBridge(packages/cli/src/serve/httpAcpBridge.ts:87) is a transport-agnostic interface. So Mode A does not need to change the routing / SSE / EventBus layers — it only needs acreateInProcessAcpBridge(agent)implementation that wraps the in-processQwenAgentas a bridge to inject. SSE / Last-Event-ID / 15s heartbeat / ring replay all reuse #3889 directly.POST /session, remote attaches to the same X" full semantics requires refactoring the TUI to flow throughQwenAgent— that's a Phase D-class refactor (see Phase A detailed design §1 below). This issue's Phase A/B/C does NOT include it.Three-phase plan overview (3 stacked PRs)
Each phase reviews and merges independently; B/C have no hard dependency between them. Phase A detailed design is in its own section below.
Phase A — Loopback-only minimal skeleton (~4.3 days, split into A0/A1/A2/A3 four stacked PRs)
qwen --serve [--workspace /path] [--serve-port N]brings up TUI + local daemon, with remotecurlover loopback exercising the full prompt + SSE flow. Local debugging only — no auth, no graceful shutdown, TUI and remote sessions not shared, remote cannot call authenticate, daemon exception will kill TUI. Detailed design below.Phase B — Remote bind + auth/CORS defaults (~1 day)
Goal:
qwen --serve --serve-host 0.0.0.0is reachable from LAN/container; bearer token required for non-loopback; loopback still no-token. Team / container alpha-quality.Changes:
--serve-hostflag (default127.0.0.1) and--serve-tokenflag +QWEN_SERVER_TOKENenv fallbackpackages/cli/src/serve/runQwenServe.ts:60-75token trim+env logicisLoopbackBind()(packages/cli/src/serve/loopbackBinds.ts); non-loopback without token → boot fails with same stderr message as Mode B's "Refusing to bind X without a bearer token"~/.qwen/serve/token, TUI banner shows the path (matches Mode B existing behavior)packages/cli/src/serve/auth.tsthree middlewares (bearerAuth/denyBrowserOriginCors/hostAllowlist); they are transparent to the in-process bridge--serve-port 0 + non-loopback hostcombination (OS ephemeral port + remote = operator can't tell what they exposed)Phase B does NOT do: Mutual TLS / client certificates (Stage 2), token revocation API (chiga0 must-have #3)
Phase C — Lifecycle coordination (~1 day)
Goal: TUI exit (Ctrl+C /
/quit/ exception) drains HTTP first, then unmounts ink, then exits process; remote clients see clean close not TCP RST; remote disconnect doesn't affect TUI. Production-ready.Changes:
{ shutdown(): Promise<void> }wrappingbridge.shutdown()+server.close()+ 5s force-close (reusesrunQwenServe.ts:15SHUTDOWN_FORCE_CLOSE_MS)qwen.tsxtop-level single SIGINT/SIGTERM handler, ordered: ① EventBus pushesdaemon_shutting_downto all SSE → ②server.close()rejects new connections, awaits in-flight → ③bridge.shutdown()→ ④ ink unmount → ⑤process.exit(0)/quitreuses the sameshutdown()function — avoid two divergent exit pathsexit(130)kill -9SSE client → TUI still running,/helpstill respondsPhase C does NOT do: Daemon hot-reload (restart daemon without restarting TUI), Persistent SSE replay across restart (Stage 2 durable scope)
Three-phase characteristics
runAcpAgentside-effect isolation, authenticate security gate, MCP resource amplification, ACP paired-channel wiringPhase A detailed design
0. opencode reference and trade-offs
opencode's
tuicommand (packages/opencode/src/cli/cmd/tui/thread.ts) implements TUI + server in one process:Rpc.clientprocess.argv.includes('--port')to distinguish internal vs external--serveis passed + lazy importcreateWorkerFetch/createEventSourcewrap RPC as fetchwithTimeout(client.call("shutdown"), 5000)5s forcerunQwenServe.ts:15SHUTDOWN_FORCE_CLOSE_MS = 5_000tui()entry acceptsurl+fetch+eventsinjectionCore borrow: opencode proves "TUI + server in same process" is viable + 5s shutdown is a sensible constant. Key difference: opencode's TUI already abstracts data access behind
fetch/eventsinjection; qwen-code's TUI does not. So Phase A cannot simply copy "TUI as daemon client" — that's Phase D's work.1. Honest scope statement⚠️
Phase A actually achieves:
qwen --servebrings up TUI with HttpServer attachedcurl /capabilities,POST /sessionto get an independent session in the same workspace, receive SSE events, send promptsKnown limitations (differences from Mode B; must be documented for users):
QwenAgentauthenticaterequest rejected (returns ACP error)QwenAgent.authenticate()callsclearCachedCredentialFile+refreshAuth→ directly clears TUI's credentialsacpAgent.ts:618newSessionConfigbuilds a newConfigper session--max-sessionsdefaults to 5; long-term via chiga0 finding #3 (MCP per-daemon shared state)"TUI ↔ daemon session unification" is broken out as Phase D (not in this issue's scope).
2. File-level change list
packages/cli/src/serve/runQwenServe.ts+ export frompackages/cli/src/serve/index.tsvalidateAndCanonicalizeWorkspace(rawPath: string): string: bundlerunQwenServe.ts:121-160'spath.isAbsolute / fs.statSync / isDirectory / ENOENT/EACCES/EPERMvalidation +canonicalizeWorkspacecall; export for Mode A reuse. Rationale: #4113'sserver.tsdocblock explicitly calls out "If a future entry point bindscreateServeAppdirectly to user input, it MUST replicate therunQwenServevalidation (or call into a shared helper if one is extracted)" — Mode A is exactly that scenario; extracting the helper is the clean pathpackages/cli/src/index.tsor yargs middleware layer--serve(boolean) +--serve-port(number, default 0 — OS-assigned to avoid clashing with Mode B 4170) flags; mutex check againstservesubcommand /--acp/-p.--workspace <path>flag reuses the same-named flag added by #4113 (do NOT introduce--serve-workspace)packages/cli/src/serve/inMemoryChannel.tshttpAcpBridge.test.ts:151-154as production code —createPairedChannel(): { clientStream, agentStream }, twoTransformStream<Uint8Array, Uint8Array>pairs back-to-back + SDK's existingndJsonStream(NOT PassThrough)packages/cli/src/serve/inProcessAcpBridge.tshttpAcpBridge.ts~2400 lines, because in-process has no child / spawn race / SIGTERM grace)HttpAcpBridgeinterface; inline a side-effect-free equivalent ofrunAcpAgent: ① create paired channel ②new QwenAgent(sharedConfig, sharedSettings, fabricatedArgv, agentSideConnection)③ do NOT redirectconsole.log/info/debug④ do NOT wrapprocess.stdout/stdin⑤ do NOT register SIGINT/SIGTERM (left for Phase C) ⑥ do NOTrunExitCleanup+process.exiton stream end ⑦ at theauthenticaterequest forwarding point, return ACP error directly: "remote authenticate disabled in Mode A" ⑧ wrap try/catch aroundsendPrompt/newSessioncalls to catch uncaughtExceptionpackages/cli/src/gemini.tsxaround line ~705if (config.isInteractive())branch, beforerender(), ifargv.serve: lazy importawait import('./serve/inProcessDaemon.js')→ call A0's extractedvalidateAndCanonicalizeWorkspace(argv.workspace ?? process.cwd())to obtain boundWorkspace → start daemon → listen URL goes towriteStderrLine(notwriteStdoutLine, to avoid polluting ink's stdout)packages/cli/src/serve/inProcessAcpBridge.test.tsHttpAcpBridgemethods aligned withhttpAcpBridge.test.ts; reuse #4113'smakeBridge()helper pattern (defaultboundWorkspace: WS_A, override explicitly when needed); add 5 cases: remoteauthenticaterejected, remote prompt-throw doesn't kill process, port conflict exit 1, lazy import verification, omittingcwdfalls back toboundWorkspacepackages/cli/src/serve/serveFlag.test.tsqwen --serve --serve-port 0subprocess, parse listen port from stderr, curl/capabilitiesandPOST /session, verify--serve+serve/--acp/-pthree mutex combinations all exit 1docs/users/qwen-serve.mdqwen --serve" chapter + Mode A vs Mode B selection table, document §1's 5 known limitations. Do NOT modifydocs/developers/qwen-serve-protocol.md(HTTP protocol, ACP wire,workspace_mismatchbody shape are identical for Mode A/B).docs/developers/examples/daemon-client-quickstart.mdonly needs a top-of-file note: "examples apply to both Mode A and Mode B; daemon startup differs but client integration is identical"3. Key technical decisions
Decision 1: in-process bridge uses paired channel + full ACP
server.ts/eventBus.tschangesThe
TransformStreampair +ndJsonStreampattern athttpAcpBridge.test.ts:151-154already exists; just extract toinMemoryChannel.ts.Simplification advantage (vs #4113's
httpAcpBridge.ts): in-process has no child process / spawn race / child crash race / SIGTERM grace window, so it does not needChannelInfo.isDyingstate,aliveChannelsset,killAllSyncpath, or tanzhenxin's BkUyD double-SIGINT invariant. Estimated in-process bridge ~180-220 lines, an order of magnitude smaller than #4113'shttpAcpBridge.ts(~2400 lines).Decision 2: TUI ↔ daemon session relationship — decoupled
See §1. The daemon runs an independent
QwenAgentin the same process; TUI session and daemon session are mutually invisible.Decision 3: when to listen + boot failure + log direction
gemini.tsx, afterinitializeApp(config, settings)(ensures settings/auth are ready), beforerender(<App ...>)writeStderrLineprints error,process.exit(1)writeStderrLine(stdout is occupied by ink; writing to stdout would pollute TUI rendering)Decision 4: EventBus instance sharing
createServeAppinstantiates an EventBus internally for SSE. Phase A: in-processQwenAgentflows via paired channel → ACPsessionUpdatenotification → bridge forwards to EventBus → SSE fan-out. Does not do "TUI directly subscribes to EventBus" — that's Phase D.Decision 5:
--servemutex with existing flagsqwen --serveqwen --serve --workspace /path/pathinstead of cwd (legal, reuses #4113's--workspacesemantics)qwen --serve --continue/--resume X/--prompt-interactive "..."/--model Xqwen serveqwen --serve serveqwen --acp --serveqwen -p "hello" --serveqwen --bare --serveqwen --input-format stream-json --serveqwen --json-schema "..." --servegemini.tsx:738already errors for interactive mode, implicit mutex)qwen --serveon non-TTY (nohup ... &)qwen servefor headless"Validation lives near
gemini.tsx:417, alongside--bare/--prompt-interactivemutex checks.Decision 6: Phase A forces loopback
--serve-hostis not exposed in Phase A; hard-coded127.0.0.1. Remote bind must come with token — that's Phase B's work.Decision 7:
boundWorkspaceis boot-time snapshot + canonicalMode A's
boundWorkspace=validateAndCanonicalizeWorkspace(argv.workspace ?? process.cwd())at--serveboot time, never changes. Even if TUI callsprocess.chdir()during runtime (triggered by some commands), the daemon still serves the original workspace. This matches #4113's 1-daemon-1-workspace semantics.Critical:
canonicalizeWorkspaceis the idempotent helper already exported fromhttpAcpBridge.ts; handles symlinks + case-insensitive FS. Phase A must pre-canonicalize — otherwise/capabilities.workspaceCwdmay drift from the bridge's internal canonical form (although #4113 has its own re-canonicalize fallback, aligning withrunQwenServe's pattern is cleaner).Empirical: grepping the entire codebase for
process.chdir()inui/commands/,services/,acp-integration/— zero calls, so snapshot vs dynamic is equivalent today. But future sub-agent tools (EnterWorktreePR #4073) involve directory switching; dynamic mode would silently change boundWorkspace, causing all connected clients to receiveworkspace_mismatchwithout notification — a sliding error. Snapshot is strictly superior to dynamic.Decision 8: reject remote
authenticaterequest forwardingThe in-process bridge intercepts the
authenticatemethod at the ACP request routing layer and returns ACP error directly:{ code: -32601, message: "remote authenticate disabled in Mode A; use TUI /auth instead" }. Rationale per §1:QwenAgent.authenticate()clears TUI credentials.Decision 9: share
config/settings, fabricateargvThe daemon's
QwenAgentuses the TUI's already-constructedconfig+settingsinstances (avoiding settings drift), butargvis freshly built as a cleanCliArgs(containing only daemon-relevant fields like cwd, mode), to avoid TUI's flags like--prompt-interactivepoisoning daemon behavior.InProcessBridgeOptionsTS signature (aligned with #4113'sBridgeOptions):fabricateDaemonArgv(orig: CliArgs)field disposition (keep / drop):model,yolo,approvalMode,extensions,includeDirectories,mcpConfig,allowedMcpServerNames,telemetry*,openaiApiKey,openaiBaseUrl,proxy,authType,coreTools,excludeTools,disabledSlashCommands,allowedTools,maxSessionTurns,chatRecording,checkpointing,debug,screenReader,sandbox,sandboxImage,channelprompt,promptInteractive,query,bare,inputFormat,outputFormat,inputFile,jsonFd,jsonFile,jsonSchema,includePartialMessages,acp,experimentalAcp,experimentalLsp,openaiLogging,openaiLoggingDir,listExtensions,continue,resume,sessionIdDecision 10: lazy import
gemini.tsx's daemon startup logic is wrapped withawait import('./serve/inProcessDaemon.js'). No--serve→ no ESM cold-start cost (~50ms, matches Mode B'scommands/serve.ts:106lazy pattern).4. Data flow diagram
5. Test matrix
inMemoryChannel.test.tsinProcessAcpBridge8-method contractinProcessAcpBridge.test.tshttpAcpBridge.test.ts(spawnOrAttachsendPromptcancelSessionsubscribeEventsrespondToPermissionlistWorkspaceSessionssetSessionModelkillSession), without spawning child; reuse #4113'smakeBridge()helper patterncwdstill creates sessioninProcessAcpBridge.test.tsPOST /sessionbody withoutcwd→ gets sessionId, responseworkspaceCwd=boundWorkspace(verifies #4113-introduced fallback works for in-process bridge too)authenticaterejected (decision 8 + §1)inProcessAcpBridge.test.tsauthenticaterequest → receives method-disabled error; TUI's current OAuth credentials file mtime unchangedinProcessAcpBridge.test.tsQwenAgent.newSessionto throwError('boom')→ bridge converts to ACP error returned to client, process still aliveinProcessAcpBridge.test.ts--servestartup →require.cachedoes not includeinProcessAcpBridge.jsqwen --servestartupserveFlag.test.tsqwen --serve --serve-port 0, parses listen port from stderr, curl/capabilities200--workspaceflag wiringserveFlag.test.tsqwen --serve --workspace /tmp/x→/capabilities.workspaceCwd === '/tmp/x'--workspaceboot validationserveFlag.test.ts--workspace /no/such/path/ relative path / file-not-directory → exit 1 + friendly error (validates A0 helper invocation)serveFlag.test.ts--serveenabled--servemutex validationserveFlag.test.ts--serve serve/--serve --acp/--serve -p/--serve --barefour combinations all exit 1serveFlag.test.ts--serve --serve-port <that port>→ stderr contains port number + exit 1serveFlag.test.tsPOST /sessiongets sessionId Y,GET /session/Y/eventsSSE works; TUI's session sessionId X does not appear in daemon'slistWorkspaceSessionsview (validates §1 limitation)6. Acceptance criteria
qwen --servestarts TUI;/helpetc. work normally inside TUIcurl http://127.0.0.1:N/capabilitiesreturnsworkspaceCwd= TUI's cwd (N read from stderr)curl -X POST http://127.0.0.1:N/session -d '{}'returns sessionId (omitting cwd takes fallback)curl -X POST http://127.0.0.1:N/session -d '{"cwd":"/wrong"}'returns 400 +code: workspace_mismatchcurl -N http://127.0.0.1:N/session/{id}/eventsstreams ACP eventsPOST /session/{id}/prompttriggers agent work and SSE returns streaming tokensauthenticaterequest → receives method-disabled error, TUI credentials untouchedqwen --serve --workspace /no/such/path→ exit 1 + friendly error--servestartup → ESM load graph does NOT include serve modulevitestall green, new tests pass7. Phase A explicit non-goals
/quitcoordination / double-Ctrl+C force exit--servemodeprocess.exitpath cleanup serve (avoid listener leak)~/.qwen/serve/instances/<pid>.json8. PR split recommendation
A 4.3-day diff in a single PR is too much review burden; split into 4 stacked PRs:
validateAndCanonicalizeWorkspaceshared helper, refactorrunQwenServe.ts:121-160to call it (this is a small refactor PR over #4113's code, does not introduce Mode A by itself)inMemoryChannel.ts+ unit tests (pure refactor ofhttpAcpBridge.test.ts:151-154's existing pattern, zero behavior change)inProcessAcpBridge.ts+ contract tests (incl. §3 decision 8 authenticate rejection, §3 decision 9 fabricate argv, §3 decision 10 lazy import prep; uses A0 helper,canonicalizeWorkspace,WorkspaceMismatchError,makeBridge()pattern)--serveflag +--workspacewiring +gemini.tsxintegration + mutex validation + e2e + docsA1 can start now (no #4113 dependency); A0/A2/A3 wait for #4113 merge before stacking, to avoid rebases.
Phase A workload estimate (revised)
validateAndCanonicalizeWorkspacehelperinMemoryChannel.ts+ testsinProcessAcpBridge.ts(incl. inline side-effect-freerunAcpAgent+ authenticate rejection + uncaughtException wrapping + cwd fallback)--serve+--workspaceflag +gemini.tsxlazy import + stderr printing + listen error handling + e2e + docsMain added cost is:
ROI is positive: in exchange for zero invasion of server layer + ACP protocol auto-follow + no security/resource amplification problems + alignment with #4113 workflow.
Routing decision point⚠️
#3929/#3930/#3931(chiga0) are an alternative route that overlaps in functionality but uses a different protocol:qwen remote-control+qwen --remote-control, WebSocket + stream-json. Both routes do "TUI as super-client + mobile/browser thin client". Before implementing 1.5b, design alignment is needed:AcpChannel/EventBus), then have both routes connect to the same rootOut of scope for this issue
QwenAgent; also unlocks remote authenticate / process-level credential mediator / telemetry span merge) — separate issuesessionScopeoverride,loadSessionHTTP, heartbeat, token revocation, etc.) — separate issueReferences
Daemon series upstream (@wenshao-led)
qwen serve(✅ merged)canonicalizeWorkspace/WorkspaceMismatchError/BridgeOptions.boundWorkspace, etc.)Design docs
qwen-code-daemon-design§04 / §06 (drafted by @wenshao)Routing decision point
External reference
packages/opencode/src/cli/cmd/tui/thread.ts(Worker + RPC fetch)📖 简体中文版本(点击展开)
背景
Stage 1 的
qwen serve(#3889) 提交了 headless daemon,远端 client 走 HTTP+SSE 接入;#4113 在收口"1 daemon = 1 workspace"的架构。但目前只有 Mode B(headless):在带 TUI 的进程里没法同时跑 daemon,本地用户开着 TUI 时,手机 / IDE / IM bot / web 接不进来。设计提案
qwen-code-daemon-design§04 / §06 把这一块定义为 Stage 1.5b — Mode Aqwen --serveflag:在普通 TUI 进程里附挂一个 HttpServer,TUI 当 super-client 走 in-process EventBus,远端 client 通过 HTTP+SSE 共享同一 daemon、同一 session 集合。架构区分
qwen --serve) — 本 issueqwen serve) — 已实现QwenAgent(同进程)qwen --acpchild/quit→ drain HTTP → 退 daemon关键技术发现
createServeApp(opts, deps)(packages/cli/src/serve/server.ts:53) 已经支持deps.bridge?: HttpAcpBridge注入,且HttpAcpBridge(packages/cli/src/serve/httpAcpBridge.ts:87) 是 transport-agnostic 的接口。所以 Mode A 不需要改路由 / SSE / EventBus 层,只需提供一个createInProcessAcpBridge(agent)实现,把当前进程的QwenAgent包装成 bridge 注入即可。SSE / Last-Event-ID / 15s heartbeat / ring replay 全部从 #3889 直接复用。POST /session,远端 attach 同一个 X" 的完整语义需要 TUI 改造成走QwenAgent路径,这是 Phase D 级的重构(详见下方 Phase A 详细设计 §1)。本 issue 的 Phase A/B/C 不包含这一项。三阶段方案概览(拆 3 个独立 PR)
每个 phase 都可独立 review、独立合入;B/C 之间无硬依赖。Phase A 详细设计见下方独立章节。
Phase A — Loopback-only 最小骨架 (~4.3 天,拆 A0/A1/A2/A3 四个 stacked PR)
qwen --serve [--workspace /path] [--serve-port N]起 TUI + 本机 daemon,远端curl在 loopback 下能跑通完整 prompt + SSE 流。仅本机调试可用,无 auth、无优雅退出、TUI 与远端 session 不共享、远端不能调 authenticate、daemon 异常会带崩 TUI。详细设计见下方。Phase B — 远端绑定 + auth/CORS 默认值区分 (~1 天)
目标:
qwen --serve --serve-host 0.0.0.0能从局域网/容器外访问,强制 bearer token;loopback 仍免 token。团队/容器内 alpha 可用。改动:
--serve-hostflag(default127.0.0.1)和--serve-tokenflag +QWEN_SERVER_TOKENenv 兜底packages/cli/src/serve/runQwenServe.ts:60-75的 token trim+env 解析逻辑isLoopbackBind()(packages/cli/src/serve/loopbackBinds.ts);非 loopback 且无 token → 启动失败,stderr 打印同 Mode B 的 "Refusing to bind X without a bearer token"~/.qwen/serve/token,TUI banner 显示路径(对齐 Mode B 现有行为)packages/cli/src/serve/auth.ts三个 middleware(bearerAuth/denyBrowserOriginCors/hostAllowlist),它们对 in-process bridge 透明--serve-port 0 + 非 loopback host组合(OS 临时端口 + 远端 = 操作员搞不清自己暴露在哪)Phase B 不做:Mutual TLS / 客户端证书(Stage 2)、token revocation API(chiga0 must-have #3)
Phase C — 生命周期协同 (~1 天)
目标:TUI 退出(Ctrl+C /
/quit/ 异常)时先 drain HTTP,再让 ink 卸载、最后退进程;远端 client 在 TUI 退出时收到 clean close 而非 TCP RST;远端断连不影响 TUI。生产可用。改动:
{ shutdown(): Promise<void> }包装bridge.shutdown()+server.close()+ 5s 强制 close(复用runQwenServe.ts:15的SHUTDOWN_FORCE_CLOSE_MS)qwen.tsx顶层 SIGINT/SIGTERM 单点处理,顺序:① EventBus 推daemon_shutting_down给所有 SSE → ②server.close()拒新连接、等 in-flight → ③bridge.shutdown()→ ④ ink unmount → ⑤process.exit(0)/quit复用同一shutdown()函数,避免两条退出路径分叉exit(130)kill -9SSE 客户端 → TUI 仍在跑,/help仍响应Phase C 不做:Daemon hot-reload(重启 daemon 不重启 TUI)、Persistent SSE 跨重启重放(Stage 2 durable 范畴)
三个 phase 的关键性质
runAcpAgent副作用隔离、authenticate 安全门、MCP 资源放大、ACP paired-channel 接线Phase A 详细设计
0. 参考 opencode 的取舍
opencode 的
tui命令(packages/opencode/src/cli/cmd/tui/thread.ts)实现 TUI + 服务端共进程:Rpc.client通信process.argv.includes('--port')区分 internal vs external--serve才 listen + lazy importcreateWorkerFetch/createEventSource把 RPC 包装成 fetchwithTimeout(client.call("shutdown"), 5000)5s 强制runQwenServe.ts:15SHUTDOWN_FORCE_CLOSE_MS = 5_000一致tui()入口接受url+fetch+events三个注入参数核心借鉴:opencode 证明"TUI + 服务端共进程"可行 + 5s shutdown 是合适常量。关键差异:opencode 的 TUI 已经把数据访问抽到
fetch/events注入接口,qwen-code 的 TUI 没这层抽象,所以 Phase A 不能照抄"TUI 当 daemon client"——这是 Phase D 的事。1. 范围诚实声明⚠️
Phase A 实际可达成:
qwen --serve起 TUI 同时挂 HttpServercurl /capabilities、POST /session在同一 workspace 拿独立 session、SSE 收事件、发 prompt已知限制(与 Mode B 的差异,必须文档化告知用户):
QwenAgent路径authenticate请求被拒绝(返 ACP error)QwenAgent.authenticate()会clearCachedCredentialFile+refreshAuth→ 直接清掉 TUI 的凭据acpAgent.ts:618newSessionConfig每 session 新建Config--max-sessions默认调低到 5;长期靠 chiga0 finding #3 (MCP per-daemon shared state)"TUI ↔ daemon session 统一" 作为 Phase D 单独立项(不在本 issue 范围)。
2. 文件级改动清单
packages/cli/src/serve/runQwenServe.ts抽 +packages/cli/src/serve/index.ts导出validateAndCanonicalizeWorkspace(rawPath: string): string:把runQwenServe.ts:121-160的path.isAbsolute / fs.statSync / isDirectory / ENOENT/EACCES/EPERM校验 +canonicalizeWorkspace调用打包;导出供 Mode A 复用。理由:#4113 在server.tsdocblock 明确点名 "If a future entry point bindscreateServeAppdirectly to user input, it MUST replicate therunQwenServevalidation (or call into a shared helper if one is extracted)" —— Mode A 正是此场景,抽 helper 是干净路径packages/cli/src/index.ts或 yargs middleware 层--serve(boolean) +--serve-port(number, default 0 —— OS 分配避免与 Mode B 4170 冲突) flag;与serve子命令 /--acp/-p互斥校验。--workspace <path>flag 复用 #4113 已加的同名 flag(不要--serve-workspace)packages/cli/src/serve/inMemoryChannel.tshttpAcpBridge.test.ts:151-154已有的 paired NDJSON 通道模式为生产代码——createPairedChannel(): { clientStream, agentStream },两对TransformStream<Uint8Array, Uint8Array>背靠背 + SDK 既有的ndJsonStream(不是 PassThrough)packages/cli/src/serve/inProcessAcpBridge.tshttpAcpBridge.ts~2400 行小一个数量级,因为 in-process 没有 child / spawn race / SIGTERM grace)HttpAcpBridge接口;inline 一份去掉副作用的runAcpAgent等价物:① 创建 paired channel ②new QwenAgent(sharedConfig, sharedSettings, fabricatedArgv, agentSideConnection)③ 不重定向console.log/info/debug④ 不包process.stdout/stdin⑤ 不注册 SIGINT/SIGTERM(留给 Phase C) ⑥ 不在 stream end 时runExitCleanup+process.exit⑦ 在转发authenticaterequest 处直接返 ACP error "remote authenticate disabled in Mode A" ⑧ 在sendPrompt/newSession等调用包 try/catch 捕获 uncaughtExceptionpackages/cli/src/gemini.tsx~705 行附近if (config.isInteractive())分支里、render()之前,如果argv.serve:lazy importawait import('./serve/inProcessDaemon.js')→ 调 A0 抽出的validateAndCanonicalizeWorkspace(argv.workspace ?? process.cwd())拿到 boundWorkspace → 启 daemon → listen URL 走writeStderrLine(不是writeStdoutLine,避免污染 ink 的 stdout)packages/cli/src/serve/inProcessAcpBridge.test.tsHttpAcpBridge方法对齐httpAcpBridge.test.ts;复用 #4113 的makeBridge()helper 模式(默认boundWorkspace: WS_A,需要不同 binding 时显式覆盖);新增 5 条:远端authenticate被拒、远端 prompt 抛异常不杀进程、port 冲突 exit 1、lazy import 验证、省略cwdfallback 到boundWorkspacepackages/cli/src/serve/serveFlag.test.tsqwen --serve --serve-port 0子进程、stderr 解析 listen port、curl/capabilities和POST /session、--serve+serve/--acp/-p三个互斥组合都 exit 1docs/users/qwen-serve.mdqwen --serve" 章节 + Mode A vs Mode B 选型表,明确写 §1 的 5 条已知限制。不改docs/developers/qwen-serve-protocol.md(HTTP 协议、ACP wire、workspace_mismatchbody 形状对 Mode A/B 完全一致)。docs/developers/examples/daemon-client-quickstart.md顶部加一句 "示例适用 Mode A 和 Mode B;启动 daemon 的方式不同,client 接入完全一致" 即可3. 关键技术决策
决策 1:in-process bridge 用 paired channel + 完整 ACP
server.ts/eventBus.ts改动httpAcpBridge.test.ts:151-154的TransformStream对 +ndJsonStream模式已经存在,提取到inMemoryChannel.ts即可。简化优势(vs #4113 后的
httpAcpBridge.ts):in-process 没有 child process / spawn race / child crash race / SIGTERM grace window,因此不需要ChannelInfo.isDying状态、aliveChannels集合、killAllSync路径、tanzhenxin BkUyD 双 SIGINT 不变式。预估 in-process bridge 实际 ~180-220 行,比 #4113 后的httpAcpBridge.ts(~2400 行) 小一个数量级。决策 2:TUI 与 daemon 的 session 关系——解耦
详见 §1。daemon 在同进程独立跑一份
QwenAgent,TUI session 与 daemon session 互不可见。决策 3:何时 listen + 启动失败 + 日志走向
gemini.tsx的initializeApp(config, settings)之后(确保 settings/auth 就绪)、render(<App ...>)之前writeStderrLine打印错误,process.exit(1)writeStderrLine(stdout 被 ink 占用,stdout 写会污染 TUI 渲染)决策 4:EventBus 实例共享
createServeApp内部 new 一个 EventBus 给 SSE。Phase A:in-processQwenAgent走 paired channel → ACPsessionUpdate通知 → bridge 转 EventBus → SSE fan-out。不做 "TUI 直接订阅 EventBus" —— Phase D。决策 5:
--serve与现有 flag 的互斥qwen --serveqwen --serve --workspace /path/path而不是 cwd(合法,复用 #4113 的--workspace语义)qwen --serve --continue/--resume X/--prompt-interactive "..."/--model Xqwen serveqwen --serve serveqwen --acp --serveqwen -p "hello" --serveqwen --bare --serveqwen --input-format stream-json --serveqwen --json-schema "..." --serveqwen --serve在 non-TTY(nohup ... &)qwen servefor headless"校验在
gemini.tsx:417附近,与--bare/--prompt-interactive互斥校验同位置。决策 6:Phase A 强制 loopback
--serve-host在 Phase A 不暴露,硬编码127.0.0.1。远端绑定必须配套 token,是 Phase B 的事。决策 7:
boundWorkspace是 boot 时 snapshot + canonicalMode A 的
boundWorkspace=validateAndCanonicalizeWorkspace(argv.workspace ?? process.cwd())at--serveboot time,永不变。即使 TUI 期间process.chdir()(被某些命令触发),daemon 仍服务原 workspace。这与 #4113 的 1-daemon-1-workspace 语义一致。关键:
canonicalizeWorkspace是httpAcpBridge.ts已经导出的幂等 helper,处理 symlink + 大小写不敏感 FS。Phase A 必须 pre-canonicalize——否则/capabilities.workspaceCwd与 bridge 内部的 canonical 形式可能漂移(虽然 #4113 自己有重复 canonicalize 的兜底,但与runQwenServe模式对齐更干净)。实证:grep 全代码
process.chdir()在ui/commands/、services/、acp-integration/下零调用——snapshot vs dynamic 在今天等价。但未来子 agent 工具(EnterWorktreePR #4073)涉及切目录,dynamic 模式会默默改 boundWorkspace导致已连接客户端全部workspace_mismatch而不通知——sliding error。snapshot 严格优于 dynamic。决策 8:远端
authenticate请求拒绝转发In-process bridge 在 ACP request 路由层拦截
authenticatemethod,直接返 ACP error{ code: -32601, message: "remote authenticate disabled in Mode A; use TUI /auth instead" }。理由见 §1:QwenAgent.authenticate()会清 TUI 凭据。决策 9:
config/settings共享,argvfabricatedaemon 的
QwenAgent用TUI 已构造好的config+settings实例(避免 settings 漂移),但argv新建一份干净的CliArgs(只含 cwd、mode 等 daemon 相关字段),避免 TUI 的 flags 如--prompt-interactive误导 daemon 行为。InProcessBridgeOptionsTS 签名(与 #4113 后的BridgeOptions对齐):fabricateDaemonArgv(orig: CliArgs)清单(保留/丢弃):model,yolo,approvalMode,extensions,includeDirectories,mcpConfig,allowedMcpServerNames,telemetry*,openaiApiKey,openaiBaseUrl,proxy,authType,coreTools,excludeTools,disabledSlashCommands,allowedTools,maxSessionTurns,chatRecording,checkpointing,debug,screenReader,sandbox,sandboxImage,channelprompt,promptInteractive,query,bare,inputFormat,outputFormat,inputFile,jsonFd,jsonFile,jsonSchema,includePartialMessages,acp,experimentalAcp,experimentalLsp,openaiLogging,openaiLoggingDir,listExtensions,continue,resume,sessionId决策 10:lazy import
gemini.tsx里 daemon 启动逻辑用await import('./serve/inProcessDaemon.js')包裹,不带--serve时不付 ESM 冷启动成本(约 50ms,对齐 Mode B 的commands/serve.ts:106lazy 模式)。4. 数据流图
5. 测试矩阵
inMemoryChannel.test.tsinProcessAcpBridge8 个方法契约inProcessAcpBridge.test.tshttpAcpBridge.test.ts同样的断言(spawnOrAttachsendPromptcancelSessionsubscribeEventsrespondToPermissionlistWorkspaceSessionssetSessionModelkillSession),不 spawn child;复用 #4113 的makeBridge()helper 模式cwd也能创建 sessioninProcessAcpBridge.test.tsPOST /sessionbody 不带cwd→ 拿到 sessionId,responseworkspaceCwd=boundWorkspace(验证 #4113 引入的 fallback 行为对 in-process bridge 也生效)authenticate被拒(决策 8 + §1)inProcessAcpBridge.test.tsauthenticaterequest → 收到 method-disabled error;TUI 当前 OAuth credentials 文件 mtime 不变inProcessAcpBridge.test.tsQwenAgent.newSession抛Error('boom')→ bridge 转换为 ACP error 返客户端,进程仍存活inProcessAcpBridge.test.ts--serve启动 →require.cache不含inProcessAcpBridge.jsqwen --serve启动serveFlag.test.tsqwen --serve --serve-port 0,stderr 解析出 listen port,curl/capabilities200--workspaceflag 接入serveFlag.test.tsqwen --serve --workspace /tmp/x→/capabilities.workspaceCwd === '/tmp/x'--workspaceboot validationserveFlag.test.ts--workspace /no/such/path/ 相对路径 / 文件而非目录 → exit 1 + 友好错误(验证 A0 helper 调用正确)serveFlag.test.ts--serve启用时仍通过--serve互斥校验serveFlag.test.ts--serve serve/--serve --acp/--serve -p/--serve --bare四个组合都 exit 1serveFlag.test.ts--serve --serve-port <该 port>→ stderr 含端口号 + exit 1serveFlag.test.tsPOST /session拿到 sessionId Y,GET /session/Y/eventsSSE 通;TUI 的会话 sessionId X 不出现在 daemon 的listWorkspaceSessions视图里(验证 §1 限制)6. 验收标准
qwen --serve启 TUI,TUI 内/help等命令正常工作curl http://127.0.0.1:N/capabilities返回workspaceCwd= TUI 的 cwd(N 从 stderr 读)curl -X POST http://127.0.0.1:N/session -d '{}'拿到 sessionId(省略 cwd 走 fallback)curl -X POST http://127.0.0.1:N/session -d '{"cwd":"/wrong"}'返 400 +code: workspace_mismatchcurl -N http://127.0.0.1:N/session/{id}/events流式收 ACP 事件POST /session/{id}/prompt触发 agent 工作并 SSE 返流式 tokenauthenticaterequest → 收到 method-disabled error,TUI 凭据未受影响qwen --serve --workspace /no/such/path→ exit 1 + 友好错误--serve启动 → ESM 加载图不含 serve 模块vitest全绿,新增测试通过7. Phase A 显式不做的事
/quit协调 / 双 Ctrl+C 强退--serve时process.exit路径 cleanup serve(避免 listener 泄漏)~/.qwen/serve/instances/<pid>.json8. PR 拆分建议
4.3 天 diff 单 PR review burden 大,建议拆 4 个 stacked PR:
validateAndCanonicalizeWorkspace共享 helper,重构runQwenServe.ts:121-160调它(这是对 #4113 代码的小重构 PR,本身不引入 Mode A)inMemoryChannel.ts+ 单元测试(纯重构httpAcpBridge.test.ts:151-154已有模式,零行为变更)inProcessAcpBridge.ts+ 契约测试(含 §3 决策 8 authenticate 拒绝、§3 决策 9 fabricate argv、§3 决策 10 lazy import 准备;用 A0 helper、canonicalizeWorkspace、WorkspaceMismatchError、makeBridge()模式)--serveflag +--workspace接入 +gemini.tsx集成 + 互斥校验 + e2e + 文档A1 可以现在就开(不依赖 #4113);A0/A2/A3 等 #4113 merge 后再叠,避免 rebase。
Phase A 工作量估算(修正后)
validateAndCanonicalizeWorkspacehelperinMemoryChannel.ts+ 测试inProcessAcpBridge.ts(含 inline 无副作用runAcpAgent+ authenticate 拒绝 + uncaughtException 包裹 + cwd fallback)--serve+--workspaceflag +gemini.tsxlazy import + stderr 打印 + listen 错误处理 + e2e + 文档主要追加成本是:
ROI 正向:换来零侵入 server 层 + ACP 协议自动跟随 + 不引入安全/资源放大问题 + 与 #4113 工作流对齐。
路线决策点⚠️
#3929/#3930/#3931(chiga0) 是另一条功能上重叠但协议不同的路:qwen remote-control+qwen --remote-control,WebSocket + stream-json。两条路都做"TUI 当 super-client + 移动端瘦客户端",开 1.5b 之前需要拉一次设计对齐:AcpChannel/EventBus),再让两套都接到同一根不在本 issue 范围
QwenAgent路径的重构;同时解锁远端 authenticate / process-level credential mediator / telemetry span 合并)—— 单独 issuesessionScopeoverride、loadSessionHTTP、heartbeat、token revocation 等)—— 单独 issue参考
Daemon 系列上游(@wenshao 主导)
qwen serve(✅ 已合入)canonicalizeWorkspace/WorkspaceMismatchError/BridgeOptions.boundWorkspace等)设计文档
qwen-code-daemon-design§04 / §06(@wenshao 起草)路线决策点
外部参考
packages/opencode/src/cli/cmd/tui/thread.ts(Worker + RPC fetch)