Commit graph

5806 commits

Author SHA1 Message Date
易良
796de4dfef
fix(core): merge IDE context into user prompt (#3980)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* fix(core): merge IDE context into user prompt

IDE mode now wraps editor context in a <system-reminder> block and
prepends it to the current user request instead of inserting a separate
user history entry via addHistory(). This preserves the API history
turn shape and avoids extra user turns in IDE mode.

Key changes:
- IDE context merged into user request via prependToFirstTextPart()
- State update deferred until after arena cancellation check
- escapeClosingSystemReminderTags() hardens against tag injection
  including zero-width/control character variants
- forceFullIdeContext reset on stream errors for correct resend
- Context prompt updated to encourage active use of editor context

Refs #3712

* fix(core): restore BaseLlmClient per-model cache clear on session reset

resetChat only cleared GeminiClient's new perModelGeneratorCache but
dropped the BaseLlmClient.clearPerModelGeneratorCache() call that was
present before the refactoring. sideQuery.ts and sessionTitle.ts still
route through BaseLlmClient with the fast model, so stale generators
survived session reset.

* refactor(core): remove duplicated per-model generator code from GeminiClient

The per-model ContentGenerator resolution logic (resolveModelAcrossAuthTypes,
createRetryAuthTypeForModel, createContentGeneratorForModel, perModelGeneratorCache)
was inadvertently duplicated from BaseLlmClient into GeminiClient. This restores
the original one-line delegation to BaseLlmClient.resolveForModel() and removes
~130 lines of redundant code to keep the PR focused on IDE context merging only.

* fix(core): harden IDE context reminder escaping

* fix(core): defer IDE context baseline update

* fix(core): use shared escapeSystemReminderTags in tool scheduler

Aligns the rule-activation envelope scrub with the IDE-context path —
both now route through `escapeSystemReminderTags`, which neutralizes
whitespace, zero-width, and control-character variants of
`<system-reminder>` tag boundaries. The previous narrow regex only
matched the literal `</system-reminder>` sequence, so a rule body
containing `</system-reminder >`, `</ system-reminder>`, or
`<​/system-reminder>` could still close the envelope mid-content.

* docs(core): clarify request assembly order in IDE merge path

Two adjacent comments described the pre-merge model: one called
the system-reminder block "append" while the code prepends, and the
tryCompressChat note still talked about "the previous context turn"
which no longer exists once IDE context is merged into the user
prompt. Rewrite both to match what the code actually does so future
readers do not get a misleading mental model of prompt assembly or
post-compression resend behavior.

* docs(core): align scheduler scrub comment with shared helper

The block-level comment still labeled the sanitization step as
"closing-tag scrub", which described the old narrow regex. The
shared escapeSystemReminderTags helper now neutralizes opening /
self-closing / obfuscated variants too, so name the helper directly
to keep the rationale and the call site in agreement.

* test(core): cover escapeSystemReminderTags variants in scheduler

The end-to-end scheduler scrub test only exercised a literal
</system-reminder> body. Now that the rule-activation envelope routes
through escapeSystemReminderTags, extend the integration coverage to
the obfuscated closing-tag variants the helper was introduced to
catch (whitespace before/after the slash, ZWSP / WJ / VS-16 inside
the name) and to opening-tag injection. Each case asserts that the
envelope still has exactly one </system-reminder> closer and that the
raw obfuscated form (or unescaped opening tag) does not survive into
the model-facing payload.

Refactor the existing test's mock setup into a shared
runSchedulerWithRule helper so the new it.each variants stay
focused on the assertion shape.

* fix(core): address remaining review feedback

- Add debug log when IDE context parts are empty for diagnosability
- Add safety comment in xml.ts explaining why no fast-path pre-check
  is used in getSystemReminderTagKind (zero-width obfuscation bypass)
2026-05-14 11:33:00 +08:00
ChiGao
3cc66f9ae5
ci(deps): bump docker/* actions to Node 24 majors (#4131)
GitHub Actions deprecates the Node 20 runtime; older versions of every
docker/* action run on Node 20 and emit the "Node.js 20 actions are
deprecated" warning in every release / e2e / image-build run today.

Each action shipped a "Node 24 as default runtime" major:

  - docker/setup-buildx-action  v3 → v4 (2026-03-05)
  - docker/setup-qemu-action    v3 → v4
  - docker/metadata-action      v5 → v6
  - docker/login-action         v3 → v4
  - docker/build-push-action    v6 → v7

None of our usages touch the deprecated inputs removed in the bumps —
release.yml / e2e.yml call setup-buildx with no `with:` block, and
build-and-publish-image.yml only passes the universally-supported
`images` / `tags` / `registry` / `username` / `password` / `context` /
`platforms` / `push` / `labels` / `build-args` inputs. ESM internal
refactor of each action is transparent to consumers.

Ratchet-pinned bumps use the v4.0.0 commit SHA
`4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd` (release.yml + e2e.yml).
Other action references in build-and-publish-image.yml use
`# ratchet:exclude` per existing convention, so version-string bumps
suffice there.

Verified runtime hosts (`actions/runner` v2.327.1+) are already in use
on github-hosted runners as of 2026-03; no infra bump required.

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
2026-05-14 10:54:01 +08:00
顾盼
c512427f93
feat(core): strip inline media before chat compaction summary (#4101)
* feat(core): strip inline media before chat compaction summary

Compaction's side-query previously shipped historyToCompress verbatim.
Two related issues degraded summary quality and accuracy:

- Inline image / document bytes (from MCP tool results) leaked into the
  summary model's prompt where they could not be interpreted and merely
  inflated payload.
- findCompressSplitPoint apportioned chars via JSON.stringify(content),
  so a single 1 MB base64 image looked like ~350K tokens and biased
  the split point. Real Qwen-VL token cost is at most a few thousand.

This change adds a new compactionInputSlimming module that replaces
inlineData / fileData parts with short [image: <mime>] / [document:
<mime>] placeholders before the side-query, leaving live history
unchanged. The same constant feeds estimateContentChars so the
split-point algorithm sees the budget the summary model actually
consumes downstream. Microcompact is also extended to clear stale
inline images alongside old tool results.

A previous draft of the design also externalized large pastes to a
content-addressable on-disk cache, but it was withdrawn after surveying
claude-code's 2026-03 to 2026-05 releases - upstream consensus is to
keep user input visible to the model and amortize cost via prompt
caching rather than externalize. See the Out-of-scope section of the
design doc for the full rationale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(core): recurse into functionResponse.parts when stripping media

E2E exposed that `read_file` (and any tool that surfaces an image)
wraps the result in `functionResponse.parts` via
`coreToolScheduler.createFunctionResponsePart`. The slimming module
only walked top-level `part.inlineData` / `part.fileData`, so the
nested base64 bytes leaked into the compaction side-query payload.
The previous design doc incorrectly claimed that no recursive walk
was needed.

Three changes:

- `slimCompactionInput.transformPart` recurses into the nested
  `functionResponse.parts` array and replaces each entry via the
  same image/document placeholder logic.
- `estimatePartChars` walks the nested array too, so the split-point
  algorithm doesn't fall back to `JSON.stringify` and over-count the
  base64 bytes.
- `microcompactHistory` drops `functionResponse.parts` when clearing
  an old tool result; the previous spread of `...part.functionResponse`
  silently carried the original media through.

New unit tests cover (a) nested image / document stripping, (b) the
estimator no longer being skewed by nested base64. The previously
failing E2E now PASSES: side-query payload contains zero `data:image/`
occurrences, zero long base64 runs, and exactly one
`[image: image/png]` placeholder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(core): address review findings on compaction image stripping

Addresses 8 valid findings from PR review:

- [Critical] estimatePartTokens now handles `fileData` parts (both
  top-level and nested under functionResponse.parts). Without this,
  microcompact's `tokensSaved === 0` short-circuit silently discarded
  every fileData clear.

- estimatePartTokens for binary parts now uses a fixed
  MEDIA_PART_TOKEN_ESTIMATE constant (1,600) instead of base64-length
  divided by 4. The old formula billed a 1 MB image as ~250K tokens
  rather than its actual ~1,280 visual tokens on Qwen-VL, inflating
  the saved-token metric by orders of magnitude.

- mimeType values from MCP tool servers are now run through
  sanitizeMimeForPlaceholder before being embedded in `[image: …]` /
  `[document: …]` placeholders. An adversarial server could otherwise
  craft `image/png]\n\n[SYSTEM: …` and inject instructions into the
  summary side-query.

- collectCompactablePartRefs now recognizes a third 'nested-media'
  kind: functionResponse parts from non-compactable tools (e.g. MCP
  screenshots whose names aren't in COMPACTABLE_TOOLS) that carry
  images on functionResponse.parts. The nested media is dropped while
  the tool's text output is preserved. Previously such media
  accumulated forever in live history.

- keepRecent budgets are now per-kind (tool / media / nested-media).
  Setting `toolResultsNumToKeep: 1` keeps 1 of each kind rather than 1
  entry total across the merged list — matches the natural reading of
  the setting name.

- findCompressSplitPoint's `precomputedCharCounts` fallback path is
  now documented as test-only; production callers MUST pass the
  precomputed array.

- The text-based branch of isAlreadyCleared is gone: with the new
  nested-media handling (drops `parts`) and existing media handling
  (replaces with `{ text: … }` that is no longer collected) it was
  unreachable.

- OpenAI converter (createToolMessage) now passes text parts inside
  functionResponse.parts through as text content. The slimmer writes
  `{ text: '[image: image/png]' }` placeholders into the nested array;
  without this fix the converter dropped them when serializing to the
  OpenAI wire format, leaving the summary model with empty tool
  responses instead of the placeholder.

Two findings deferred with rationale (see design doc Open Questions):
MIN_COMPRESSION_FRACTION still uses pre-slim counts (acceptable —
"user shared an image" is itself worth summarizing); SlimResult is not
re-exported (round-3 simplify decided to keep core's public surface
minimal).

E2E re-verified end-to-end: side-query payload contains 0 data:image/
occurrences, 0 long base64 runs, and 1 `[image: image/png]` placeholder
in the expected position. 185/185 collocated unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(core): tidy compaction slimming after self-review

Three small polishes from a follow-up code review pass:

- `stripNestedMedia` no longer re-casts its return value: after
  destructuring `parts` out of the widened input type, TypeScript
  infers the original `FunctionResponse` shape without help.
- `isAlreadyCleared` shed a 10-line comment block — the body is now
  one line, so one descriptive line above it is enough.
- OpenAI converter's nested-part text check switched from
  `(part as { text?: unknown }).text` to
  `'text' in part && typeof part.text === 'string'`, dropping the
  cast and letting `in` narrow the type.

No behavior change. 185/185 unit tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(core): wire slim stats to debug log; split MicrocompactMeta tools vs media

Addresses two follow-up review suggestions:

- `slimCompactionInput` returned `stats.imagesStripped` and
  `stats.documentsStripped` but the orchestrator never consumed them.
  Now logged at debug level whenever non-zero so operators can confirm
  the slimming pipeline actually fires on image-heavy compactions.

- `MicrocompactMeta.toolsCleared` lost meaning after the recent
  refactor: it had grown to count both tool-result clears AND
  inline-media / nested-media clears. Renamed:
  - `toolsCleared` → only `tool`-kind clears (compactable tool output)
  - `mediaCleared` → `media` + `nested-media` clears (new)
  - `toolsKept` / `mediaKept` mirror the split, replacing the prior
    `toolsKept` that was actually a combined count.

  The single non-test consumer (`client.ts` debug log) updated to use
  both fields.

185/185 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:20:11 +08:00
tanzhenxin
f72a156e13
fix(anthropic): allow cache_control on tool_result blocks so the per-turn breakpoint advances (#4121)
The Anthropic adapter places three cache_control breakpoints (system, last tool,
last user message), but `addCacheControlToMessages` only attached the third when
the last block of the last user message was a non-empty text block. After turn 1
of any agentic conversation, the last user message is a tool_result, so the
breakpoint was silently dropped and the cacheable region collapsed back to
system+tools. Per-turn history was never cached.

Anthropic's docs explicitly list tool_result as a cacheable block type
(https://docs.claude.com/en/docs/build-with-claude/prompt-caching). Accepting
both text and tool_result keeps the breakpoint moving forward as the
conversation grows.
2026-05-14 10:07:11 +08:00
qwen-code-ci-bot
d419a92672
chore(release): v0.15.11 [skip ci]
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-14 09:51:46 +08:00
John London
93c5ce162f
fix(search): make empty-query exit synchronous and normalize Windows Backspace (#3981)
* fix(search): make empty-query exit synchronous and normalize Windows Backspace

Two fixes to useSessionSearchInput that caused 'Backspace edits the query;
emptying it returns to list mode' to fail on Windows:

1. Replaced the useEffect-based onExitToList mechanism with a ref-backed
   setSearchQuery that detects the non-empty → empty transition
   synchronously inside the state updater. The previous useEffect approach
   introduced a one-frame delay where the component rendered in search
   mode with an empty query before the effect fired, causing the
   'Press / to search' hint to be absent on Windows.

2. Added isDeletionKey() helper that recognizes Backspace from both the
   key.name field ('backspace'/'delete') and the raw sequence bytes
   (\x7f DEL and \b BS), so Windows terminals that deliver Backspace
   without normalizing the key name are handled correctly.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* fix(search): address review feedback on PR #3981

- Update onExitToList JSDoc: describe synchronous ref-backed detection instead of stale useEffect reference
- Update test suite comments: replace useEffect references with ref-backed setter description
- Add assertion that implicit entry (empty→non-empty via setSearchQuery) does NOT trigger onExitToList
- Add tests for Backspace/Esc/Ctrl+U/Ctrl+L on already-empty query: must not trigger onExitToList

* fix(search): address remaining review feedback on PR #3981

- Add isDeletionKey byte-fallback tests (\x7f DEL and \b BS)
- Update setSearchQuery JSDoc to document synchronous onExitToList side-effect
- Stabilize callback deps: read onExitToList via ref so setSearchQuery
  and handleSearchKey are not recreated on parent re-render
- Clarify onExitToList JSDoc: fires before React re-renders, ref is
  current but state variable is stale
- Add test for setSearchQuery('') direct-empty path (non-empty → empty)
- Add test for setSearchQuery('') on already-empty query (no spurious exit)

* test: stabilize flaky export format cycling test on Windows CI

Increase wait from 50ms to 350ms after typing '/export md' to allow
the useEffect in useExportCompletion to fire and set cyclingActiveRef
before the Down keypress arrives. This matches the pattern used by other
tests in this file that depend on useEffect timing.

* fix(search): guard isDeletionKey byte fallback against ctrl/meta modifiers

The byte fallback in isDeletionKey (key.sequence === '\x7f' / '\b')
previously fired even when ctrl or meta modifiers were active.
Ctrl+H delivers name:'h', ctrl:true, sequence:'\b' on many terminals
and was incorrectly treated as Backspace, deleting the last query char.

Add !key.ctrl && !key.meta guard so the byte fallback only activates
when the terminal truly did not normalize the key name.

Also add two tests:
- Ctrl+H (BS byte with ctrl) must not be treated as deletion key
- Meta+DEL byte must not be treated as deletion key

---------

Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-05-14 09:03:16 +08:00
ChiGao
d343e2c15e
feat(perf): progressive MCP availability — MCP no longer blocks first input (#3994)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* feat(perf): progressive MCP availability — MCP no longer blocks first input

Today `Config.initialize()` runs MCP discovery synchronously and the cli
can't accept input until every configured MCP server finishes its
discover handshake. One slow or hung server bottlenecks every user with
MCP configured. Validated by the profiler instrumentation added in this
PR (set `QWEN_CODE_PROFILE_STARTUP=1` to reproduce):

| User scenario             | Time to first prompt input |
| ------------------------- | -------------------------- |
| No MCP                    | ~480 ms                    |
| 1 fast MCP                | ~875 ms                    |
| 2 fast + 1 slow MCP       | **~7.1 s**                 |
| 1 hung MCP server         | **~10.5 s**                |

(Measured on macOS arm64 / Node 24.15, n=30/fixture, p50.)

`Config.initialize()` now passes `{ skipDiscovery: true }` to
`createToolRegistry` by default and kicks off MCP discovery in a
fire-and-forget background path. As each server completes discover,
the cli's `AppContainer` debounces `setTools()` calls into one-frame
(16 ms) batches so the model sees the consolidated tool list shortly
after each server settles. Rollback: `QWEN_CODE_LEGACY_MCP_BLOCKING=1`.

- `packages/core/src/config/config.ts` — `Config.initialize` switches
  to `skipDiscovery: true` + new `startMcpDiscoveryInBackground()`
  (defensive against partially-stubbed `ToolRegistry` in tests). Adds
  `MCPServerConfig.discoveryTimeoutMs` (last positional ctor param —
  doesn't shift existing call sites). Tool-call timeout is untouched.
- `packages/core/src/tools/tool-registry.ts` — new
  `getMcpClientManager()` getter so the background path can call the
  incremental discover directly without going through `discoverMcpTools`
  (which would wipe already-registered tools).
- `packages/core/src/tools/mcp-client-manager.ts` —
  `discoverAllMcpToolsIncremental` now: emits `mcp-client-update`
  after IN_PROGRESS transition, wraps each per-server discover in a
  discovery-only timeout (stdio 30s, remote 5s), emits trailing
  `mcp-client-update` after COMPLETED so UI subscribers see the
  terminal state.
- `packages/cli/src/ui/AppContainer.tsx` — new `useEffect` (gated on
  `isConfigInitialized`) subscribes to `mcp-client-update` and
  16ms-batches `setTools()` calls. Same effect also defers
  `finalizeStartupProfile` until MCP settles (or 35s hard cap), so
  startup-perf profiles capture the full MCP timeline.

Activated only by `QWEN_CODE_PROFILE_STARTUP=1`; when unset every
profiler entry point short-circuits in a single null/flag check and
returns. Heisenberg overhead measured at -1.12% Δp50 between
profile-on vs profile-off (Welch p=0.092, n=30/config × 3 configs) —
within statistical noise.

- `packages/cli/src/utils/startupProfiler.ts` — extended with
  `events` array (multi-fire), `recordStartupEvent`,
  `setInteractiveMode`, `derivedPhases`, per-checkpoint heap snapshots,
  `MAX_EVENTS` cap, and `QWEN_CODE_PROFILE_STARTUP_OUTER` / NO_HEAP
  env opt-ins. + 7 new tests.
- `packages/core/src/utils/startupEventSink.ts` (new) — minimal
  cross-package sink so `core` can emit profiler events without
  reverse-depending on `cli`. No-op when no sink registered. + 4 tests.
- `packages/core/src/index.ts` — export `setStartupEventSink` /
  `recordStartupEvent` / type aliases.
- `packages/cli/src/gemini.tsx` — registers the sink at `main()`
  entry, adds `first_paint` checkpoint after Ink render, calls
  `setInteractiveMode(true)` in the interactive branch.
- `packages/core/src/config/config.ts` — emits
  `tool_registry_created`.
- `packages/core/src/core/client.ts` — emits `gemini_tools_updated`
  at the end of `setTools()`.
- `packages/core/src/tools/mcp-client-manager.ts` — emits
  `mcp_discovery_start`, `mcp_server_ready:<name>`,
  `mcp_first_tool_registered`, `mcp_all_servers_settled`.
- `packages/cli/src/ui/AppContainer.tsx` — emits
  `config_initialize_start`, `config_initialize_end`, `input_enabled`.

`Config.initialize()` now returns BEFORE MCP discovery completes.
Things to check:
- Any code path that assumed "after `config.initialize()`, all MCP
  tools exist in the registry" — these will see only built-in tools
  initially; new tools appear via `mcp-client-update` events.
- `MCPDiscoveryState.COMPLETED` is now set asynchronously instead of
  synchronously after `initialize()` resolves.
- Model requests issued before MCP settles see only built-in tools;
  subsequent requests see the full set as servers come online.
- Tests that assert MCP tool count immediately after
  `config.initialize()` should wait for the `mcp-client-update` with
  COMPLETED discoveryState instead.

- 313 impacted-area tests green (config / mcp-client-manager / client
  / startupProfiler 18 / startupEventSink 4).
- `tsc --noEmit` clean for `packages/core` and `packages/cli`.
- `eslint` clean on touched files.
- Manual: `QWEN_CODE_PROFILE_STARTUP=1 SANDBOX=1` interactive run
  produces a JSON profile in `~/.qwen/startup-perf/` containing
  `first_paint`, `config_initialize_start/end`, `input_enabled`,
  MCP per-server events, and `gemini_tools_updated`. See PR
  description's "How to validate" section.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): harden progressive MCP discovery against silent regressions

Addresses review feedback on PR #3994:

- Skip user-disabled servers in discoverAllMcpToolsIncremental. The new
  incremental path used to iterate Object.entries(servers) without
  consulting isMcpServerDisabled, so a server the user had explicitly
  turned off would still get connected and its tools registered.
  Mirrors the existing protection in discoverAllMcpTools.

- Disconnect the underlying client when runWithDiscoveryTimeout fires.
  Without this, the inner discoverMcpToolsForServer kept running after
  the timeout rejected the outer promise — if discover() eventually
  succeeded it would register the late server's tools into the live
  toolRegistry (a silent registration vector, especially exploitable
  with a 0/negative discoveryTimeoutMs override).

- Clamp discoveryTimeoutMs to [100ms, 300_000ms]. 0/negative/Infinity
  values previously passed through to setTimeout unvalidated and made
  the silent-registration bug above trivially reachable.

- Classify the `tcp` (WebSocket) transport field as remote so hung WS
  handshakes use the 5s default instead of the 30s stdio default.

- Defensive delete of serverDiscoveryPromises[name] in the per-server
  catch so a doomed/orphan entry can't briefly short-circuit a
  subsequent discoverMcpToolsForServer call.

Adds focused tests for each fix.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): restore runtime.json sidecar and harden non-interactive MCP visibility

Addresses review feedback on PR #3994:

- Restore writeRuntimeStatus + markRuntimeStatusEnabled in
  startInteractiveUI. The progressive-MCP diff inadvertently dropped
  the runtime.json sidecar write from the interactive entry point,
  leaving Config.refreshSessionId()'s session-swap refresh as dead
  code and silently breaking external integrations (terminal
  multiplexers, IDE integrations, status daemons) that map PID →
  sessionId via runtime.json.

- Add Config.getFailedMcpServerNames() and surface a stderr warning
  in --prompt / stream-json / ACP entry points when one or more MCP
  servers failed during background discovery. Per-server errors are
  caught inside discoverAllMcpToolsIncremental and never reached a
  TTY otherwise, so a script using non-interactive mode with broken
  MCP config would silently run with only built-in tools — a
  regression vs the legacy synchronous path.

- Pass the parsed `settings` object through to
  runNonInteractiveStreamJson. The new call site dropped the
  argument, falling back to createMinimalSettings() and losing any
  user-configured permission / approval / hook setup for stream-json
  sessions. Added regression assertion to gemini.test.tsx.

- Move finalizeStartupProfile out of gemini.tsx's stream-json branch
  and into Session.ensureConfigInitialized so it runs AFTER
  config.initialize() / waitForMcpReady() in stream-json. Previously
  the profile was finalized before any MCP / config_initialize_*
  events were emitted, producing empty stream-json profiles.

- Gate setStartupEventSink registration on isStartupProfilerEnabled()
  so core-side recordStartupEvent calls short-circuit at the first
  null-check when profiling is disabled, instead of going through an
  arrow wrapper and the profiler's own enabled gate.

- Tighten the type-unsafe ToolRegistry cast in
  startMcpDiscoveryInBackground to preserve the typed return signature
  so a rename of getMcpClientManager would be flagged at this call
  site (kept the optional-chain guard for tests that stub
  ToolRegistry as a plain object).

- Re-document first_paint as "render call returned" so consumers don't
  confuse Ink's synchronous render() return with literal pixel paint.
  Kept the checkpoint name for backward compatibility with collected
  profiles.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): restore resize repaint and pin gemini_tools_lag capture in AppContainer

Addresses review feedback on PR #3994:

- Restore the terminal-resize useEffect that calls
  repaintStaticViewport() when terminalWidth changes. The progressive-
  MCP diff removed previousTerminalWidthRef + the repaint useCallback
  + the resize useEffect, so tmux pane resizes and fullscreen toggles
  leave the static region rendered at the old width — header content
  visibly tears until something else triggers refreshStatic.

- Pin the gemini_tools_lag startup metric. The previous onMcpUpdate
  handler called finalizeOnce() synchronously when discovery reached
  COMPLETED, but the pending setTools() batch was still 16ms away.
  setTools() emits `gemini_tools_updated` — when finalize ran first
  the profile's `finalized` guard suppressed that event, so
  gemini_tools_lag came out undefined in interactive mode. New
  onMcpUpdate flushes setTools() NOW on COMPLETED and only finalizes
  after the flush resolves, guaranteeing the event lands.

- Log setTools() batch-flush errors via debugLogger instead of
  silently swallowing them. GeminiClient.setTools() has no try/catch
  around warmAll() / getFunctionDeclarations() / getChat().setTools();
  the previous `.catch(() => {})` would have hidden production
  tool-registration regressions completely.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): correct MCP failure visibility and incremental cleanup

Addresses three review findings on PR #3994:

- McpClient.discover() now flips the client status to DISCONNECTED before
  re-throwing. Previously, a server that connected successfully but whose
  discoverPrompts / discoverTools then rejected (or that returned no
  prompts and no tools) would remain CONNECTED in the global status
  registry. Config.getFailedMcpServerNames() filters by
  `status !== CONNECTED`, so such servers were silently omitted from the
  non-interactive failure banner and the Footer's MCP health pill kept
  counting them as healthy.

- discoverAllMcpToolsIncremental no longer records `outcome: 'ready'`
  for servers whose connect/discover threw. The inner
  discoverMcpToolsForServerInternal catches errors without re-throwing
  (best-effort discovery semantics), so the try block resolved even for
  failures — only the runWithDiscoveryTimeout path reached the catch.
  Auth errors, server crashes, and missing-tools responses were therefore
  recorded as success in the startup profile. We now consult the actual
  server status (now correctly DISCONNECTED after the first fix) before
  emitting `ready`, and emit `outcome: 'failed'` otherwise.
  `mcp_first_tool_registered` is gated on the same check so a failed
  server can't pollute that user-facing metric.

- discoverAllMcpToolsIncremental tears down enabled→disabled mid-session
  transitions. When a previously-connected server is disabled (e.g. via
  `/mcp disable foo` or by editing settings), the incremental path used
  to just `continue` past it, leaving its client, tools, health check,
  and global status entry in place. Now calls removeServer() for any
  already-known client we encounter in the disabled branch.

Adds focused tests for each fix.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(core): clarify ToolRegistry cast comment in startMcpDiscoveryInBackground

Addresses review feedback on PR #3994. The previous comment claimed the
call site uses "no defensive cast" but the code still casts via
`as ToolRegistry & { getMcpClientManager?: ... }`. Reword to explain
the cast's actual purpose: it exists only because some tests stub
ToolRegistry as a plain object, so we use optional chaining to avoid
crashing the init path when those tests run. Also note that the inner
shape now uses `ReturnType<ToolRegistry['getMcpClientManager']>` — a
future rename of the production method still surfaces as a type error
at this call site rather than silently falling through to the
`if (!manager)` branch.

Comment-only change; no behavior diff.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): close MCP timeout TOCTOU race and propagate disconnect status

Addresses two critical findings on PR #3994 round 6:

- runWithDiscoveryTimeout no longer uses fire-and-forget disconnect. The
  prior `void client.disconnect()` returned before `transport.close()`
  landed, leaving a window where an in-flight `discover()` could pump
  `tools/list` through the transport and synchronously register tools
  into the live registry BEFORE the close took effect. The earlier fix
  comment described this as a "remote-exploitable silent-tool-registration
  vector"; the await closes the timing window but doesn't help if tools
  already landed, so we also drop them with `removeMcpToolsByServer()`
  after the disconnect resolves. No-op when discover hadn't reached
  registration yet.

- McpClient.disconnect() now writes DISCONNECTED to the global registry
  directly. Previously, `isDisconnecting = true` was set BEFORE the
  internal `updateStatus(DISCONNECTED)` call, and `updateStatus`'s guard
  (designed to suppress LATE writes from a stale `connect()` catch)
  silently swallowed the write. The global stayed CONNECTED forever for
  timeout-disconnected servers, so `Config.getFailedMcpServerNames()`
  (which filters `status !== CONNECTED`) omitted them from the
  non-interactive failure banner and the Footer's MCP health pill kept
  counting them as healthy. This invalidated the round-5
  `getMCPServerStatus === CONNECTED` gate, which would always pass the
  "ready" check for timed-out servers. The guard stays in place for its
  original purpose; the legitimate disconnect→DISCONNECTED notification
  now bypasses it by writing the registry directly.

Also adds the `config_initialize_start` / `_end` profiler checkpoints
to `Session.ensureConfigInitialized()` so stream-json startup profiles
include the same derived `config_initialize_dur` phase as the
non-stream-json branch in gemini.tsx (round 6 [Suggestion]).

Tests cover (a) the disconnect-and-cleanup path on timeout and (b) the
intentional-disconnect global registry propagation regression.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(mcp): surface failures + prevent health-check resurrection of timed-out servers

Round-7 review follow-ups:

- AppContainer (interactive): MCP startup failures now route through
  debugLogger.warn on COMPLETED. Was silent — only debug logs / profile
  events surfaced failures, so regular interactive users got no
  indication their MCP servers failed. Mirrors the non-interactive
  stderr warning, adjusted to debugLogger so it doesn't collide with
  Ink's rendered output.

- acpAgent per-session: `QwenAgent.initializeConfig()` now emits the
  same `Warning: MCP server(s) failed to start` stderr line as the
  top-level `runAcpAgent` path. Previously per-session ACP configs
  with failed MCP servers silently fell back to built-in tools.

- mcp-client-manager timeout handler: after disconnecting an
  intentionally timed-out server, also drop it from `this.clients` and
  stop any pending health-check timer. Without this the discovery
  `finally` block would arm a health-check that detected DISCONNECTED
  status and called `reconnectServer()` → `discoverMcpToolsForServer()`
  directly — bypassing `runWithDiscoveryTimeout` entirely and silently
  resurrecting the slow server. `startHealthCheck` also early-returns
  for unknown servers so the trailing finally-block call is a no-op.

- startupEventSink: silent `catch {}` now logs via `debugLogger.error`
  so a corrupted sink doesn't silently drop every subsequent event.
  Quiet by default; visible under `QWEN_CODE_DEBUG=1`.

Tests:
- mcp-client-manager.test.ts: regression for the timeout → no-reconnect
  invariant (clients map purged + health-check timer absent).
- acpAgent.test.ts: per-session newSession surfaces failures to stderr,
  and stays safe when Config lacks `getFailedMcpServerNames`.

Declines (with reasoning in PR reply):
- [Critical] AppContainer batch-flush useEffect untested → re-flag of
  the round-5 deferral that wenshao acknowledged at the time. Lower-
  layer invariants (this PR's mcp-client-manager + mcp-client tests)
  pin the dependent contracts. The component-test harness for timers +
  event emitters in this file is non-trivial and out of scope; tracked
  for a follow-up.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-13 22:17:16 +08:00
jinye
4bb8dc894a
fix(telemetry): address PR #3847 review follow-ups for trace correlation (#4058)
* fix(telemetry): address PR #3847 review follow-ups for trace correlation

Addresses unresolved review feedback from PR #3847:

- Respect OTEL_TRACES_SAMPLER env var when setting TraceFlags on the
  synthetic session root, so custom samplers (e.g. traceidratio) are
  not bypassed by forced SAMPLED flag
- Store current session ID in session-context.ts and use it as a
  fallback in LogToSpanProcessor when the OTel Resource session.id
  attribute is stale after /clear or /resume
- Wrap for-await loop body in runInSpan() so debug logs emitted
  during stream iteration see the stream span as active
- Add autoOkOnSuccess option to withSpan, eliminating the need for
  the load-bearing setStatus(UNSET) hack in cancellation paths
- Add defensive 5-minute timeout for stream spans to prevent leaks
  from abandoned generators

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(telemetry): address review issues in PR #4058

- Fix sdk.test.ts assertion to match new two-arg setSessionContext call
- Add spanEnded guard to prevent double span.end() when timeout fires
- Add .trim() to OTEL_TRACES_SAMPLER env var for robustness
- Pass sessionId in debugLogger.test.ts for signature completeness
- Clarify log-to-span-processor comment: fallback covers "missing" not "stale"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(telemetry): adopt review feedback for sampler and idle timeout

- Fix shouldForceSampled to force SAMPLED for all parentbased_* samplers,
  not just parentbased_always_on — parentbased samplers delegate to
  localParentNotSampled (default AlwaysOff) when parent has NONE, which
  silently drops all traces
- Convert stream span timeout from fixed wall-clock to idle timeout:
  reset timer on each chunk so legitimately long streams are never
  affected; timeout only fires when no chunks arrive for 5 minutes
- Add test for parentbased_traceidratio → TraceFlags.SAMPLED

* fix(telemetry): guard resetSpanTimeout against already-ended span

Prevent zombie timer accumulation when chunks arrive after idle timeout
has already ended the span.

* fix(telemetry): handle parentbased_always_off sampler and fill test gaps

- shouldForceSampled() now returns false for parentbased_always_off,
  preventing silent over-sampling that contradicts user intent.
- Updated JSDoc to document the always_on exception for non-parentbased
  samplers.
- Added test for parentbased_always_off → TraceFlags.NONE.
- Added test for success path resilience when safeSetStatus throws in
  coreToolScheduler.

* fix(telemetry): harden span timeout ordering and session ID fallback

- Swap spanEnded/span.end() order so finally block can retry if end()
  throws.
- Clear idle timeout immediately after for-await loop exits, before
  post-loop processing.
- Use || instead of ?? for session.id fallback to handle empty strings.

* fix(telemetry): address review round 4 — docs, cleanup, and test gaps

- Expand shouldForceSampled JSDoc: document env-var assumption,
  parentbased_traceidratio 100% sampling semantics.
- Remove unnecessary runInSpan wrapper around chunk field assignments.
- Add stream.timed_out span attribute when idle timeout fires.
- Deduplicate config.getSessionId() call in initializeTelemetry.
- Add tests for always_on and always_off sampler values.

* fix(telemetry): harden timeout callback ordering and add || comment

- Move safeSetStatus before setAttribute in timeout callback so ERROR
  status is set even if setAttribute throws.
- Set spanEnded=true before span.end() so finally block never overwrites
  ERROR with OK if end() throws.
- Add comment explaining || vs ?? choice for session.id fallback.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 22:02:20 +08:00
ZevGit
97ac766405
fix(core): improve runtime fetch options error handling and documentation (#3997)
* fix(core): improve runtime fetch options error handling and documentation

- Add debug logging for dispatcher creation failures to prevent silent proxy bypass
- Skip preconnect dispatcher creation when no proxy is configured (optimization)
- Update outdated comments about timeout behavior (was "always disabled", now conditional)
- Add tests for catch block error handling fallback behavior
- Remove peer flag from @vscode/windows-process-cycles in package-lock.json

The original change skipped custom dispatcher when no proxy to avoid Node/undici
version mismatch (Node v26 ships undici v8 while project bundles v6). This follow-up
addresses code review findings:
1. Critical: catch block silently bypassed proxy on dispatcher failure
2. Suggestion: preconnect warmed unused dispatcher pool when no proxy
3. Suggestion: outdated comments about "always disabled" timeouts

* fix(core): address PR #3997 review comments

- Fix 14 failing apiPreconnect tests by passing proxy parameter
- Add console.error log for proxy dispatcher creation failure visibility
- Redact credentials from proxy URL in error messages
- Update outdated dashscope.ts timeout comment
- Add mockWarn assertions to dispatcher failure tests

* test(core): add console.error and credential redaction tests for dispatcher failure

- Add console.error call verification in dispatcher failure tests
- Add dedicated test for credential redaction from proxy URLs
- Mock console.error to prevent noise during test runs

* chore: improve code quality and documentation clarity

- Remove hardcoded version numbers from undici compatibility comment
- Add dual-logging rationale explaining debugLogger + console.error usage
- Document implicit coupling between apiPreconnect and buildFetchOptionsWithDispatcher
- Explain preconnectFired = true rationale for no-proxy case
- Add comment for preconnectFired before async fire (fire-and-forget semantics)
- Fix global.fetch mock leak by using vi.stubGlobal/unstubAllGlobals
- Remove duplicate test and rename misleading test name

* fix(core): align dashscope.ts comment and remove duplicate test

- Update dashscope.ts comment to match default.ts and anthropicContentGenerator.ts
- Remove misplaced duplicate test in getOrCreateSharedDispatcher describe block
  (functionally covered by existing test at line 72)

* fix(cli): restore URL validation and error path test coverage

- Add proxy parameter to 3 URL validation tests that were hitting no-proxy
  early return instead of exercising isDefaultBaseUrl logic
- Add proxy parameter to 2 error handling tests to ensure they exercise
  fetch rejection and dispatcher error paths
- Add mockFetch.mockResolvedValue(undefined) to beforeEach to prevent
  mock state leakage between tests
- Add mockDebugLogger.debug.mockClear() to beforeEach to prevent debug
  log assertions from being polluted by previous tests

* fix(core): harden proxy error handling and add timeout evaluation note

- Use greedy regex match (.+@) instead of non-greedy ([^@]*) for credential
  redaction to handle edge cases like user@domain:pass@proxy.local
- Add rejectedProxyCache Set to prevent duplicate error logging for the same
  broken proxy config across multiple API requests in long conversations
- Add resetRejectedProxyCache() export for test isolation
- Add timeout evaluation note documenting that Node.js built-in fetch uses
  default 300s bodyTimeout which is sufficient for all current model streaming
- Update test beforeEach to call resetRejectedProxyCache()

* fix(core): address final review feedback on credential redaction and JSDoc

- Restore safe regex /\/\/[^/\s]*@/g for credential redaction to avoid
  over-consuming hostname when error messages contain unrelated '@' chars
  and to redact all occurrences in multi-line error chains via /g flag
- Fix misplaced JSDoc: resetRejectedProxyCache and resetDispatcherCache
  now each have their own correct docstrings
- Add 2 tests to verify rejectedProxyCache dedup behavior:
  same proxy URL logs once, different proxy URLs each log separately

* fix(core): extract redactProxyCredentials helper with dedicated tests

- Extract redactProxyCredentials() as an exported pure function
  for testability and reusability
- Add 4 dedicated tests: single URL redaction, multi-URL /g coverage,
  non-over-redaction of unrelated @ chars, no-op for clean messages
- Add mockWarn/mockConsoleError cleanup to getOrCreateSharedDispatcher
  beforeEach to prevent test state leakage

* fix(core): harden credential redaction and cache key security

- Use redacted proxyUrl as cache key to avoid storing plaintext
  credentials in process memory (heap dump protection)
- Add fallback regex for scheme-less error messages (e.g., Node.js
  native 'connect ECONNREFUSED user:pass@host:8080')
- Add 4 tests: no-scheme redaction, double-redact prevention,
  cache key dedup with different credentials, mock expanded

* fix(core): address remaining review findings — mock alignment, code quality, test lifecycle

- Revert MockProxyAgent to fail only for syntactically invalid URIs
  (real ProxyAgent accepts credential URLs — includes('@') was wrong)
- Update credential redaction test to use http://invalid-proxy whose
  mock error message contains credentials to redact
- Extract NO_DISPATCHER_FALLBACK constant to replace duplicated ternary
  expression (sdkType === 'openai' ? undefined : {})
- Move vi.spyOn(console, 'error') into beforeEach with afterEach
  restoreAllMocks (consistent with apiPreconnect.test.ts pattern)
- Add preconnectFired verification test: no-proxy call permanently
  skips subsequent preconnect attempts

* fix(proxy): address PR #3997 review feedback

- Add credential redaction in apiPreconnect.ts catch blocks to prevent
  proxy credentials from being written to ~/.qwen/debug/ log files
- Export redactProxyCredentials from core package barrel for CLI reuse
- Fix environment variable tests (NODE_EXTRA_CA_CERTS,
  QWEN_CODE_DISABLE_PRECONNECT, SANDBOX) that were masked by no-proxy
  early return by adding proxy parameter
- Remove rejectedProxyCache deduplication to allow administrators to
  see each credential change failure; log only hostname to avoid
  credential leakage
- Add extractHostnameFromProxyUrl helper for safe hostname extraction

Verified: lint + build + typecheck + tests all pass

* fix(core): harden proxy credential redaction

Cover token-only and colon-containing proxy credentials in scheme-less error messages, keep hostname fallback logging credential-safe, and add failure counts for repeated proxy dispatcher failures.

Also align preconnect tests with the shared redaction helper and restore the unrelated fsevents lockfile peer marker.

* test(core): prevent proxy redaction overreach

Tighten scheme-less proxy credential redaction so ordinary email-like text is preserved while token and userinfo proxy credentials remain covered.

* fix(core): address proxy review edge cases

* docs(core): clarify proxy dispatcher behavior

* fix(core): redact proxy credentials from SDK errors
2026-05-13 21:52:08 +08:00
qqqys
15e6222546
fix(dashscope): use URL hostname check instead of regex to avoid ReDoS (#4112)
CodeQL flagged a polynomial regular-expression alert on the unanchored
/([\w-]+\.)?dashscope(-intl)?\.aliyuncs\.com/i pattern introduced in
#3991 — repeated '-' in the baseUrl could trigger catastrophic
backtracking. The unanchored regex was also too permissive: it would
incidentally match the dashscope domain appearing anywhere in a URL
path, not just in the hostname.

Parse the baseUrl with the URL constructor and compare hostname
suffixes instead. This eliminates the ReDoS surface entirely and is
strictly tighter — only real dashscope[-intl].aliyuncs.com hostnames
(and their subdomains) match.

Co-authored-by: Qwen-Coder <noreply@alibaba-inc.com>
2026-05-13 18:19:39 +08:00
tanzhenxin
5a1c427d6a
feat(subagents): use fastModel for Explore subagent (#4086)
Adds a "fast" keyword to the subagent model selector. When set, the
runtime resolves it via Config.getFastModel() under the parent authType,
falling back to inheriting the parent model when fastModel is unset or
invalid for the current authType. The built-in Explore subagent opts in,
so a configured fastModel automatically powers codebase searches without
affecting other subagents or the main session.
2026-05-13 16:27:25 +08:00
tanzhenxin
613fc42c89
fix(core): tag subagent OpenAI JSON logs (#4099) 2026-05-13 16:26:38 +08:00
Shaojin Wen
870bdf2a9d
feat(cli,sdk): qwen serve daemon (Stage 1) (#3889)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* feat(cli): scaffold `qwen serve` HTTP daemon (Stage 1, #3803)

Adds a `serve` subcommand that boots an Express 5 listener with bearer
auth, host allowlist, and CORS modeled on `vscode-ide-companion/src/
ide-server.ts`. Ships only `/health` and `/capabilities` to begin with;
session/prompt/event routes will land in follow-up PRs once the per-
session ACP child-process bridge in `httpAcpBridge.ts` is wired.

Defaults to 127.0.0.1 with auth disabled so local development needs no
configuration. Binding beyond loopback (e.g. `--hostname 0.0.0.0`)
refuses to start without a token (`--token` or `QWEN_SERVER_TOKEN`).

Capabilities envelope versioned at v=1 with a `features` array — clients
should gate UI off `features`, never off `mode`, so subsequent PRs can
add capability tags without breaking older clients.

Per design issue's Stage 1 scope (~700-1000 LOC). Adds ~430 LOC of
implementation + tests in this scaffold; the remaining budget belongs
to the route wiring + bridge implementation in follow-ups.

* feat(cli): wire HttpAcpBridge + POST /session for `qwen serve` (#3803)

Stage 1 follow-up to the scaffold. Implements the bridge between the
HTTP daemon and the existing ACP child agent, plus the first session
endpoint.

`HttpAcpBridge.spawnOrAttach`:
  - Spawns `node $cliEntry --acp` per workspace via an injectable
    `ChannelFactory` (default uses `process.argv[1]`; tests use an
    in-memory `TransformStream` pair so they don't fork real processes).
  - Drives the ACP `initialize` + `newSession` handshake via the SDK's
    `ClientSideConnection`, with a 10s timeout that kills the channel.
  - Under `sessionScope: 'single'` (default), reuses the live session
    when the same canonical workspace cwd is requested again — backs
    the `attached: true` flag.
  - The `Client` impl on the bridge side proxies file reads/writes to
    local fs (daemon and agent share the host) and buffers
    `sessionUpdate` notifications for the SSE wiring in the next PR.
    `requestPermission` returns `cancelled` until the
    `/permission/:requestId` route lands.

`POST /session`:
  - 400 on missing or relative `cwd`.
  - 200 with `{sessionId, workspaceCwd, attached}` on success.
  - 500 on bridge failure (the failing channel is killed, not leaked).

`runQwenServe` constructs the bridge and ties `bridge.shutdown()` into
the listener-close path so SIGINT/SIGTERM drain children before the
socket closes.

Tests (14 new, 0 regressions in the 4967-test baseline):
  - 9 bridge cases over an in-memory channel — fresh spawn, single-scope
    reuse, cross-workspace isolation, thread-scope independence, path
    canonicalization, relative-path rejection, init failure cleanup,
    init timeout, multi-channel shutdown.
  - 4 route cases for /session (missing/relative/200/500).
  - 1 lifecycle case asserting `runQwenServe.close()` calls
    `bridge.shutdown()` before closing the listener.

Verified end-to-end: `qwen serve` boots, `POST /session` spawns a real
`qwen --acp` child and returns the SDK-assigned `sessionId`, repeat
calls under the same cwd return `attached: true`, `SIGTERM` reaps the
child along with the listener.

* feat(cli): wire POST /session/:id/prompt + /cancel for `qwen serve` (#3803)

Stage 1 follow-up after the bridge scaffold. Adds the two routes a client
needs to actually run a turn against the daemon.

Bridge:
  - `sendPrompt(sessionId, req)` looks up the session, FIFO-queues the
    call against the per-session prompt queue, and forwards through the
    SDK `ClientSideConnection.prompt`. Concurrent calls observe ACP's
    "one active prompt per session" invariant — second waits for first.
  - A failed prompt does NOT poison the queue; the tail catches and
    keeps draining so the next caller still runs (the original caller
    still sees its own rejection).
  - `cancelSession(sessionId, req?)` bypasses the queue and forwards
    the ACP notification immediately. ACP semantics: the agent winds
    down the *currently active* prompt; queued work is unaffected.
  - Both methods throw `SessionNotFoundError` (a typed Error subclass)
    when the id is unknown so route handlers can map cleanly to 404
    without brittle message matching.
  - Both methods overwrite the `sessionId` field in the request body
    with the routing id — a stale or spoofed body would otherwise be
    dispatched to the wrong agent process.

Routes:
  - `POST /session/:id/prompt` → 200 with PromptResponse, 400 on
    missing/non-array prompt, 404 on unknown session, 500 on agent
    error.
  - `POST /session/:id/cancel` → 204 always (cancel is a notification),
    404 on unknown session.

Tests (14 new — 7 bridge + 7 route, 0 regressions in the 4981 baseline):
  - sendPrompt: success forwards & returns response · routing-id
    overrides body sessionId · concurrent prompts FIFO-serialize
    (verified via per-prompt start/end ordering with a release latch) ·
    failed prompt doesn't block subsequent prompts · 404 for unknown id.
  - cancelSession: forwards with routing id · 404 for unknown id.
  - Routes: 200/400/404/500 paths for prompt; 204 with body or empty +
    404 for cancel.

Verified end-to-end against a real `qwen --acp` child:
  - POST /session/:id/prompt with `[{type:'text',text:'hi'}]` → 200
    `{"stopReason":"end_turn"}` in ~3.4s.
  - POST /session/:id/cancel → 204.
  - POST /session/does-not-exist/prompt → 404 with the unknown id
    surfaced in the body.

* feat(cli): wire SSE streaming for `qwen serve` events (#3803)

Stage 1 follow-up that turns prompt into a real streaming experience.
Replaces the in-memory `notifications: SessionNotification[]` buffer
on each session with a per-session EventBus and exposes it through
`GET /session/:id/events` as an `text/event-stream` SSE feed.

EventBus (`packages/cli/src/serve/eventBus.ts`):
  - Monotonic per-session ids (`v: 1` schema). Each `publish` chains an
    id, returning the materialized BridgeEvent.
  - Bounded ring (default 1000) backs `Last-Event-ID` reconnect — a
    consumer that drops can resume from `lastEventId` and replay any
    still-buffered events before live events flow.
  - Per-subscriber bounded queue (default 256). When a slow consumer
    overruns its queue, the bus appends a synthetic `client_evicted`
    terminal frame and closes that subscription so it can't hold the
    daemon hostage. Other subscribers are unaffected.
  - `subscribe()` returns an AsyncIterable — registration is synchronous
    so events `publish`ed immediately after the subscribe land in the
    queue (a generator-style implementation deferred registration to
    first `next()` and raced with publishes).
  - AbortSignal-aware: aborting the signal closes the iterator promptly.

Bridge (`httpAcpBridge.ts`):
  - `BridgeClient.sessionUpdate` now publishes onto the session's
    EventBus instead of pushing to a plain array — every ACP
    notification the agent emits becomes a stream event automatically.
  - New `subscribeEvents(sessionId, opts?)` returns the bus's
    AsyncIterable; throws `SessionNotFoundError` for unknown ids.
  - Shutdown closes every live event bus before killing channels so
    pending consumers unwind cleanly.

Route (`server.ts`):
  - `GET /session/:id/events` sets the SSE content type, advertises a
    3s reconnect hint, and writes a 15s heartbeat comment frame to
    keep proxy/NAT connections alive.
  - Forwards the `Last-Event-ID` header to the bus.
  - `req.on('close')` triggers an AbortController that propagates into
    the bridge subscription so disconnects don't leak subscribers.
  - 404 when the bridge can't find the session.

Capabilities envelope: `STAGE1_FEATURES` now advertises
`session_create`, `session_prompt`, `session_cancel`, `session_events`
in addition to `health`/`capabilities` so clients can light up UI for
the routes that have actually shipped.

Tests (16 new, 0 regressions in the 4995 baseline):
  - 9 EventBus unit cases — id sequencing, live delivery, replay,
    replay+live splice, fan-out to N subscribers, eviction on
    overflow, abort-signal unsubscribe, bus.close() drains
    subscribers, ring-size eviction.
  - 4 bridge subscribe cases — 404, sessionUpdate→event publishing
    via real ACP fake-agent, shutdown closes live subscriptions.
  - 4 SSE route cases against a live HTTP listener — frame format,
    Last-Event-ID forwarding, 404, abort propagation on disconnect.

Verified end-to-end against a real `qwen --acp` child:
  - Subscribed to `/session/$SID/events`, fired `POST /session/$SID/prompt`
    with text content. Captured 13 distinct `event: session_update`
    SSE frames in real time during the model's response — `available_
    commands_update` metadata, 9 `agent_thought_chunk` frames carrying
    the model's chain-of-thought, 3 `agent_message_chunk` frames with
    the actual reply, and a final usage frame with token totals.
  - Frames carry monotonic ids 1..13, the daemon-side counter, and
    are valid SSE per the EventSource spec.

* feat(cli): wire POST /permission/:requestId for `qwen serve` (#3803)

Stage 1 follow-up that turns `BridgeClient.requestPermission` from a
hardcoded `cancelled` placeholder into a real first-responder vote
loop, and ships the HTTP route any attached client uses to cast the
deciding vote.

Bridge:
  - `requestPermission` generates a UUID requestId, registers a
    pending entry on a daemon-wide map (and the owning session's
    `pendingPermissionIds` set), publishes a `permission_request`
    event onto the session's EventBus (so SSE subscribers see it),
    and awaits the resolution.
  - New `respondToPermission(requestId, response)` resolves the
    pending promise with the supplied outcome. First call wins —
    subsequent calls return false. On success the bridge publishes a
    `permission_resolved` event so other attached clients can update
    their UI when the race is decided.
  - `cancelSession` and `shutdown` both resolve every still-pending
    permission for the affected session(s) as
    `{ outcome: { outcome: 'cancelled' } }` per the ACP spec
    requirement that a cancelled prompt MUST resolve outstanding
    requestPermission calls with cancelled.
  - New `pendingPermissionCount` getter exposes inflight count for
    inspection / tests.

Route (`server.ts`):
  - `POST /permission/:requestId` validates the body's `outcome` is
    either `{ outcome: 'cancelled' }` or `{ outcome: 'selected',
    optionId: string }`, then forwards to `bridge.respondToPermission`.
  - 200 on accepted vote, 404 when the requestId is unknown or
    already resolved (Stage 1 doesn't differentiate), 400 on a
    malformed outcome.

Capabilities envelope: STAGE1_FEATURES gains `permission_vote`.

Tests (14 new — 9 bridge + 5 route, 0 regressions in the 5011 baseline):
  - Bridge: publishes permission_request with a generated requestId
    and waits; respondToPermission first-responder wins; publishes
    permission_resolved on vote; respondToPermission false for
    unknown requestId; cancelSession resolves outstanding as
    cancelled; shutdown resolves outstanding as cancelled.
  - Route: 200 on selected outcome; 200 on cancelled outcome; 404 on
    unknown requestId; 400 on malformed outcome; 400 on missing
    outcome.

Verified end-to-end against a real `qwen --acp` child:
  - Subscribed to /session/$SID/events, sent a prompt asking the
    agent to write a file at /tmp/qwen-serve-permission-e2e-test.txt.
  - The agent triggered a permission_request via the bus, surfacing
    the three options Qwen Code presents (Allow Always / Allow /
    Reject) with their option ids.
  - POSTed `{outcome:{outcome:"selected",optionId:"proceed_once"}}`
    to /permission/$requestId — got HTTP 200.
  - Bus published the matching permission_resolved event.
  - Agent proceeded with the writeTextFile tool call; file was
    actually created on disk with the expected content.

* feat(sdk): add DaemonClient for the qwen serve HTTP API (#3803)

Stage 1 follow-up that proves the cross-mode protocol-isomorphism design
assumption: an SDK client can drive the daemon's HTTP routes end-to-end
without going through ProcessTransport's stdio + stream-json path.

DaemonClient is a sibling of ProcessTransport, not a replacement. The two
speak different protocols (ACP NDJSON over HTTP vs stream-json over
stdio). Existing `query()` users keep getting subprocess-mode unchanged;
applications that want daemon-mode (cross-client attach, shared MCP
pool, network reachability, first-responder permissions) opt in by
constructing a DaemonClient against a running `qwen serve`.

API surface (`packages/sdk-typescript/src/daemon/`):
  - `new DaemonClient({ baseUrl, token?, fetch? })`. The `fetch` override
    is for tests; defaults to `globalThis.fetch`. Trailing slashes on
    `baseUrl` are stripped.
  - `health()`, `capabilities()` — discovery.
  - `createOrAttachSession({ workspaceCwd, modelServiceId? })` — `attached:
    true` on the response indicates a session was reused under
    sessionScope:single.
  - `prompt(sessionId, { prompt: ContentBlock[] })` — returns
    PromptResult with stopReason.
  - `cancel(sessionId)` — tolerates 204; throws on 404.
  - `subscribeEvents(sessionId, { lastEventId?, signal? })` — async
    iterator over parsed SSE frames; AbortSignal-aware. Native Node
    AbortController only — jsdom polyfills are incompatible with undici.
  - `respondToPermission(requestId, response)` — first-responder vote;
    returns true on 200, false on 404 (lost the race or unknown id),
    throws on 400/500.

`DaemonHttpError` is thrown for any non-2xx (besides the 404
"already-resolved" case on permission votes); carries `status` and
`body` so callers can branch on standard daemon HTTP semantics.

`parseSseStream(body)` is the underlying SSE parser; exported separately
so applications can consume daemon SSE outside the DaemonClient surface.
Handles split-chunk frames, comment/retry directives, malformed JSON
(skip), trailing frame without final newline.

Wire types live SDK-side (no SDK→CLI dep); the capabilities envelope's
`v` field signals breaking changes.

Tests (26 new, 0 regressions in the 201 baseline):
  - 7 SSE parser cases — single frame, multiple frames, comments,
    chunked-split frame, malformed JSON skip, trailing frame on close,
    empty stream.
  - 19 DaemonClient cases — health success/error, capabilities, bearer
    auth presence/absence, createOrAttachSession success/400, prompt
    body shape + sessionId url-encoding, cancel 204/404, permission
    200/400/404, subscribeEvents header forwarding + 404, baseUrl
    normalization.

Verified end-to-end against a real `qwen serve` daemon driving a real
`qwen --acp` child:
  - `client.capabilities()` returned `{v:1, mode:"http-bridge", features:
    [...7 tags]}`.
  - First `createOrAttachSession` returned `attached:false`; second
    returned `attached:true` with the same sessionId.
  - `client.prompt(...)` with text content yielded `{stopReason:
    "end_turn"}` while the parallel `subscribeEvents` iterator streamed
    10 distinct frames during the same turn.
  - AbortController on the events iterator cleanly severed the SSE
    connection.

* feat(cli,sdk): list workspace sessions + set session model (#3803)

Closes the §04 Stage-1 routes table for `qwen serve` with the two
remaining endpoints, plus matching SDK methods.

`GET /workspace/:id/sessions`
  - `:id` is the URL-encoded canonical absolute workspace path
    (Express decodes path params automatically; clients pass
    `encodeURIComponent(cwd)`).
  - Returns `{ sessions: [{ sessionId, workspaceCwd }, ...] }` for live
    sessions whose canonical workspace matches.
  - Empty array (not 404) when the workspace is idle so picker UIs
    don't have to special-case "no sessions yet".
  - 400 when the decoded path isn't absolute.

`POST /session/:id/model`
  - Body: `{ modelId: string, ... }`. The route's `:id` overrides any
    spoofed sessionId in the body.
  - Forwards to ACP's `unstable_setSessionModel` and publishes a
    `model_switched` event onto the session bus so cross-client UIs
    update.
  - 200 with the agent's response on success, 400 on missing/empty
    modelId, 404 on unknown session.
  - The SDK method is currently unstable; documented in the bridge
    comment in case the spec renames the method when it stabilizes.

Bridge:
  - New `listWorkspaceSessions(workspaceCwd)` iterates `byId.values()`
    and filters by canonical workspace path; works for both `single`
    and `thread` session scopes.
  - New `setSessionModel(sessionId, req)` forwards through
    `connection.unstable_setSessionModel`, normalizes sessionId,
    publishes `model_switched`, throws SessionNotFoundError on
    unknown ids.

`STAGE1_FEATURES` capabilities envelope grows to 9 tags, adding
`session_list` and `session_set_model`.

SDK (`DaemonClient`):
  - `listWorkspaceSessions(workspaceCwd)` URL-encodes the cwd and
    returns the parsed `sessions` array directly.
  - `setSessionModel(sessionId, modelId)` POSTs the body and returns
    the agent response (currently opaque per ACP unstable spec).
  - Wire types `DaemonSessionSummary` and `SetModelResult` exported
    from the SDK barrel.

Tangential cleanup: `sendBridgeError` now extracts a useful message
from non-Error values via a small `errorMessage` helper. JSON-RPC
errors from the agent (`{code, message, data}`) used to surface as
`"[object Object]"` in the 500 response body; they now show the
inner `message` field. Caught while running the model-set e2e.

Tests (17 new — 9 bridge + 7 route + 4 SDK, 0 regressions in the
5022 + 227 baselines):
  - Bridge listWorkspaceSessions: matching cwd returns the live
    sessions; canonicalizes the lookup; empty for relative paths.
  - Bridge setSessionModel: forwards modelId + overrides body
    sessionId; publishes model_switched event; 404 unknown session.
  - Route /workspace/:id/sessions: returns the bridge list; empty for
    idle workspace; 400 for relative path.
  - Route /session/:id/model: 200 success; 400 missing modelId; 400
    empty modelId; 404 unknown session.
  - SDK listWorkspaceSessions: URL-encodes the cwd; throws on 400.
  - SDK setSessionModel: posts body; throws on 404.

Verified end-to-end against a real `qwen serve`:
  - SDK reports 9 capability features, list returns the existing
    session, attached:true on repeat create, and `setSessionModel`
    rejects with HTTP 500 when the modelId isn't registered (with the
    daemon now surfacing "Internal error" instead of "[object Object]").
  - 404 path through SDK on unknown sessionId works.

* fix(cli,sdk): audit round 1 follow-ups for `qwen serve` (#3803)

Self-review pass on PR #3889. Two real correctness bugs and an
ergonomics gap, plus the test-coverage holes the audit surfaced. The
loudest finding ("host allowlist no-op when bind=localhost") was a
false positive — the conditional was misread; existing tests already
prove the validator is active on `localhost` binds.

Real fixes:

  - Bearer-auth timing-attack: `parts[1] !== token` short-circuits per
    byte, leaking which prefix is correct via response latency. Replace
    with SHA-256 of both sides + `crypto.timingSafeEqual` so comparison
    is constant-time regardless of token length.

  - Concurrent `spawnOrAttach` race in single-scope: two parallel
    callers for the same workspace both passed the `byWorkspace.get`
    check, both spawned, and one entry ended up orphaned in `byId`
    while the other won `byWorkspace`. Violates the
    "at most one session per workspace" invariant. Coalesce via an
    `inFlightSpawns` map: parallel callers attach to the in-flight
    promise and report `attached: true`. The slot is cleared on both
    success and rejection so a failed spawn doesn't poison the
    workspace forever. New test asserts ONE channel spawns under
    parallel calls and that retry works after rejection.

  - `Number.parseInt('1.5e10z', 10)` returns 1, so a malformed
    `Last-Event-ID` header silently passes through. Tighten
    `parseLastEventId` to `^\d+$` so anything not a pure decimal
    integer is dropped. New test exercises 'abc', '-1', '1.5e10z'.

Ergonomics:

  - `LOOPBACK_BINDS` and `LOOPBACK_HOST_BINDS` now include `::1` and
    `[::1]`. IPv6 loopback users no longer have to set a token.
    Host-allowlist allows `[::1]:port` Host headers.

Documentation:

  - `BridgeClient` doc-comment now states the Stage 1 trust model
    explicitly: agent runs as the same UID, the file-proxy methods
    are NOT a workspace-cwd sandbox, restricting them would be
    theatre. The audit flagged this as a "design gap" but the
    daemon-and-agent-on-same-host posture makes a sandbox here
    redundant — Stage 4+ remote-sandbox swaps the Client for a
    sandbox-aware variant.

SDK fix:

  - `DaemonClient.failOnError` previously called `res.json()`, which
    consumes the body even on parse-failure; the subsequent
    `res.text()` returned empty. New impl reads once as text and
    attempts JSON-parse; raw text is the fallback. New test asserts
    a `text/plain` 502 surfaces the body verbatim.

Test gap fills (audit-flagged):

  - Bridge: in-memory file-proxy tests for `BridgeClient.{read,write}
    TextFile` including line/limit slicing.
  - SSE route: `stream_error` synthetic frame on iterator throw
    mid-stream; numeric Last-Event-ID forwarded; malformed
    Last-Event-ID dropped.
  - DaemonClient: text/plain error body coerced to `body` field;
    `respondToPermission` 5xx throws; `subscribeEvents` null-body
    throws; `cancel`/`respondToPermission` URL-encode session/request
    ids that contain slashes.

Verified end-to-end with a token-required daemon: right token → 200,
wrong/missing/malformed → 401. All paths return uniform 401 messages
so a side-channel can't distinguish between "no header", "bad scheme",
and "wrong token".

Test counts: cli serve **89** (was 81, +8), sdk daemon **35** (was
30, +5). Full suites still green.

* fix(cli): audit round 2 follow-ups for `qwen serve` (#3803)

Second self-review pass on PR #3889. Three real bugs (one
correctness, one resource-cleanup, one cosmetic) plus consolidation
of the loopback bindings into a single source of truth.

Real fixes:

  - Shutdown could hang forever on a long-lived SSE consumer:
    `server.close` waits for every in-flight connection to drain,
    and a paused EventSource client never disconnects. Added a
    `SHUTDOWN_FORCE_CLOSE_MS` (5s) timer that calls
    `server.closeAllConnections()` to force-destroy stuck sockets,
    then resolves so `process.exit(0)` can run. New test asserts
    close completes well under 5.5s even when an SSE GET is in
    flight.

  - Signal-handler race during shutdown: round 1 detached the
    SIGINT/SIGTERM listeners *up front* in `handle.close()`. If a
    second SIGTERM arrived during the drain, no handler existed and
    Node's default termination ran, orphaning agent children. Switch
    to detaching at the *end* of the close path (in `finish()`):
    during the drain window the handler is still attached and the
    `if (shuttingDown) return` guard makes a second signal a no-op;
    after drain completes we can safely remove the listeners (this
    also fixes a test-suite MaxListenersExceededWarning that fired
    once we ran the runQwenServe tests >10 times in a single
    process).

  - SSE response had no `error` listener. When the underlying TCP
    socket died (RST, kill -9 on the client), the next `res.write`
    threw EPIPE and Express forwarded it to the default error
    handler, logging noisily. Added `res.on('error', cleanup)` so
    the failure is absorbed and triggers the same teardown path the
    `req.on('close')` handler uses.

Validation:

  - `createHttpAcpBridge` now throws on invalid `sessionScope` (anything
    other than `'single'` or `'thread'`) and on `initializeTimeoutMs <= 0`.
    Misconfigured callers used to silently degrade to thread behavior;
    now they fail loudly.

Cleanup:

  - The `LOOPBACK_BINDS` set was duplicated between `auth.ts` and
    `runQwenServe.ts` (round 1 missed this). Extracted into
    `packages/cli/src/serve/loopbackBinds.ts` with a single
    `isLoopbackBind(hostname)` helper. Both files now import; drift is
    impossible.

  - `res.flushHeaders?.()` lost the optional chaining. The method is
    on `http.ServerResponse` since Node 1.6; our `engines` floor is 20.

Tests added:

  - bridge: `sessionScope` validation, `initializeTimeoutMs` validation.
  - server: shutdown force-close timeout, SIGINT/SIGTERM listener
    detach-after-drain.

False positives from the round 2 audit (verified and dismissed):

  - "EventBus nextId overflow at 2^53" — theoretical only (would
    require ~9 quadrillion publishes per session). No code change.
  - "Subscribe-during-close race" — JS is single-threaded; the close()
    flag is set synchronously before the loop touches state.
  - "Queued prompts on shutdown" — by design; documented via the
    promptQueue tail comment.
  - "10MB body parser limit" — design choice for Stage 1's in-memory
    buffering model; revisit if ACP streaming lands in Stage 2.
  - "Unbounded body read in DaemonClient.failOnError" — daemon is
    local in Stage 1; the threat surface for adversarial-large error
    bodies is the same as the daemon's other unbounded buffers.

Test counts: cli serve **93** (was 89, +4), full cli **5047** (no
regressions), sdk **236** (no regressions).

* docs(cli): audit rounds 3 + 4 follow-ups for `qwen serve` (#3803)

Two more self-review passes on PR #3889. No correctness bugs surfaced
this time — round 3 found a HIGH-severity Windows-path claim that
turned out to be a false positive (`path.win32.isAbsolute('/foo/bar')`
returns true; verified against Node 20). Round 4 confirmed every
prior decision and surfaced one latent-but-not-currently-triggered
concurrency note.

Changes are pure documentation + a tiny optional-chain cleanup:

  - Drop `?.` on `server.closeAllConnections()` in runQwenServe.ts —
    the method exists since Node 18.2 and our `engines` floor is 20.
    The optional chain dated from before round 2's force-close timer
    landed; clean it up.

  - Help text for `qwen serve --port` now documents that port 0 means
    "OS-assigned ephemeral port" (which the implementation has always
    supported but never advertised).

  - `defaultSpawnChannelFactory` gains a comment near the spawn site
    documenting the FD-budget implication (~3 FDs per session, bump
    `ulimit -n` for many concurrent sessions) and the `stdio:
    ['pipe', 'pipe', 'inherit']` choice (child stderr lands in the
    daemon's stderr, interleaved across sessions). Both are
    Stage-1-accepted; Stage 2/4+ revisit each.

  - Comment on the bridge's `byWorkspace`/`byId` Maps documenting the
    known gap that a child crashing between requests leaves a garbage
    SessionEntry until daemon shutdown — surfaced as a per-prompt
    failure when the dead session is touched, not a hang. Stage 2's
    in-process bridge eliminates the spawned-child failure mode
    entirely so this gap goes away naturally.

  - `EventBus.subscribe` doc-comment now states explicitly that the
    returned iterator is NOT safe to drive from concurrent
    `.next()` callers — the underlying queue isn't atomic. Daemon
    usage is the sequential `for await ... of` inside the SSE route,
    so this is safe in production. Documented so a future fan-out
    consumer doesn't accidentally rely on undefined behavior.

False positives verified and dismissed (round 3 + 4 combined):

  - `path.isAbsolute('/foo/bar')` Windows breakage — `path.win32.
    isAbsolute('/foo/bar')` is true; verified empirically.
  - "Windows drive divergence" causing duplicate sessions — different
    drives are different on-disk paths; sessions intentionally
    differ.
  - "parseSseStream early-break leaks reader" — `for await ... break`
    triggers `iterator.return()` which runs the generator's `finally`
    that calls `releaseLock`. Standard JS semantics.
  - "Promise executor sync-throw fragility in requestPermission" —
    sync throws inside `new Promise(executor)` reject the outer
    promise; functionally correct, just stylistic.
  - "Force-close timeout test elapsed assertion flakiness" — assertion
    is `< 5500ms` but the natural happy-path is sub-100ms. Generous
    headroom; not flake-prone in practice.
  - "fetch reference stale after polyfill" — `globalThis.fetch.bind`
    captures at construction; tests inject `opts.fetch` instead of
    polyfilling, which is the correct pattern.

Test counts unchanged (cli serve **93**, sdk **236**); typecheck +
lint clean. STAGE1_FEATURES still matches every implemented route
1:1, fakeBridge in tests implements every HttpAcpBridge method.

* fix(cli): PR #3889 review round 1 — critical correctness (#3803)

Addresses the four critical findings from the PR #3889 reviewer pass:

  1. ACP `ReadTextFileRequest.line` is 1-based per spec, but the
     bridge's `BridgeClient.readTextFile` was treating it as a
     0-based slice index. A client asking for `{line:1, limit:2}`
     ("first two lines") was getting lines 2-3 — a sign-off-by-one
     bug that breaks every editor / SDK client following the ACP
     schema. Convert to 0-based via `Math.max(0, line - 1)`. The
     existing slice test was asserting the wrong behavior; updated
     to expect the spec-correct result and added a second `line:3,
     limit:2` case to lock in the offset.

  2. `modelServiceId` was accepted by the SDK + server `POST /session`
     path, forwarded into `bridge.spawnOrAttach`, and then silently
     dropped: `doSpawn` never wired it into the agent. Callers
     requesting a specific model got the agent's default and no
     indication anything was wrong. Now `doSpawn` issues
     `unstable_setSessionModel` immediately after `newSession`. If
     the agent rejects the model id, the half-initialized session is
     torn down and the spawn rejects so the caller can retry cleanly
     instead of inheriting silent drift. Three new bridge tests:
     happy path, omit-when-undefined, agent-rejection cleanup.

  3. The CORS middleware used `cors({ origin: (o, cb) =>
     cb(new CORSError(...), false) })` for browser-Origin requests.
     `cors` flows the Error into Express's error chain; without an
     explicit error handler that produces a 500 + HTML body, which
     is misleading for what is really a deterministic 403 denial.
     Replace with a tiny `RequestHandler` that checks
     `req.headers.origin` directly and returns
     `403 { error: 'Request denied by CORS policy' }` JSON. Drops
     the `cors` and `@types/cors` dependencies — there's no other
     consumer in the cli package.

  4. The SSE `stream_error` synthetic frame hard-coded `id: 0`,
     which would regress the client's `Last-Event-ID` tracker and
     trigger duplicate replays on reconnect. The frame is terminal
     and daemon-emitted — it has no place in the per-session
     monotonic sequence. Refactor `formatSseFrame` to omit the
     `id:` line when the input event has no id field, and emit
     `stream_error` without one. Test updated to assert
     `frames[1].id === undefined` while the preceding
     `session_update` still carries its monotonic id.

Tangential cleanup: `errorMessage` now formats the SSE error body
(was `err.message` only — would have shown `[object Object]` for
JSON-RPC errors mid-stream, mirroring the round-1 SDK fix).

Test counts: cli serve **96** (was 93, +3 modelServiceId cases);
existing readTextFile slice test rewritten in place. Full
typecheck + lint + suite green.

* fix(cli,sdk): PR #3889 review round 2 — SSE robustness + EventBus polish (#3803)

Second batch of reviewer-flagged fixes for PR #3889. Addresses 7
robustness issues across the daemon's SSE pipeline + the bus + the
SDK's stream parser.

Daemon SSE (`server.ts`):

  - SSE writes now respect backpressure. `res.write` returns false when
    the kernel send buffer is full; the previous code ignored that and
    Node accumulated payloads in user-space memory unboundedly. A slow
    consumer on a chatty session could balloon daemon RSS. New
    `writeWithBackpressure` helper awaits `drain` (or `close`/`error`)
    before scheduling the next write — for both per-frame writes and
    heartbeats.

  - `parseLastEventId` rejects values > `Number.MAX_SAFE_INTEGER`. With
    the prior `^\d+$` regex a malicious 25-digit value would parse to
    a number that loses precision and confuses replay comparisons.

EventBus (`eventBus.ts`):

  - `Last-Event-ID` replay events now `forcePush` past `maxQueued`. A
    client reconnecting with a 1000-event gap on a subscriber whose
    cap is 256 was silently losing entries 257-1000 — a sign-off-by-
    nothing breakage of the resume contract. Live publishes still go
    through the normal cap (slow live consumer must be evictable);
    historical replay is bypassed.

  - `onAbort` now disposes the subscription immediately instead of
    only closing the queue. An aborted-but-never-iterated subscriber
    used to linger in `bus.subs` until the consumer drove `next()` /
    `return()`. New tests cover both abort-after-subscribe and
    already-aborted-at-subscribe paths.

  - `BoundedAsyncQueue.next` now checks `buf.length > 0` before
    shifting instead of `buf.shift() !== undefined`. The bus never
    pushes `undefined` today but the queue is generic — the prior
    pattern would mis-handle a queue whose element type legitimately
    includes undefined.

SDK SSE parser (`sse.ts`):

  - Now flushes the TextDecoder on stream close. Without the final
    `decoder.decode()`, an incomplete multi-byte UTF-8 sequence at
    the tail of the last chunk was silently dropped — corrupting any
    frame whose JSON ended mid-character. New test feeds a stream
    split mid-byte through "中" (3-byte UTF-8) and asserts the
    character round-trips.

  - Frame separators now accept both `\n\n` and `\r\n\r\n`. SSE spec
    allows CRLF, and intermediaries (corporate proxies, some Node
    http servers) sometimes normalize. Frame field splitter also
    accepts `\r?\n`. Two new tests cover pure CRLF + mixed-LF/CRLF.

Test counts: cli serve **99** (was 96, +3 EventBus); sdk daemon-sse
**10** (was 7, +3). Full typecheck + lint + suite green.

* docs(cli,sdk): PR #3889 review round 3 — minor + docs (#3803)

Last batch from the PR #3889 reviewer pass: mostly docs + a
ReDoS-tooling-silencing rewrite + a yargs-key cleanup.

  - `commands/serve.ts` ServeArgs interface dropped the camelCase
    `httpBridge` mirror; the handler now reads `argv['http-bridge']`
    matching the declared option name. The dual surface relied on
    yargs's camelCase expansion behavior — fragile if yargs config
    ever changes.

  - `DaemonClient` constructor's `baseUrl.replace(/\/+$/, '')` (which
    is end-anchored and linear, but CodeQL's polynomial-regex
    detector flags any `\/+$` pattern on attacker-controlled input)
    swapped for a hand-rolled `stripTrailingSlashes` loop. Same
    behavior, no rule trigger.

  - `defaultSpawnChannelFactory`'s `cwd: workspaceCwd` flow into
    `spawn` is the second CodeQL finding ("uncontrolled data used in
    path expression"). It IS user-controlled, by design — that's the
    Stage 1 trust model. Added a `// lgtm[js/shell-command-
    constructed-from-input]` suppression with a comment explaining
    the model and pointing at issue #3803 §11 for the Stage 4+ remote-
    sandbox replacement.

  - Stale doc comment on `createServeApp` that still listed only
    `/health`, `/capabilities`, `POST /session` as shipped — now
    enumerates all 9 routes that match §04 of the design.

  - Stale doc comment on `HttpAcpBridge` saying "Stage 1 buffers them
    in-memory; SSE wiring lands in the next PR" — SSE wiring landed
    in commit 41aa95094. Replaced with a description of the actual
    flow through EventBus + SSE.

No behavior change; tests + lint + typecheck still green. cli serve
still **99**, sdk **38** (was 30 before this batch — daemon-sse +3,
DaemonClient +5 from rounds 1+2). Full e2e against built daemon
re-verified: CORS denial returns 403 JSON (was 500 HTML), bad
`modelServiceId` now causes spawn to fail with HTTP 500 (was: silent
default-model substitution), `POST /session` without modelServiceId
unaffected.

* fix(cli,sdk): self-audit round 5+ — close orphaned EventBus + DaemonEvent.id optional (#3803)

Two more fixes from a final post-review-comment audit pass on PR #3889.
Both are subtle correctness gaps that fell out of the round-1 critical
fixes (modelServiceId apply + SSE id-less stream_error).

  - In `httpAcpBridge.ts:doSpawn`, when `unstable_setSessionModel`
    rejects after `newSession` succeeded, we tear down the entry from
    `byWorkspace` + `byId` (round 1 fix) but did NOT close the
    EventBus we'd just constructed for that entry. The agent could
    have published a session_update notification during init that
    queued in the (now unreachable) bus's ring buffer; without an
    explicit close the bus + buffer linger until the next GC cycle.
    Bounded leak (1 bus per failed spawn × 1000-event ring) but
    cleaner to close it. New regression test exercises the retry path
    after a model-rejection failure to lock in that we don't reuse
    the orphan and that subscribers on the fresh session see an empty
    iterator on immediate abort.

  - SDK `DaemonEvent.id` is now `id?: number` instead of `id: number`.
    The round-1 SSE fix made the daemon emit `stream_error` frames
    *without* an `id:` line so they don't pollute the per-session
    monotonic sequence. The SDK parser correctly returns `undefined`
    for the missing field, but the type still advertised `id: number`
    — TypeScript consumers persisting `lastSeenId = event.id` would
    accidentally store `undefined`. Made the field optional and added
    a doc comment instructing consumers to skip frames without an id.

Plus one more false-positive verified and dismissed:

  - "writeWithBackpressure Promise double-settle race": the auditor
    flagged that `res.write(chunk, callback)` could fire its callback
    after the synchronous `ok=true` resolve. Verified harmless —
    Promise double-settle is a no-op, the callback only rejects on
    error (caught separately by `res.on('error', cleanup)`), and
    multiple parallel writes register independent listener sets that
    each remove their own pair after firing.

Test counts: cli serve **100** (was 99, +1 retry-after-model-rejection
regression). SDK unchanged at 239. Full typecheck + lint + suites
green; flow re-verified end-to-end.

* fix(cli,sdk): PR #3889 review round 4 — child-crash recovery + SSE/permission/SSE polish (#3803)

Fourth and final batch of reviewer-flagged fixes for PR #3889. 14
inline threads addressed, plus 8 spam threads up for resolution.

Critical correctness:

  - `eventBus.test.ts`'s ring-eviction test wrapped its assertion in a
    `void (async () => { … })()` IIFE that returned synchronously to
    vitest — the inner `expect` could fail without ever surfacing.
    Hoisted to a top-level `await` so the harness actually waits and a
    broken eviction would now fail loudly.

  - `runQwenServe.ts handle.close()` is now idempotent. Concurrent
    callers (test harness + signal handler firing simultaneously,
    explicit caller + finally-block fallback) used to each construct a
    new shutdown promise, arm a fresh force-close timer, and call
    `bridge.shutdown` redundantly. Cache a single `closePromise`;
    repeat calls return it. New test exercises 3 overlapping callers
    + a post-settle call → exactly one bridge.shutdown.

  - `POST /permission/:requestId` now rejects `outcome.selected` with
    an empty `optionId`. The string-typeof check passed `""` through;
    bridge would forward an opaque "unknown option" error from the
    agent. Tighten the validator + add a 400 test.

  - `denyBrowserOriginCors` now has explicit unit tests (3 cases:
    Origin-bearing GET → 403 JSON, no-Origin GET → 200, Origin-bearing
    POST → 403 + bridge untouched). The CSRF defense was previously
    implicit-only.

Channel-exit recovery:

  - `AcpChannel` interface gains an `exited: Promise<void>` that
    resolves on either planned `kill()` or unexpected child crash.
    Bridge subscribes via `channel.exited.then(...)`: if the entry is
    still in `byId` when exit fires (i.e. unexpected crash), it
    cancels pending permissions, publishes a `session_died` event so
    SSE subscribers get notified, closes the bus, and removes the
    entry from `byWorkspace`/`byId`. Without this, a crashed child
    used to leave its `SessionEntry` stuck — under
    `sessionScope:'single'` (default) the whole workspace was
    unreachable until daemon restart.

  - `defaultSpawnChannelFactory` now wires `child.once('error', …)` in
    addition to `'exit'`. Without an `error` listener Node treats an
    async spawn failure (ENOMEM, EACCES, …) as an unhandled error and
    crashes the daemon.

  - Two new bridge tests: `crash()` simulates an unexpected exit →
    asserts `session_died` event + entry removed + retry spawns a
    fresh child; planned shutdown asserts the cleanup handler no-ops
    when the entry is already gone (no double-publish).

SSE robustness:

  - SDK `parseSseStream` now calls `reader.cancel()` (not just
    `releaseLock`) in its `finally`. Early-break consumers were
    leaving the underlying HTTP body stream open; cancel propagates
    upstream so the connection drops promptly. New test asserts the
    underlying ReadableStream's `cancel()` runs.

  - SDK `parseSseStream` accepts `data:` (no space after colon) AND
    multiple `data:` lines per frame (joined by `\n` per spec). Two
    new tests cover both cases.

  - SDK `DaemonClient.subscribeEvents` now validates response
    Content-Type before delegating to the parser. A misconfigured
    proxy returning 200 + JSON was silently producing zero events;
    now throws `DaemonHttpError` with the actual mime type.

  - Daemon SSE route's initial `retry: 3000` write now `.catch(()=>{})`s.
    A socket that errors before the first write would have surfaced as
    an unhandled rejection.

Documentation (deferred items now noted in code):

  - `EventBus.publish` ring shift is O(n) when full. Comment notes
    the deferral; circular-buffer refactor only if profiling flags it.

  - SSE heartbeat doesn't detect dead connections without TCP RST.
    Comment notes Stage 2 may add an explicit idle timeout.

  - `defaultSpawnChannelFactory` won't run a `.ts` entry directly —
    `npm run dev` users must build first. Comment in the spawn site.

Test counts: cli serve **107** (was 100, +7), SDK daemon **42**
(was 38, +4). Full typecheck + lint + suite green.

* test(integration): qwen serve daemon — routes + streaming + recovery (#3803)

Persists the e2e validation of every PR #3889 fix as vitest
integration tests under `integration-tests/cli/`. Two files split by
auth requirement:

`qwen-serve-routes.test.ts` (18 cases, no LLM credential needed)
  - Bearer auth timing-safe compare: right token / wrong-same-length /
    wrong-shorter / missing / Basic-scheme.
  - CORS browser-Origin denial: GET-with-Origin → 403 JSON; no-Origin
    → 200.
  - Capabilities envelope: all 9 Stage 1 features advertised in order.
  - POST /session validation: relative cwd → 400; two parallel POSTs
    same workspace coalesce; bad modelServiceId tears down half-init.
  - POST /permission/:requestId validation: empty optionId → 400;
    missing optionId → 400; valid vote on unknown id → 404.
  - SDK SSE Content-Type guard: throws DaemonHttpError when upstream
    returns 200 + JSON.
  - Last-Event-ID strict parsing: malformed value accepted but
    ignored (`'1abc'` doesn't get parsed as 1).
  - Cancel idempotent + listWorkspaceSessions returns the live session.

`qwen-serve-streaming.test.ts` (3 cases, gated by SKIP_LLM_TESTS)
  - Real `qwen --acp` child SIGKILL → daemon publishes
    `session_died`, removes the entry from `byWorkspace`/`byId`,
    next createOrAttachSession spawns fresh. Uses `pgrep -P` to
    locate the daemon's direct child by PID.
  - Two SSE subscribers + a tool requiring permission: both observe
    the same `permission_request` requestId; two concurrent POST
    votes resolve as exactly one 200 + one 404 (first-responder
    wins).
  - SSE reconnect with `Last-Event-ID: N` after consuming N frames
    yields events with `id > N` from the bus's replay ring.

Both files spawn `node packages/cli/dist/index.js serve --port 0
--token …` per `beforeAll` and clean up in `afterAll`. Use the
existing `@qwen-code/sdk` alias the integration-tests vitest config
already wires to the built SDK bundle.

Run with the existing `npm run test:integration:cli:sandbox:none`
(or any of the integration-tests target). The streaming file is
skip-able via `SKIP_LLM_TESTS=1` for environments without auth.

Verified locally: 18/18 routes pass in ~6.8s; 3/3 streaming pass in
~23s against a real model.

* fix(cli): PR #3889 review round 5 — claude-opus-4-7 audit (#3803)

Seven new substantive findings from a `/qreview` pass on PR #3889.
Six real bugs + one type-safety gap; all addressed.

Critical correctness:

  - **EventBus replay overflow + eviction race**. Round 4's
    `forcePush` for `Last-Event-ID` replay bypassed the per-subscriber
    cap, but `BoundedAsyncQueue.push`'s cap check was `buf.length >=
    maxSize` — so the very next live publish saw the inflated buf,
    rejected, and triggered the `client_evicted` terminal frame.
    Concrete sequence the audit walked through: client reconnects
    after 300+ events, replay force-pushes 300 entries, next live
    event evicts them. Defeats the resume contract.

    Fix: track force-pushed items separately (`forcedInBuf` counter).
    `push()` cap is now on `(buf.length - forcedInBuf)`. `next()`
    decrements `forcedInBuf` as the consumer drains (force-pushed
    entries are FIFO at the front of `buf` since `forcePush` only
    runs at subscribe time, before any live `push`). Two new
    regression tests: (1) live publish after a >cap replay does
    NOT evict; (2) eviction triggers only after the LIVE backlog
    (excluding replay) hits the cap.

Performance + UX:

  - **Eager express import on every `qwen` invocation**. The
    `serve` subcommand statically imported `../serve/index.js`,
    which transitively pulled express + body-parser + qs into
    cold-start path of every CLI invocation (interactive, mcp,
    channel, etc). ~50ms tax on the 99% of invocations that never
    run `serve`. Defer to dynamic `import()` inside the handler;
    types are still imported for the builder shape.

  - **Middleware order**: `express.json({limit:'10mb'})` ran
    BEFORE `bearerAuth`. Unauth POST got full JSON.parse before
    401. Trivial DoS amp on non-loopback deployments. Reorder so
    auth + Host allowlist + CORS run first; body parser runs
    only for requests that pass the gate.

  - **`sendPrompt` no AbortSignal**. A stuck/dead child poisons
    the per-session FIFO; HTTP client disconnect didn't propagate
    so daemon CPU stayed tied up. `HttpAcpBridge.sendPrompt` now
    accepts `signal?: AbortSignal`. Route handler creates an
    AbortController and wires `req.on('close')` to abort it. On
    abort, bridge sends an ACP `cancel` notification; the agent
    winds down → prompt resolves with `stopReason: 'cancelled'`
    → next queued prompt can run. New test exercises real
    socket disconnect via `node:http` (jsdom AbortSignal isn't
    compatible with undici).

Security:

  - **`--token` on argv leaks via `/proc/<pid>/cmdline`**. Default
    Linux permissions allow any local user to `ps auxww | grep
    'qwen serve'` and read the bearer token. Daemon now warns to
    stderr when `--token` is used and recommends
    `QWEN_SERVER_TOKEN` (which uses `/proc/<pid>/environ`,
    owner-only).

  - **Token inherited by spawned `qwen --acp` child**. `env:
    process.env` in `defaultSpawnChannelFactory` passed
    `QWEN_SERVER_TOKEN` into the child. The agent runs
    user-supplied prompts with shell-tool access — leaving the
    token in env enables prompt-injection-into-self-call attacks.
    Strip `QWEN_SERVER_TOKEN` from the child's env before spawn.

Robustness:

  - **`BridgeClient` publishes lacked try/catch on closed bus**.
    `BridgeClient.requestPermission` and `sessionUpdate` called
    `entry.events.publish(...)` directly. Shutdown closes the bus
    *before* killing the channel, so a late `sessionUpdate` from a
    not-yet-dead agent throws. For `requestPermission` the throw
    was particularly bad: `registerPending` had already mutated
    the daemon-wide map, so the throw left the registry
    inconsistent. Cleaner fix: make `EventBus.publish` a no-op on
    closed bus (returns undefined) instead of throwing. Removes
    the need for try/catch at every call site and keeps state
    consistent.

Type safety:

  - **`STAGE1_FEATURES: readonly string[]`** widened the inferred
    tuple-of-literals back to `string[]`. A typo'd feature
    (`'sesion_set_model'`) compiled silent. Drop the annotation +
    add `as const`; export `Stage1Feature` literal-union for
    SDK-side `features.includes(...)` checks to narrow against.

Test counts: cli serve **112** (was 105, +7); SDK unchanged at
243. Full typecheck + lint + suite green.

* fix(cli): PR #3889 review round 6 — gpt-5.5 audit (#3803)

Four new findings from a `/review` pass on PR #3889. Three real
correctness bugs + one Stage 1 design-gap documentation.

Critical:

  - **`[::1]` bind ENOTFOUND**. `LOOPBACK_BINDS` accepts `[::1]` for
    the auth gate, but `app.listen()` wants the unbracketed `::1`;
    `qwen serve --hostname [::1]` passed the gate and then crashed
    with ENOTFOUND. Strip brackets at bind-time, keep them for the
    printed URL. New test asserts the listener actually binds when
    the operator types `[::1]`.

  - **`sendPrompt` no transport-close detection**. The chained
    `entry.connection.prompt()` could hang indefinitely if the
    `qwen --acp` child wedged or the underlying stream broke
    mid-flight (the SDK's pending JSON-RPC promise never delivers
    a response). Because the per-session FIFO tail derives from
    that promise, a single stuck prompt poisoned every subsequent
    caller for the same session. Round 4's `channel.exited` is
    already wired to remove the entry, but the in-flight prompt
    itself wasn't racing it.

    Fix: race `entry.connection.prompt(...)` against
    `entry.channel.exited` inside `sendPrompt`; when the transport
    closes mid-flight, the prompt fast-fails with a descriptive
    error rather than hanging the queue. New test exercises this
    via a stuck fake agent + manual `crash()`.

Real correctness:

  - **`spawnOrAttach` attach-path ignored modelServiceId**. Under
    `sessionScope:'single'` (default) a client requesting a
    specific model on attach got `attached:true` while continuing
    to use whatever model the shared session already had — a
    silent contract drift. Refactor the per-session
    `unstable_setSessionModel` call into a shared
    `applyModelServiceId(entry, modelId)` helper that runs both at
    create-time (existing path) AND on attach-with-model. Same
    helper publishes the `model_switched` event so cross-client
    UIs see the change. New tests cover apply-on-attach and the
    omit-modelServiceId-on-attach no-op case.

Stage 1 design:

  - **`BridgeClient.{readTextFile, writeTextFile}` raw fs proxy**.
    The audit flagged that the bridge reimplements file I/O with
    `fs.{read,write}File` instead of delegating to core's
    filesystem service — divergence on BOM handling, non-UTF-8
    encodings, original line endings. Wiring core's
    FileSystemService through the bridge is invasive (constructor
    dep, reaches into core's runtime), and Stage 2's in-process
    bridge eliminates the proxy entirely. Documented as a
    known gap with the exact user-visible scenarios; no behavior
    change in this PR.

Test counts: cli serve **116** (was 112, +4); full cli **5070**
(was 5066, +4); SDK unchanged at 243. Lint + typecheck green.

* fix(cli): PR #3889 review round 7 — match CodeQL suppression to fired query (#3803)

Single new CodeQL alert (#201) on `workspaceCwd → spawn({cwd})`. The
round-3 suppression I added (`lgtm[js/shell-command-constructed-from-
input]`) referenced the WRONG query id — the alert fires the
`js/path-injection` query, not the shell-command one. The misnamed
suppression also lived 30+ lines above the actual flagged spawn call,
out of CodeQL's annotation scope.

Move the suppression onto the line immediately preceding the spawn
call and use the matching query id `js/path-injection`. The
function-level comment block above still documents the Stage 1 trust
model rationale (operator-controlled cwd is intentional; agent runs
as same UID with shell-tool access; Stage 4+ remote sandbox replaces
this factory entirely).

Defense-in-depth note added: `workspaceCwd` is canonicalized via
`path.resolve()` in `spawnOrAttach` before reaching this factory, and
spawn's `cwd` doesn't pass through any shell.

No behavior change. Test counts unchanged (cli serve 116, full cli
5070).

* fix(cli): self-audit round 8 — concurrency + listener leak + IPv6 + CodeQL honesty (#3803)

Multi-round audit pass on PR #3889 commits 5/6/7. Four findings, one
real high-severity.

High:

  - Attach-with-modelServiceId had no error recovery and no FIFO. If
    the agent rejected the new model on attach, `applyModelServiceId`
    threw, the route 500'd, and the existing session kept running the
    OLD model — caller sees a 500 with no easy way to detect the
    state. Worse, two simultaneous attaches with different
    modelServiceIds would race the `unstable_setSessionModel` calls
    with no serialization. Add a per-session `modelChangeQueue`
    (parallel to `promptQueue`); `applyModelServiceId` now chains
    through it. On failure publishes a `model_switch_failed` event to
    the bus so OTHER attached clients can see what happened (the
    failed-caller still gets the 500). Two new bridge tests cover
    rejection observability + concurrent FIFO.

Medium:

  - `sendPrompt` was adding a `.then` listener to
    `entry.channel.exited` PER CALL, accumulating linearly with
    prompt count over a session's lifetime. ~hundreds of bytes per
    prompt; trivially observable on chatty long-running sessions.
    Cache a single `transportClosedReject` lazy-init promise on
    SessionEntry; every subsequent prompt's race uses the same
    promise.

Low:

  - `[host]:port` IPv6 syntax in `--hostname` was being naively
    bracket-stripped to `host]:port`, which Node rejects with a
    cryptic ENOTFOUND at startup. Tighten the strip to only
    accept pure `[addr]` forms; reject the URL-with-port form
    upfront with a useful error pointing at `--port`.

  - `BoundedAsyncQueue.forcedInBuf` invariant comment was wrong: it
    claimed force-pushed items were always at the front of `buf`,
    but the eviction-frame path force-pushes at the BACK. The
    miscount that follows is functionally inert (`close()` blocks
    the next cap check), but the comment was actively misleading.
    Rewrote it to honestly describe both call paths and explain
    why the eviction-case miscount is harmless.

CodeQL honesty:

  - Round 7's `// lgtm [js/path-injection]` comment doesn't actually
    suppress alerts — GitHub Code Scanning ignores inline `lgtm`
    annotations (LGTM.com retired 2021). Replaced the misleading
    `// lgtm` line with a NOTE block stating the constraint
    explicitly: suppression requires UI dismissal or
    `.github/codeql/codeql-config.yml`, both out of scope for a
    code-only PR. The function-level comment that explains the
    Stage 1 trust model rationale stays.

Test counts: cli serve **119** (was 116, +3); full cli **5073**
(was 5070, +3, no regressions).

* fix(cli): self-audit round 9-10 — reject empty-bracket --hostname (#3803)

Final fix from rounds 9-10 of the audit chain. One real concern + three
nice-to-have test gaps that the code already handles correctly.

  - `--hostname '[]'` (empty brackets) used to slip past the bracket
    validator: `slice(1, -1)` produced `''`, which Node interprets as
    "bind to all interfaces". An operator typing `[]` clearly meant
    something specific, not wildcard. Reject the empty-inner case
    upfront with the same useful error as the `[host]:port` case.
    New test asserts the rejection.

Round 10 ran a clean convergence pass and signed off:
  - Cross-cutting state invariants (byWorkspace, byId, inFlightSpawns,
    pendingPermissions, plus all per-entry queues and caches) — all
    mutations paired and async holes safe.
  - All test names match assertions.
  - Public type surface clean (DaemonEvent.id?, Stage1Feature
    CLI-only, DaemonClientOptions.fetch shape correct).
  - Production paths verified: non-executable child times out at 10s
    init, multiple-daemon EADDRINUSE rejects cleanly via
    `server.once('error', reject)`.
  - Three "missing test" notes (transportClosedReject cache sharing,
    full subscribe-publish-evict sequence, modelChangeQueue failure
    isolation) are diagnostic gaps — the code paths are correct and
    covered by adjacent tests.

Test counts: cli serve **120** (was 119, +1 empty-bracket); SDK
unchanged at 243.

* docs(cli): note SSE single-line data emit vs multi-line parser (#3803)

formatSseFrame emits the payload as a single `data:` line. The
EventSource spec also allows a frame to span multiple `data:` lines
(joined by `\n` on parse), and the SDK receive-side parser handles
that variant — but we never emit it because the JSON payload has no
embedded newlines after JSON.stringify. Document the in/out asymmetry
so future readers don't mistake the absence of newline splitting for
a bug. Closes review thread AMgP0.

* fix(cli,sdk): close 11 #3889 review threads — race + leak + IPv6 + SSE

Critical correctness:
- setSessionModel now serializes through `entry.modelChangeQueue` so
  POST /session/:id/model can't race with the attach-with-different-
  modelServiceId path that already chains on the same queue. Without
  this two concurrent model changes interleave and the published
  `model_switched` event may not match the agent's actual model.
- POST /session reaps the spawned child when the client disconnected
  during the 1-3s spawn window (`req.aborted && !session.attached`).
  Without this, every aborted request leaks one orphan child the
  daemon can't address by sessionId. Attached sessions skip the kill
  — another client legitimately owns them.
- spawnOrAttach refuses dispatch once shutdown has started
  (`shuttingDown` flag set at the top of `shutdown()`). Late-arrivers
  on already-established HTTP connections that pass `server.close`'s
  rejection of NEW connections would otherwise spawn children the
  shutdown snapshot already missed. Late re-check inside `doSpawn`
  (after `connection.newSession` resolves) catches the in-flight case
  and tears down the half-built channel.
- sendPrompt early-aborts pre-aborted callers before queuing — saves
  a queue trip and gives a clean trace for retry-after-abort flows.

Defensive:
- parseSseStream caps the unread buffer at 16 MiB. Without this, an
  upstream that returns non-SSE (misconfigured proxy, long-lived
  non-streaming body) feeds `buf` until the consumer OOMs.
- parseSseStream now accepts an optional AbortSignal that is checked
  at each iteration, and DaemonClient.subscribeEvents forwards
  `opts.signal` into it. Post-200 aborts now actually stop iteration
  instead of buffering frames until the upstream closes.
- DaemonClient.fetchTimeoutMs (30s default) wraps every short-poll
  method (health/capabilities/createOrAttachSession/listWorkspaceSessions/
  setSessionModel/cancel/respondToPermission) with `AbortSignal.timeout`.
  Composes with caller-provided signals via `AbortSignal.any`. `prompt`
  is intentionally exempt (long-lived: model + tool turns can take
  minutes); `subscribeEvents` is exempt (long-lived SSE).
- New `bridge.killSession(sessionId)` API mirrors the shutdown teardown
  for a single session — used by POST /session orphan-reap above and
  exposed for future routes that need targeted cleanup.

Stale + cosmetic:
- Bridge map header comment said "no path that removes a session...
  when its child process crashes between requests" — out of date since
  the `channel.exited` cleanup landed in an earlier audit round.
  Rewritten to describe the actual cleanup chain.
- runQwenServe now wraps IPv6 hostname literals in brackets when
  building the URL (`http://[::1]:4170` not `http://::1:4170`). The
  bracket-stripping logic on `listenHostname` already handled
  `app.listen()` correctly; this fixes the printed/copy-paste URL.
- Dead `mode: ServeMode` variable in serve.ts removed (the runQwenServe
  call hardcodes `mode: 'http-bridge'`); the warning condition is now
  inlined.

Test plan:
- `vitest run` cli/serve: 120/120 + 49/49 (httpAcpBridge) pass
- `vitest run` sdk-typescript daemon: 42/42 pass
- tsc --build packages/cli packages/sdk-typescript: clean
- ESLint: clean

* chore(lint): allow mime/lite in import/no-internal-modules (#3803)

`packages/core/src/utils/fileUtils.ts` and its test import `mime/lite`,
which is mime@4's documented public sub-export (a smaller bundle that
omits the legacy mime DB) — not an internal module. The rule has been
flagging these on PR CI runs even though main's CI happens to pass
(likely stale-cache vs fresh-install timing). Add `mime/lite` to the
allowlist so lint is consistent across main and PR runs.

* fix(cli,sdk): close 14 review threads — env whitelist + races + Windows tests + structured errors (#3803)

Critical correctness:
- registerPending now resolves orphaned permissions as cancelled when
  the entry has been torn down between the agent's `requestPermission`
  decision and the bridge handler firing. Previously the permission
  would hang the agent forever (killSession's pendingPermissionIds
  iteration didn't include the just-orphaned id, shutdown's clear()
  dropped it without resolving).
- Workspace key now goes through `realpathSync.native` (with a
  resolved-but-uncanonicalized fallback for non-existent paths) so
  case-insensitive filesystems (macOS APFS, Windows NTFS) don't
  silently degrade `sessionScope: 'single'` into "one session per
  spelling". Matches how `config.ts` / `settings.ts` / `sandbox.ts`
  resolve workspace paths.
- killChild gets a hard 10s deadline after SIGKILL so a child stuck
  in uninterruptible sleep (D-state, e.g. NFS read on a dead server)
  can't block `bridge.shutdown()`'s `Promise.all` forever.
  `SHUTDOWN_FORCE_CLOSE_MS` in `runQwenServe` only covers
  `server.close()` — without this hard kill, daemon shutdown hangs.
- setSessionModel now races the agent call against
  `transportClosedReject` and wraps in `withTimeout`, matching what
  `sendPrompt` and `applyModelServiceId` already do. Without the
  race, a wedged child blocks `POST /session/:id/model` forever.
  Also publishes a `model_switch_failed` SSE event on rejection so
  passive subscribers see the failure (matches `applyModelServiceId`).
- shutdown() now awaits `inFlightSpawns` so the late-shutdown re-check
  inside `doSpawn` finishes its half-built channel teardown before
  `bridge.shutdown()` resolves. Without the await, `runQwenServe.close()`
  returns and `process.exit(0)` is queued before the orphan tears
  itself down, surfacing a stderr error AFTER the daemon claimed
  graceful shutdown.
- sendPrompt re-checks `signal.aborted` immediately after
  `addEventListener` so a microsecond-window synchronous abort that
  fires between the early-exit check and listener registration still
  triggers the agent `cancel` notification.

Security:
- `defaultSpawnChannelFactory` now passes an *allowlisted* environment
  to the spawned `qwen --acp` child instead of `{ ...process.env }`
  with `QWEN_SERVER_TOKEN` deleted. The agent runs user-supplied
  prompts with shell-tool access; anything in its env (OPENAI/
  ANTHROPIC/DASHSCOPE keys, AWS/GCP credentials, DB passwords,
  OAuth tokens) is reachable by prompt injection. Allowlist covers
  HOME/PATH/USER/LOGNAME/LANG/LC_*/TMPDIR/TEMP/TMP/NODE_PATH plus
  Windows essentials (SYSTEMROOT/USERPROFILE/APPDATA/...). The
  explicit `delete childEnv['QWEN_SERVER_TOKEN']` stays as
  defense-in-depth — anyone grepping for the token name finds the
  scrub explicitly named.

Observability:
- 5xx responses now carry structured `code` and `data` fields when
  the underlying error has them (JSON-RPC errors from the ACP SDK
  forward as `{code, message, data}`). Without this, every distinct
  failure (quota / rate-limit / auth / crash) collapses to the same
  opaque "Internal error" string at the client.
- 5xx errors log to stderr (via `writeStderrLine`, not `console.error`,
  to keep the no-console lint rule happy). Stop-gap until structured
  access/error logging lands.
- Eviction frame on EventBus subscriber overflow no longer consumes
  a `nextId` slot. The synthetic frame burning a sequence id meant
  healthy subscribers saw gaps (3 → 5) that the resume ring couldn't
  back-fill — silently broke the `BridgeEvent.id` "monotonic per-
  session" contract. `BridgeEvent.id` is now optional on the type
  to make the absence honest. Same pattern as `stream_error`.

Cross-platform:
- httpAcpBridge.test.ts now derives expected paths via
  `path.resolve(path.sep, 'work', 'a')` (factored out as `WS_A`/
  `WS_B`/`SESS_A` constants) instead of hardcoded POSIX literals
  like `/work/a`. On Windows `path.resolve('/work/a')` returns
  `D:\work\a` so the literal expectation drifted; the bridge's
  internal canonicalization to that form was correct, the tests
  were wrong. Fixes 3 Windows CI matrices that have been red since
  the PR opened.

Compatibility:
- `DaemonClient.fetchWithTimeout` now feature-detects
  `AbortSignal.timeout` and `AbortSignal.any` with polyfills, so the
  SDK actually works on its declared minimum runtime (Node >=18.0.0).
  `AbortSignal.any` was added in Node 20.3 — without the fallback
  every non-streaming call throws on Node 18.0–20.2.

Documentation:
- `cancelSession` now explicitly documents that cancel only affects
  the currently active prompt; previously POST'd queued prompts
  continue to execute. Multi-prompt queueing is a daemon-introduced
  behavior (not in ACP spec), so the contract for queued prompts is
  ours to define and was previously implicit.
- Removed misleading "still reliable on Node 20" comment around
  `req.aborted` and switched the orphan-cleanup signal to
  `res.writable` — the right "can we still send a response to this
  client?" check (`req.destroyed` is too eager: clients close their
  writable end after sending the body even though they're still
  listening for the response).

* fix(cli): close 3 more review threads — case-insensitive Host, trim token, sliceLineRange (#3803)

- hostAllowlist now lowercases the Host header before comparison. Per
  RFC 7230 §5.4 Host is case-insensitive; Express normalizes header
  *names* but not values, so a Docker proxy that capitalizes the
  hostname (`Host: Localhost:4170`) or a platform with case-preserving
  DNS (`HOST.docker.internal`) was getting 403 with an exact-match
  compare.
- `runQwenServe` now `.trim()`s the token from both `--token` and
  `QWEN_SERVER_TOKEN`. Common gotcha: `export QWEN_SERVER_TOKEN=$(cat
  token.txt)` keeps the file's trailing `\n`, so the hashed-then-
  compared token never matches what well-behaved clients send. Every
  request returns the generic 401, no breadcrumb pointing at the
  whitespace, operators chase ghosts.
- `BridgeClient.readTextFile` partial-read path no longer
  `content.split('\n')`s the entire file. New `sliceLineRange` walks
  `indexOf('\n', …)` forward only to the end-of-range boundary and
  returns a single substring. For a 100 MB file with `{line: 1,
  limit: 2}` this avoids a ~100 MB `String[]` allocation.

* fix(sdk): close 2 #3889 polyfill leaks — abortTimeout + composeAbortSignals

Two copilot review threads on commit 11567a43c's AbortSignal
polyfill code:

- `abortTimeout` polyfill scheduled `setTimeout` but never cleared
  it. Even after the awaited fetch resolved, the pending timer kept
  the event loop alive until it fired; on a heavily-used client the
  per-call timers accumulated. Fix: `.unref()` the handle (so a
  fast-resolving fetch doesn't pin the loop) AND clear it on the
  controller's `abort` event (so the composed-signal-aborted-first
  path also drops the timer). Defensive `typeof handle.unref` so
  the polyfill works in any runtime that returns a non-NodeJS
  Timeout shape.

- `composeAbortSignals` polyfill added an `abort` listener to every
  input signal but never removed them. Long-lived caller signals
  (e.g. a session-scope cancel signal that lives for the whole SDK
  client) accumulated one listener per SDK call — slow leak that
  retained the closure + controller of every prior call. Fix:
  track per-input cleanups in an array, detach all on the first
  abort (whichever input fires) AND on the composed controller's
  own abort path (defense-in-depth for callers that abort the
  composed signal independently).

Both leaks only fire on the polyfill path — runtimes with native
`AbortSignal.timeout` / `AbortSignal.any` (Node 20.3+) take the
early-return path and bypass the leak surface entirely.

29/29 DaemonClient.test.ts pass; tsc + ESLint clean.

* fix(cli,sdk): close 13 deepseek review threads — error handling + race + log noise (#3803)

Correctness:
- `applyModelServiceId` now races against `transportClosedReject` like
  `setSessionModel` and `sendPrompt` already do, so a child crash
  during attach-with-different-model fails fast instead of waiting
  the full 10s `withTimeout`.
- `POST /session` disconnect guard now handles the `attached` case:
  previously `!res.writable && session.attached` fell through to
  `res.json` and threw EPIPE through Express's default handler.
- `POST /session/:id/prompt` now drops `AbortError` silently. When
  the HTTP client closes mid-prompt the bridge re-throws as
  `AbortError`; routing it through `sendBridgeError` produced a
  noisy 500 + stderr stack trace that under active use generated
  dozens of misleading log lines per second.
- `POST /session/:id/prompt` now rejects empty arrays (`[]`) and
  non-object elements with a 400 instead of letting the ACP SDK
  surface 500s on degenerate input.
- `readTextFile` rejects `limit <= 0` up front (previously
  `sliceLineRange` hit the `end < start` path with surprising
  results).
- `inFlightSpawns` tracks ALL `doSpawn` promises now, not just
  single-scope ones. Under `thread` scope, `shutdown()` previously
  resolved before in-flight spawns finished their child cleanup,
  surfacing stderr noise after the daemon claimed graceful shutdown.
  Use a unique `${workspaceKey}#${randomUUID()}` key per thread-scope
  spawn so simultaneous spawns don't collide.

Shutdown ordering:
- The 5s force timer is now armed AFTER `bridge.shutdown()` resolves,
  so it only races `server.close()` (the listener drain) — not the
  bridge's own 10s `KILL_HARD_DEADLINE_MS` child cleanup. The earlier
  arrangement could resolve this promise while the bridge was still
  killing children, orphaning anything not yet at the deadline.

Express error handling:
- Final 4-arg error middleware catches `express.json()`'s
  `SyntaxError` on malformed bodies and returns JSON `400` instead of
  Express's default HTML page (which trips SDK clients that expect a
  JSON body on every response).
- SSE `res.on('error')` handler now logs the error before cleanup, so
  operators get a breadcrumb for flaky-network triage instead of
  silent disconnect.

Performance:
- `ALLOWED_CHILD_ENV_KEYS` moved to module scope so the 22-element
  Set is allocated once at load instead of rebuilt on every
  `defaultSpawnChannelFactory` call. (Renamed from `ALLOWED_ENV_KEYS`
  for clarity.)

Documentation:
- `canonicalizeWorkspace` now explicitly notes the cross-module
  contract with `config.ts`/`settings.ts`/`sandbox.ts`. A shared
  utility was considered but deferred — the call sites use slightly
  different fallback policies and Stage 2 in-process collapses the
  bridge into core, removing the bridge-side path resolution
  entirely.

Tests:
- Two new DaemonClient tests exercise `fetchWithTimeout`'s
  AbortSignal.timeout / composeAbortSignals polyfill paths against
  a never-resolving fetch promise. Previously every test used
  `recordingFetch` with synchronous resolution, so those polyfills
  shipped untested — a logic error there would only surface when a
  real daemon became unresponsive.

* docs(serve): close §08 Stage 1 doc gap — user guide + protocol reference + DaemonClient example (#3803)

Stage 1 of issue #3803 §08 budgeted "Documentation + examples + e2e tests"
as the closing 1d task. The e2e tests landed (22 cases under
integration-tests/cli/), the docs did not. After merge, anyone who
discovers `qwen serve` via `qwen --help` had nowhere in-repo to read
about it — the only complete description lived on the PR page itself.

This commit fills that gap with three complementary docs and a README
mention:

- `docs/users/qwen-serve.md` — operator-facing quickstart: 5-step curl
  walkthrough (start → /health → /capabilities → /session → /prompt →
  /events), CLI flag table, default-deployment threat model summary,
  and a pointer to the orchestrator-shaped multi-session future.
- `docs/developers/qwen-serve-protocol.md` — full HTTP protocol
  reference: per-route request/response shapes, auth contract, error
  envelope, SSE frame format and event-type table, Last-Event-ID
  reconnect semantics, environment variables, source layout.
- `docs/developers/examples/daemon-client-quickstart.md` — TypeScript
  end-to-end snippet with the SDK's DaemonClient: capabilities probe,
  spawn-or-attach, subscribe-before-prompt event handling, reconnect
  via Last-Event-ID, first-responder permission voting, shared-session
  collaboration between two clients, auth, cancel.
- README.md — "Daemon mode" added to the 5-way usage list + a short
  section under Usage with three doc links.
- `docs/users/_meta.ts` and `docs/developers/_meta.ts` — sidebar
  entries for the new pages.

No code changes; no test changes.

* docs(serve): close 8 deepseek doc-review findings (#3803)

Inline doc review on the Stage 1 doc set caught real issues:

- `qwen-serve-protocol.md`: `session_died` (and `client_evicted`,
  `stream_error`) now explicitly marked as terminal — SSE stream
  closes after the frame; subscribers should reconnect via POST
  /session for `session_died`.
- `qwen-serve-protocol.md`: documented coalesced spawn failure path
  — when the underlying spawn fails, all coalesced callers receive
  the same error and the in-flight slot is cleared so a follow-up
  call can retry.
- `qwen-serve-protocol.md`: clarified the `modelServiceId` (back-end
  provider, picked at session create) vs `modelId` (model within an
  already-bound service, picked via POST /session/:id/model)
  distinction, and explained why `/capabilities`'s `modelServices`
  array is always `[]` in Stage 1.
- `qwen-serve-protocol.md`: typo "Re-races" → "Races" on the model
  switch description.
- `qwen-serve.md`: reordered quickstart so SSE subscribe (now step 4)
  comes before the prompt POST (now step 5). Previously, step 4's
  blocking prompt resolved before step 5's `curl -N` was open, so
  readers following the steps verbatim never saw a streaming event.
  Also expanded the event-types paragraph to call out which frames
  are terminal.
- `daemon-client-quickstart.md`: closed a TOCTOU race in the example
  — `sendPrompt` fired before the SSE handshake completed, so
  fast-starting agents could emit events into the ring before the
  iterator was actually pulling. Pass `lastEventId: 0` so the
  daemon's replay buffer covers the gap; comment in the example
  explains the rationale.
- README.md: "Loopback bind has no auth" → "no auth by default"
  (since the user can opt into bearer auth on loopback by setting
  `QWEN_SERVER_TOKEN`).

* fix(cli,sdk,docs): close 21 review threads — env regression + races + doc accuracy (#3803)

CRITICAL regression fix:
- Child env scrub flipped from allowlist back to denylist (just
  QWEN_SERVER_TOKEN). The earlier allowlist was overzealous: it
  dropped OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY /
  QWEN_* / DASHSCOPE_API_KEY / custom modelProviders[].envKey, all of
  which the agent legitimately needs to authenticate to the LLM.
  Daemon-mode users with env-only auth would start the daemon, attach
  a session, then watch every prompt fail with auth errors. Threat-
  model rationale documented at the call site: prompt-injected shell
  tools can already read ~/.bashrc, ~/.aws/credentials, etc., so env
  passthrough isn't the security boundary; the user-as-trust-root is.
  QWEN_SERVER_TOKEN stays scrubbed to prevent agent → its own daemon
  escalation.

Other code fixes:
- doSpawn no longer tears down the session when create-time model
  switch fails. The session is still operational on the agent's
  default model; tearing it down left the caller with a 500 and no
  sessionId to retry against. The model_switch_failed SSE event is
  the visible signal; caller can retry via POST /session/:id/model
  once they have the sessionId.
- doSpawn now uses applyModelServiceId for the create-time model
  switch (was raw conn.unstable_setSessionModel + withTimeout). The
  helper races against transportClosedReject too, so a child crash
  during model switch fails fast instead of consuming the full init
  timeout.
- sendPrompt's abort handler now calls cancelPendingForSession
  before the ACP cancel notification (matching cancelSession). A
  client disconnecting mid-permission was leaving the agent stuck
  waiting on a vote that no SSE subscriber would ever cast.
- shutdown() and killSession() now publish a terminal `session_died`
  SSE event before closing the bus. Previously the channel.exited
  handler's "byId.get(...) !== entry" guard short-circuited (entry
  already removed), so SSE subscribers couldn't tell daemon shutdown
  from a transient network error.
- Express error middleware now special-cases `status: 413`
  (EntityTooLargeError from body-parser when a request exceeds the
  10 MB JSON limit) and returns a JSON 413 instead of a misleading
  500.
- /health is now registered BEFORE bearerAuth middleware, so
  liveness probes work without credentials when the daemon was
  started with --token. CORS deny + Host allowlist still apply.
- SSE writes serialize through a per-connection chain so the
  heartbeat interval can no longer interleave with the main event-
  write loop. Two concurrent res.write calls would otherwise bypass
  the backpressure guard and could interleave bytes between SSE
  frames on the wire.

SDK:
- abortTimeout / composeAbortSignals exported for direct unit
  testing. The existing test claimed to cover the polyfill paths via
  subscribeEvents, but subscribeEvents calls _fetch directly (not
  fetchWithTimeout), so composeAbortSignals never ran in the test.
  New tests exercise the helpers directly across native + polyfill
  runtimes.

Doc accuracy fixes:
- daemon-client-quickstart.md: createOrAttachSession({ cwd: ... })
  → ({ workspaceCwd: ... }) (SDK type), client.sendPrompt → prompt,
  client.cancelSession → cancel. The example wouldn't typecheck.
- qwen-serve.md: "binds one workspace" claim removed — a single
  daemon hosts sessions for any cwd the caller passes; the
  per-instance constraint is per-user / scale, not per-workspace.
  Auth verification example switched from /health to /capabilities
  (since /health is now exempt from bearer auth).
- qwen-serve-protocol.md: env var was QWEN_E2E_LLM, real var is
  SKIP_LLM_TESTS (inverted polarity). Streaming test count was 4,
  actually 3. Added Stage 1 limitation notes for "no DELETE
  /session" and "no permission timeout". Added client-side
  ring-buffer gap detection guidance for Last-Event-ID reconnect.

Test updates:
- httpAcpBridge.test.ts: rewrote two tests for the new
  doSpawn-on-model-switch-fail contract (publish event, keep
  session). Updated shutdown-closes-subscriptions test to expect
  the new terminal `session_died` frame.
- server.test.ts: switched bearer-auth rejection probes from
  /health to /capabilities (since /health is now exempt). Added a
  test that locks /health's exemption.

* docs(serve): close 2 last review threads — prompt timeout limitation note (#3803)

A05Yk (deepseek): document that `POST /session/:id/prompt` has no
server-side timeout. The bridge only races against the agent child
exiting + the caller's HTTP-disconnect AbortSignal; a wedged-but-alive
agent blocks the per-session FIFO. Long-running prompts are
legitimate (deep research / large-codebase analysis) so a default
deadline is deliberately not set; Stage 2 will expose a configurable
opt-in. Callers should set their own client-side timeout and
disconnect / POST /session/:id/cancel on expiry.

AyoUy (copilot): same env-allowlist concern as A09HB — already
addressed by the allowlist→denylist revert in the previous commit
(e74aa9919). No additional code change needed; the resolve here just
acks that the upstream fix covers it.

* fix(serve): close 3 copilot review threads — SSE envelope shape + integration test ordering (#3803)

A8uSe / A8uSt — the SSE frame examples in qwen-serve.md and
qwen-serve-protocol.md showed `data:` containing only the inner ACP
payload (e.g. `{"sessionUpdate": ...}`). The daemon actually emits
the full event envelope — `{id?, v, type, data, originatorClientId?}`
— JSON-stringified on a single line. Readers copying the curl output
and writing parsers against the documented shape would extract garbage
or fail JSON-shape validation. Both docs now show the real envelope
and call out the SSE-level `id:` / `event:` lines as EventSource
convenience that duplicates fields already inside the JSON envelope.

A8uSz — integration `qwen serve — bearer auth` tests probed `/health`
for 401 assertions, but `/health` is now intentionally registered
BEFORE the bearer middleware (per the A8dZT fix in the previous
commit) so liveness probes work without credentials. Switched probes
to `/capabilities`, plus added a `/health exempt` test that locks the
exemption so a future middleware ordering change can't silently break
liveness probes.

Also: integration `bad modelServiceId tears down half-init session`
asserted the OLD doSpawn-on-model-switch-fail behavior (throw + clear
maps). Per #3889 review A05Ym the new behavior keeps the session
operational on the agent's default model and surfaces the failure
via the `model_switch_failed` SSE event. Test renamed to
`bad modelServiceId keeps the session alive on the default model`
and rewritten to assert the new contract.

* fix(serve): close 3 copilot review threads — sync write throw, polyfill name, blockquote (#3803)

A800o (server.ts:360): `res.write(chunk, cb)` callback isn't documented
to receive an error argument in Node — errors come on the `'error'`
event, which the surrounding code already wires up. The dead `(err) =>
if (err) reject(err)` branch was misleading. The real concern was
that `res.write()` can throw synchronously when the socket is already
destroyed (typical EPIPE shape), and the throw escaped the promise
executor. Wrapped the `res.write` call in try/catch so that surfaces
as a rejection on the returned promise instead of an unhandled
exception.

A8008 (DaemonClient.ts:375): `abortTimeout` polyfill called
`new DOMException('TimeoutError')`, which sets the *message* to
"TimeoutError" and leaves `name` at its default ("Error"). Native
`AbortSignal.timeout()` aborts with `name === 'TimeoutError'` (per
WHATWG), so callers doing `if (err.name === 'TimeoutError')` to
distinguish timeout from user-abort would see the polyfill behave
differently from the native runtime. Constructor signature is
`new DOMException(message, name)` — fixed both args.

A801J (qwen-serve-protocol.md:254): blockquote was broken — one
line in the middle of the multi-line `>` block was missing the `>`
prefix, which dropped the rest of the list out of the quote and
rendered awkwardly. Added the missing `>`.

* fix(cli,sdk): close 8 review threads — DoS cap + SDK plumbing + cleanup (#3803)

Critical:
- A9UEi — `EventBus` had no subscriber cap and evicted subscribers
  lingered in the `subs` Set until the consumer drove `next()`. An
  attacker opening thousands of SSE connections to one session would
  amplify each `publish()` (O(N) over subs) into a CPU/memory DoS,
  with each evicted-but-stalled connection's `BoundedAsyncQueue`
  pinned in memory forever. Two fixes: per-bus subscriber cap of 64
  (refuses new subs at the limit by returning an empty iterable),
  AND `subs.delete(sub)` immediately when a subscriber is evicted so
  subsequent publishes don't pay the dead-sub iteration cost. Also
  set `server.maxConnections = 256` on the listener to bound socket
  descriptors against connections that never finish their headers.

SDK:
- A9UEv — `prompt()` now accepts an optional `AbortSignal`. Caller
  cancellation forwards through the underlying TCP close, which the
  daemon already translates into an ACP `cancel` notification. The
  bridge's `sendPrompt(sessionId, req, signal)` always supported it;
  only the SDK surface was missing the parameter.
- A9UEn — `subscribeEvents` now applies `fetchTimeoutMs` to the
  CONNECT phase only (request → headers received). The SSE body
  itself stays uncapped (it's long-lived by design), but a daemon
  that's TCP-open but never returns headers no longer blocks
  callers indefinitely. Implementation: a setTimeout-driven
  AbortController composed with the caller's signal, cleared in
  `finally` once `_fetch` returns.
- A9UEr — `respondToPermission` now drains the response body via
  `res.body?.cancel()` on both 200 and 404. undici keeps the
  underlying socket pinned waiting for an unconsumed body; long-
  running clients with frequent permission votes would exhaust
  the connection pool.

Cleanup:
- A9UNF — `MAX_BUF_BYTES` renamed to `MAX_BUF_CHARS` (the guard
  checks `buf.length`, which is UTF-16 code units, not bytes). The
  cap's job is "stop runaway non-SSE bodies", not exact accounting,
  so the proxy is intentional — but the name now matches the unit.
  Error message updated.
- A9UNb / A9UNp — both integration tests' boot-timeout `setTimeout`
  is now stored and `clearTimeout`'d on success and on early exit.
  Without the clear the un-cancelled 10s timer outlived the spawn
  promise and could keep the vitest event loop alive past the test,
  manifesting as intermittent timeouts on slow CI.

A9UEy was already addressed by the prior commit's `status === 413`
branch in the Express error middleware (body-parser sets both
`status: 413` and `type: 'entity.too.large'` on body-too-large
errors); resolve only.

* fix(cli,test): close 2 copilot review threads — case-insensitive bearer + Windows skip (#3803)

A9sCe (auth.ts:88): bearer scheme parsing was case-sensitive
(`parts[0] !== 'Bearer'`). Per RFC 7235 §2.1 / RFC 7230 §3.2.6 the
auth scheme token is case-insensitive — `Bearer` / `bearer` /
`BEARER` are all valid, and conformant clients may send any. The
old code returned 401 on those. Switched to a regex-based split that
also tolerates runs of whitespace between scheme and credentials,
then `.toLowerCase()`s the scheme before comparing. The token value
itself stays case-sensitive (it's user-defined opaque material).

A9sCw (qwen-serve-streaming.test.ts): the streaming integration
suite shells out to `pgrep` / `kill -KILL` to simulate child-process
crashes for the `SIGKILL → session_died` test. Those binaries are
POSIX-only — on Windows runners the suite would fail even when
`SKIP_LLM_TESTS` is unset. Added `process.platform === 'win32'` to
the SKIP gate. A Windows-equivalent (`taskkill /F /PID …`) needs
different scaffolding; deferred.

* fix(cli,sdk,docs): close 6 review threads — CodeQL regex, body cancel, env doc (#3803)

A90nk (auth.ts:93): CodeQL flagged the new bearer-scheme regex
`^(\S+)\s+(.+)$` as a polynomial-regex risk on user-controlled
input — `\s+` and `.+` overlap on whitespace-heavy adversarial
headers (the alert example: `'!\t' + '\t'.repeat(N)`). Replaced
with a hand-rolled split (`indexOf(' ')` + manual whitespace
skip) so there's no backtracking. Behavior unchanged: scheme is
still case-insensitive, runs of whitespace between scheme and
credentials still tolerated, scrubs `header.charCodeAt() === 0x20`
explicitly so we don't accidentally consume tab/newline as scheme
separator.

A90oi / A96Q8 (qwen-serve.md:117): the threat-model bullet still
claimed the spawned child runs with an "allowlisted environment"
(HOME / PATH / USER / LOGNAME / LANG / etc), but the prior commit
flipped the implementation to a denylist (only `QWEN_SERVER_TOKEN`
scrubbed) so the agent could authenticate to LLM providers. Doc
now matches code: explicit pass-through with a one-key scrub, plus
the threat-model rationale (user-as-trust-root, env passthrough is
not the boundary).

A90ou (qwen-serve-protocol.md:300): `stream_error` example showed
the inner ACP-style payload `{"error":"<message>"}` instead of the
full envelope `{v, type, data:{error}}` that other SSE-frame
examples in the same doc already use. Updated to match.

A96RL (DaemonClient.ts:352): `subscribeEvents` threw on a 200 with
the wrong content-type without consuming the response body first.
On undici-backed `fetch` an unconsumed body keeps the underlying
socket pinned waiting for the consumer; long-running clients
hitting this path repeatedly would exhaust the connection pool.
Same `await res.body?.cancel()` pattern as `respondToPermission`.

A96RR (server.ts:167): prompt-element validation accepted any
non-null object, but `typeof [] === 'object'`, so `prompt: [[]]`
slipped past with a confusing 500 from the ACP SDK layer downstream.
Added `!Array.isArray(item)` so the 400 actually catches array
elements.

* fix(cli,sdk,docs): close 10 review threads — DoS observability + race + tests (#3803)

Code:
- A-Ur8 (httpAcpBridge.ts:1319): SCRUBBED_CHILD_ENV_KEYS gets a
  prominent WARNING that the denylist-only design is correct ONLY
  because the agent has unrestricted shell-tool access. Any future
  sandbox-locked variant MUST switch back to allowlist or expand
  the denylist to cover provider/CI/cloud secret prefixes.

- A-XfH (auth.ts:60): Host allowlist now accepts the no-port form
  (`localhost`, `127.0.0.1`, `[::1]`, `host.docker.internal`) when
  the bind port is 80. Per RFC 7230 §5.4 clients may legitimately
  omit the port suffix when it matches the URI scheme default.

- A-UsJ (httpAcpBridge.ts:564): unify model-switch failure handling.
  The create-session path swallows the error to keep the session
  alive on its default model; the attach path now does the same
  (was: throwing a 500 with no sessionId, denying the caller any
  way to recover). Both paths surface failure via the
  `model_switch_failed` SSE event.

- A-UsN (httpAcpBridge.ts:621): extracted the lazy-init
  `transportClosedReject` pattern into `getTransportClosedReject`
  helper. Three call sites (`applyModelServiceId`, `sendPrompt`,
  `setSessionModel`) collapsed to one, single-listener invariant
  documented at one place.

- A-UsH (eventBus.ts:194): subscriber-cap rejection is now
  observable. EventBus.subscribe throws a typed
  `SubscriberLimitExceededError` (was: silent empty iterable). SSE
  route catches it, logs to stderr, and emits an SSE-shaped
  `stream_error` terminal frame so the rejected client sees a
  readable failure rather than a closed-with-no-frames stream.

- A-UsO (server.ts:72): `/health` is now exempted from bearerAuth
  ONLY on loopback binds. On non-loopback the route is registered
  AFTER bearerAuth so probes must carry the token — otherwise an
  unauthenticated caller could probe arbitrary IP:port to confirm
  a `qwen serve` exists. Doc updated.

Tests added:
- A-UsP: new test sends an 11 MB body to verify the 413 path in
  the Express error middleware returns the actionable
  "Request body too large" JSON instead of a generic 500.
- A-UsQ: new test for `DaemonClient.prompt(sessionId, req, signal)`
  AbortSignal forwarding through to fetch.
- A-UsS: two new tests for `subscribeEvents` connect-timeout
  (never-resolving fetch aborts; fast-resolving fetch clears the
  timer so it doesn't leak as a dangling handle).
- A-UsU: new test for `sendPrompt` abort path resolving pending
  permissions as cancelled — the bug being regressed: an HTTP
  client disconnecting mid-permission would leave the agent stuck
  waiting on a vote that no SSE subscriber would ever cast.

Test contract updates:
- `publishes model_switch_failed and surfaces the error when the
  agent rejects` rewritten for the new attach-path swallow contract:
  attach now returns the existing session with `attached: true`
  and the `model_switch_failed` event is the visible failure
  signal instead of a thrown error.

* fix(serve): add missing v field on subscriber-limit stream_error frame (#3803)

`tsc --build` (which CI runs as part of the lint job) caught what
`tsc --noEmit` (the local typecheck script) missed: the new
`stream_error` frame in `server.ts:344` was constructed without the
`v` field, but `OmitId<BridgeEvent>` requires it. Local typecheck
in the previous commit was clean; the build's stricter project
graph reported `error TS2345` and broke both Lint and Test
(Ubuntu) jobs.

Set `v: 1` to match the existing `stream_error` construction in
the SSE iterator-throw path in the same file.

* docs(users): close 1 copilot review thread — GitHub canonical casing in nav (#3803)

A_U2e: nav label "Github Actions" was inconsistent with the
canonical "GitHub" casing used elsewhere in the repo (skills,
README, etc.). Rename to "GitHub Actions" for consistent branding.

Pre-existing entry in `docs/users/_meta.ts` adjacent to the
`'qwen-serve'` line this PR added — flagged in the diff context.

* fix(serve): close 4 deepseek review threads — closed-bus race + per-session stderr + entry override (#3803)

BBb9H (correctness): `BridgeClient.requestPermission` could orphan
a pending permission if the bus closed between `registerPending`
and `entry.events.publish` (the shutdown path closes per-session
buses BEFORE awaiting `channel.kill()`, so the agent can still
issue `requestPermission` in that window). Pending was registered
in the daemon-wide map but `publish()` returned `undefined`
(closed bus) → no SSE subscriber ever saw the request → no client
voted → agent's `requestPermission` hung forever, blocking the
daemon's `Promise.all` over child kills. Now: check publish's
return; if `undefined`, roll back the pending via a new
`rollbackPending` callback that resolves it as `cancelled`.

BBb8e (Critical observability): child stderr was `'inherit'` —
all sessions' stderr interleaved on the daemon's stderr stream
unattributed. Switched to `'pipe'` and forward each line with a
`[serve pid=<n> cwd=<dir>]` prefix; operators can now
`grep pid=12345` to pull one session's trace cleanly. Updated
the now-stale doc comment that claimed inherit was current.

BBb8- (deployability): `process.argv[1]` is brittle — fails on
non-`qwen` launchers (bundled binaries, npx wrappers, `node -e`,
`tsx`, container images that relocate the script). Added
`QWEN_CLI_ENTRY` env override as the higher-priority resolution
path. Improved the failure message to suggest the env var as
the actionable fix.

BBb82 (documented limitation): `withTimeout` REJECTS but doesn't
ABORT the underlying ACP op. For `unstable_setSessionModel` this
means a timed-out caller perceives failure while the agent may
eventually complete the switch — drift between caller's perceived
model and agent's actual model + contradictory SSE events.
Documented as a Stage 1 limitation in the `withTimeout` JSDoc;
acceptable because (1) ACP doesn't expose a cancel signal for
`unstable_setSessionModel` yet so we couldn't abort even if we
wanted to, (2) model switches complete in milliseconds in
practice — a timeout means genuinely wedged, not just slow.
Stage 2 will add abort plumbing once ACP exposes the hook.

* ci(noop): re-trigger workflow for f8509dde5 (#3803)

* fix(cli,sdk): close 8 review threads — sse abort + queue drain mode + perf + doc engine drift (#3803)

Correctness:
- BCcd6 (sse.ts:80): trailing flush at EOF used `splitFrames(buf)`
  which returned `[buf]` — a multi-byte split that completed
  multiple frame separators in the final `decoder.decode()` would
  merge the frames into one parse and silently drop events.
  Switched the EOF flush to `consumeFrames()` (same walker the
  main loop uses), then attempt one more `parseFrame` on any
  trailing fragment. Removed the now-unused `splitFrames` helper.

- BCybH (sse.ts:67): `parseSseStream` only checked `signal.aborted`
  before each `reader.read()`, leaving the generator parked inside
  a pending `read()` if the upstream went idle right when the
  caller aborted — contradicting the docstring's "AbortSignal
  cleanup is prompt" claim. Added a one-shot abort listener that
  calls `reader.cancel()` (cleared in `finally`), so abort
  reliably terminates even on a stalled stream.

- BCce_ / BCycT (eventBus.ts:391/253): subscribe documented "abort
  closes the iterator promptly" but `BoundedAsyncQueue.next()`
  drained any items already in `buf` before honoring `closed`.
  Aborted SSE subscribers could keep yielding hundreds of queued
  events to a closed socket. Added a `close({drain: false})` mode
  that truncates `buf` immediately, used by the abort path; the
  default drain-on-close behavior is preserved for the eviction
  path (which needs the synthetic `client_evicted` terminal frame
  to reach the consumer before the iterator unwinds).

Performance:
- BCcfe (auth.ts:72): `hostAllowlist` was allocating a fresh `Set`
  + 4 interpolated strings on every request. Cache once per
  resolved port (relevant because tests bind to ephemeral 0 and
  the port is only known after `listen()`); SSE heartbeats and
  high-frequency probes now skip the allocation.

- BCcgJ (DaemonClient.ts:137): `fetchWithTimeout` used
  `AbortSignal.timeout()` — the timer fires regardless of whether
  the fetch resolved early. On a fast-resolving request with the
  default 30s timeout, the pending timer hangs around. Switched
  to `AbortController` + `setTimeout` + explicit `clearTimeout`
  in `finally`, so each timer is released the moment its fetch
  settles. Also `.unref()`s the timer so it doesn't pin the event
  loop on its own.

Doc accuracy:
- BCyc0 (DaemonClient.ts:468): the `abortTimeout` /
  `composeAbortSignals` JSDoc claimed Node 18-20.2 polyfill
  compatibility, but `engines.node` is `>=22.0.0` now. Reframed
  as a generic feature-detect for non-Node runtimes (browsers /
  edge workers) so future maintainers don't reason about the
  wrong floor.
- BCydi (server.ts:368): "Always present in Node >= 20" → "on the
  supported Node versions (engines.node >=22)".

CodeQL alert #207 (httpAcpBridge.ts:1342, `js/path-injection` on
`cwd: workspaceCwd`) is the renumbered version of the
already-accepted #201 — same trust-model rationale documented at
the call site, same need for maintainer UI dismiss / config
exclusion.

* feat(serve): close 3 chiga0 audit items — ringSize 4000, --max-sessions, /health?deep=1 (#3803)

Three "30-minute" items from chiga0's external architecture audit
(2026-05-11). All actionable within Stage 1 scope; remaining items
in chiga0's review (SaaS positioning, multi-token to Stage 1.5,
acp-bridge package extraction, reference orchestrator) are larger
scoping decisions deferred to Stage 1.5/2.

DEFAULT_RING_SIZE 1000 → 4000 (Risk 4):
- A single long turn can emit hundreds of frames (test plan reports
  13 for a SHORT turn, real workloads can be 10× that). 1000 was
  exhausted by a moderate turn before a 5s reconnect window
  finished. 4000 gives ~30× headroom over a typical busy turn at
  the cost of a few hundred KB RAM/session. Updated user + protocol
  docs and the daemon-client-quickstart example.

--max-sessions <n> (default 20) (Rec 3):
- New `ServeOptions.maxSessions` + matching `BridgeOptions`. Bridge
  throws `SessionLimitExceededError` when `byId.size +
  inFlightSpawns.size >= max` BEFORE issuing a fresh spawn. Attaches
  to existing sessions (single scope) bypass the cap so an idle
  daemon's reconnects keep working at-capacity. `0` disables.
  Default of 20 sized below the design's N≈50 cliff (per-session
  ~30–50 MB RSS + FD pressure). HTTP route maps to 503 with
  `Retry-After: 5` and `code: session_limit_exceeded`. Tests cover:
  cap rejection under thread scope, attach-not-counted under single
  scope, `0` disables. Documented in CLI flags table + protocol
  Common-error section.

/health?deep=1 (Risk 3):
- Default `/health` stays cheap (no bridge access). With `?deep=1`
  the response includes `sessions` and `pendingPermissions` from
  the bridge — touches state so a wedged bridge surfaces as 503
  `{status: "degraded"}` instead of "200 ok" on a zombie daemon
  (the `k8s rolling deploy will see healthy` failure mode chiga0
  flagged). Loopback-vs-non-loopback bearer-exempt logic from the
  earlier A8dZT fix is preserved via a shared handler. Tests cover:
  cheap default, deep response shape, throwing-getter → 503.

* fix(serve,sdk,docs): close 9 review threads — req.on('close') prompt-cancel bug + doc + types (#3803)

Critical correctness:
- BQAnZ (server.ts:225): `POST /session/:id/prompt` wired
  cancellation to `req.on('close')` — but Node's `IncomingMessage`
  fires that event when the request body has been fully consumed,
  even when the client is still listening for the response. Result:
  ordinary prompt calls were getting cancelled the moment their
  upload finished, returning `{stopReason: "cancelled"}` instead
  of completing. Switched to `res.on('close')` guarded by
  `!res.writableEnded` (the documented "client gave up before we
  could send the response" pattern, same as the POST /session
  disconnect-detection from earlier in the PR).

Already addressed earlier — resolve as ack:
- BQAna (httpAcpBridge.ts:767): no global session cap. Already
  shipped in commit 66ffd7cc6 — `--max-sessions` flag + bridge
  enforces with `SessionLimitExceededError` mapped to 503; both
  in-flight spawns and live sessions count against the cap.

Doc fixes:
- BDAOf (DaemonClient.ts:49): `fetchTimeoutMs` JSDoc said it
  applies to "every non-streaming method including prompt", but
  `prompt()` actually bypasses fetchWithTimeout (model+tool turns
  are minutes-scale, can't be 30s-capped). Doc now lists the
  short-lived methods explicitly and notes prompt's exemption.
- BDAPY (qwen-serve-protocol.md:283): blockquote was broken — the
  `POST /session/:id/cancel` line was missing the leading `>` and
  a stray "- POST /session/:id/cancel." rendered orphaned outside
  the quote. Reformatted as a single coherent quote.

Reviewer-tooling resilience:
- BQAnf / BQAng (integration-tests/...:325/185): added explicit
  `DaemonSessionSummary` type to two `.find` / `.every` callbacks.
  Local typecheck infers the type fine via the SDK's source
  declarations; the reviewer's environment resolves
  `@qwen-code/sdk` against a possibly-stale `dist/index.d.ts`
  (per `integration-tests/tsconfig.json` `paths` mapping) and the
  `s` parameter widens to `any`. Annotation makes both envs happy.

Reviewer-only artifacts (no code action):
- BQAnb / BQAnc (integration-tests/...:26/30) — same SDK-dist
  staleness; the imports are correct and resolve fine when
  `packages/sdk-typescript` has been built.
- BQAni (server.test.ts:8 supertest module not found) — Node 20
  setup blocker the reviewer noted; resolves cleanly under
  Node >=22 (our declared engines floor) with `npm install`.

* fix(serve,sdk,test): close 7 review threads — fetchTimeoutMs negative + bridge-error context + perm scope contract (#3803)

Real fixes:
- BQPRo (DaemonClient.ts:136): `fetchTimeoutMs` accepted any number,
  including negatives that would slip past the `Number.isFinite`
  check inside `fetchWithTimeout` and fire `setTimeout(-1)` →
  immediate abort, killing every request before it could complete.
  Coerce non-positive / non-finite to 0 (the documented disable
  sentinel) at the constructor so call-site math stays simple.
- BQLdO (server.ts:725): `sendBridgeError` now accepts a `ctx`
  arg `{ route, sessionId }` folded into the stderr log line.
  Bare `ECONNRESET` / `ENOMEM` traces are no longer unattributable
  on a busy daemon — operators see `qwen serve: bridge error
  (POST /session/:id/prompt session=abc-123): ...`. All five route
  call sites pass context.
- BQI-6 (qwen-serve-streaming.test.ts:123): `sseFrames` test helper
  forwards `opts.signal` into `parseSseStream` so post-connect
  abort terminates iteration immediately (the parser's own abort-
  -wired-to-reader.cancel landed earlier; this just plumbs through
  the test harness).

Doc / contract:
- BQNqL / BQNqM (httpAcpBridge.ts:692, server.ts:199):
  `cancelPendingForSession` cancelling all session permissions on
  client disconnect is intentional under the per-session FIFO + ACP
  spec — permissions are issued inline DURING an active prompt,
  the agent awaits them, so the only outstanding permissions at
  any moment belong to the prompt being cancelled. Cross-client
  caveat (B's vote 404s when A disconnects mid-A's-prompt) is
  the right behavior — a vote on a cancelled-prompt's permission
  wouldn't drive the agent forward. Documented the scope contract
  + multi-client caveat in `cancelPendingForSession` JSDoc.

Already addressed (resolve as ack):
- BQI-c (qwen-serve-protocol.md): blockquote was already
  reformatted in the previous round (`POST /session/:id/cancel`
  now sits inline on a single quoted line); copilot reviewed an
  older commit.
- BQI-v (DaemonClient.ts): `fetchTimeoutMs` JSDoc was already
  updated last round to explicitly note `prompt()` is excluded;
  copilot reviewed the older shape.

* fix(serve,test,docs): close 6 review threads — TEST_CLI_PATH + Stage 2 markers + SSE phantom-conn warning (#3803)

Real fix:
- BQpu6 / BQpvW (integration-tests/cli/...): both qwen-serve test
  files hardcoded `../../packages/cli/dist/index.js`, while the
  rest of the integration suite reads `process.env.TEST_CLI_PATH`
  (set by `globalSetup.ts` to the root `dist/cli.js` bundle). The
  difference made our tests sensitive to which build step
  (`build` vs `bundle`) ran last. Now read `TEST_CLI_PATH` first,
  fall back to per-package dist for direct vitest invocations
  that bypass globalSetup.

Operator-facing doc:
- BQsOD (server.ts:497 KNOWN GAP): added an operator warning to
  `docs/users/qwen-serve.md`'s threat-model section about phantom
  SSE connections behind NATs that swallow TCP RSTs (kernel
  keepalive ~2h Linux default → can accumulate to the 256-conn
  ceiling on `--hostname 0.0.0.0` deployments). Stage 2 will add
  application-level idle deadline; until then operators on such
  networks may want to lower `server.keepAliveTimeout` via reverse
  proxy.

Stage 2 maintenance markers (no code change, just visible TODOs):
- BQsOA (httpAcpBridge.ts:1247): added `FIXME(stage-2)` on the
  sync `realpathSync.native` call so the Stage 2 in-process
  refactor doesn't ship without removing this event-loop-blocking
  syscall.
- BQsOB (server.ts:243): added a SECURITY NOTE on the
  `...(body as object)` passthrough explaining the spec-defined
  `_meta` forwarding contract + the rule that an explicit pick is
  required if any new bridge field starts being trusted by name.
  Pattern repeats on cancel/model — note covers all four sites.
- BQsOF (httpAcpBridge.ts:1041): `FIXME(stage-2)` noting that
  `setSessionModel` reuses `initTimeoutMs` (default 10s) for the
  in-flight model swap — conceptually distinct from cold-start
  init, currently sharing only by coincidence; Stage 2 should
  split into `modelSwitchTimeoutMs` and remove the no-abort
  `withTimeout` race-condition once ACP exposes a cancel signal
  for `unstable_setSessionModel`.

* fix(serve): close 4 review threads — unhandled rejection + maxSessions plumbing + 2 docs

- httpAcpBridge.sendPrompt: attach .catch(() => {}) to the
  abort-listener cleanup chain. The chain is `racedPromise.finally
  (...)` and we never await it; if `racedPromise` rejects, the
  finally returns a rejected promise that surfaces as an unhandled
  rejection (Node's default behavior on unhandled rejection is
  process termination). The route's own catch handles the original
  rejection — only the cleanup chain needs the swallow.
- httpAcpBridge.sendPrompt: FIXME(stage-2) for absolute prompt
  deadline — buggy agent ignoring cancel + alive channel = slow
  prompt-promise leak.
- server.createServeApp: forward opts.maxSessions when constructing
  the default bridge. Direct callers (tests, embeds) were silently
  falling back to DEFAULT_MAX_SESSIONS (20); only the runQwenServe
  path piped the option through.
- docs/users/qwen-serve.md: clarify Host allowlist is loopback-only;
  non-loopback binds rely on bearer + operator-managed front proxy.

* docs(sdk): close 1 review thread — sse.ts MAX_BUF_CHARS docstring lead-line said "bytes"

Doc lead-line claimed "Hard cap on accumulated unread bytes" while the
implementation enforces the cap via `buf.length` (UTF-16 code units),
which the rest of the same docstring already correctly explained.
Fix the lead-line so a reader skimming the first sentence isn't
misled.

The runtime error message and constant name (MAX_BUF_CHARS) already
say "code units" — only the docstring lead-line needed alignment.

* fix(serve,sdk): close 5 review threads — disconnect/attach race + 3 spec fixes + 1 doc

- httpAcpBridge: add SessionEntry.attachCount + new
  killSession({requireZeroAttaches:true}) opt to fix the BQ9tV race.
  When client A spawned (attached:false) but disconnected mid-spawn,
  A's disconnect-reaper (server.ts) could tear down a session that
  client B had just attached to. spawnOrAttach now bumps attachCount
  on each attached:true return, and killSession with the new opt
  bails when attachCount > 0. The check + the eager byId/byWorkspace
  deletes both run in killSession's synchronous prefix, so the
  guard is atomic across the await boundary.
- server.ts disconnect-reap path now passes requireZeroAttaches:true.
- loopbackBinds.ts: lowercase the operator-supplied hostname before
  Set lookup so --hostname Localhost / LOCALHOST aren't forced to
  require a token. Aligns boot-time detection with the runtime
  Host-header check (auth.ts already lowercases).
- auth.ts bearer parsing: accept HTAB (0x09) in addition to SP
  between scheme and credentials per RFC 7230 §3.2.6 BWS.
- sdk sse.ts parseFrame: guard against `null` / primitive JSON
  parses so the AsyncGenerator<DaemonEvent> contract isn't
  violated by a misbehaving proxy emitting `data: null`. Daemon
  itself never emits these — defense-in-depth only.
- docs/developers/qwen-serve-protocol.md: document the
  modelServiceId-rejection-on-fresh-session corner case + tell
  subscribers to pass Last-Event-ID:0 to replay the spawn-time
  model_switch_failed event from the ring.
- 3 new unit tests: BQ9tV positive + negative race paths,
  BQ9ze parseFrame null guard.

* fix(serve): close 4 review threads — 2 critical (NaN cap, stderr buffer) + IPv6 zone-id + deep doc

- httpAcpBridge maxSessions normalization (BRApy [Critical] gpt-5.5):
  NaN / negative values previously fell through `!Number.isFinite(...)`
  to `Infinity`, silently disabling the daemon's session cap (fail-OPEN
  on a typo). Now throw TypeError on NaN / negative; explicit 0 and
  Infinity remain valid "unlimited" sentinels.
- httpAcpBridge stderr line buffer (BRAp3 [Critical] gpt-5.5): the
  per-spawn `buf` accumulating stderr until `\n` had no length cap; a
  child that wrote a huge line or never emitted a newline could grow
  daemon memory unboundedly per session. Cap at 64 KiB per line and
  force-flush with a `[truncated]` marker — keeps the prefix-attributed
  log line, bounds memory, no content drop.
- runQwenServe.formatHostForUrl (BQ-6V copilot): RFC 6874 requires
  `%` in IPv6 zone IDs (e.g. `fe80::1%lo0`) to be percent-encoded as
  `%25` in URLs. Now encode on the raw-IPv6 path; already-bracketed
  input is the operator's responsibility.
- /health?deep=1 (BQ-6F copilot): the 503 path is unreachable for
  the real bridge (counter getters are simple Map-size accessors that
  don't throw). Reframed in code + protocol doc as INFORMATIONAL
  observability ("capacity dashboards, not real liveness"); keep the
  try/catch as defense-in-depth for custom bridge impls.
- 2 new unit tests: BRApy NaN/negative throws + 0/Infinity ok;
  BQ92B Localhost case-insensitive boot.

* fix(sdk): close 1 review thread — sse parseFrame tighter shape guard (BREsR followup to BQ9ze)

The previous parseFrame guard only rejected null/primitive JSON; arrays
and shape-incomplete objects still cast through to DaemonEvent. Tighten
to require: non-null non-array object with v === 1 and type: string.
Now the generator's static AsyncGenerator<DaemonEvent> type is a
genuine runtime guarantee instead of a structural hope.

Daemon never emits malformed frames (formatSseFrame always serializes
{v: 1, type: string, ...}); guard remains defense-in-depth against
misbehaving proxies / alternate implementations. Existing test fixtures
already conform to the shape so no other tests needed updating.

* fix(sdk): close 1 review thread — fetchWithTimeout keeps timer alive through body consumption (BRN1o)

Pre-fix: `fetchWithTimeout` cleared the timer in `finally` the moment
the underlying `fetch` resolved. But `fetch` resolves at headers, not
at body completion. A daemon or proxy that sent headers and then
stalled mid-body left `await res.json()` (and `failOnError`'s
`res.text()`) without any deadline — calls to `health()`, `capabilities()`,
`createOrAttachSession()`, `listWorkspaceSessions()`, `setSessionModel()`,
`cancel()`, `respondToPermission()` could hang indefinitely past
`fetchTimeoutMs`.

Refactor `fetchWithTimeout<T>` to take an optional `consume(res)`
callback whose execution is included in the timer scope. The composed
abort signal still flows through to fetch's body stream, so an
in-progress `res.json()` rejects cleanly when the timer fires. All
JSON-returning routes updated to pass the body-read code as the
callback. SSE (subscribeEvents) + prompt are unchanged: they bypass
fetchWithTimeout intentionally (long-lived).

Regression test: response with a never-emitting body that errors via
the composed AbortSignal — pre-fix would hang for 5s+, post-fix
rejects within ~80ms (configured timeout).

* fix(serve,sdk): close 8 review threads — coalescing race fix + --max-connections + 5 docs/cleanups

- httpAcpBridge spawnOrAttach (BRSCi [Critical] DeepSeek): the BQ9tV
  attachCount fix was incomplete for the in-flight coalescing path.
  When two callers await the same doSpawn and the second has a
  modelServiceId, the attach-bump landed AFTER an extra await for
  applyModelServiceId — leaving a microtask window in which A's
  killSession sync-prefix would still see attachCount==0 and reap a
  session B was about to receive. Move the bump to the very first
  sync step after `await inFlight` (and same in the direct-attach
  branch) so the bump-before-killSession ordering holds even when
  the model-switch yields. Test added for the coalescing-race path.
- commands/serve + serve/types + runQwenServe (BRQQb): add
  `--max-connections` flag (default 256), wired through ServeOptions
  and `server.maxConnections`. Operators with high-concurrency
  deployments can now tune the listener-level cap without waiting
  for Stage 2.
- commands/serve (BRQQZ): wrap `new Promise<never>(() => {})` in a
  named `blockForever()` helper so a future maintainer doesn't read
  the bare expression as a never-resolving-promise bug.
- auth.ts (BRQQd): rewrite the comment about HTAB BWS — clarify
  that the scheme→credentials separator is `1*SP` per RFC 9110
  §11.6.2, and HTAB is only accepted in the BWS *after* the SP.
  `Bearer\t<token>` (pure HTAB) is intentionally rejected.
- types.ts + qwen-serve-protocol.md (BRQQf): document
  `modelServices: []` is always empty in Stage 1 so SDK consumers
  don't build off it.
- qwen-serve.md (BRQQl + BRQQm): add operator note about subscribing
  to /events BEFORE posting modelServiceId on attach (otherwise the
  model_switch_failed event is missed). Document the four-layer load
  cap stack near --max-sessions so operators can size the related
  knobs together.
- sdk index (BRSCv): drop the historical `Daemon`-prefixed type
  aliases (`DaemonPromptRequest` / `DaemonSubscribeOptions`) for
  consistency with the other un-prefixed daemon-type exports. SDK is
  Stage-1-experimental with no shipping consumers.

* fix(sdk): close 1 review thread — sse parseFrame must not drop frames whose first line is a comment/retry (BRgq-)

Per the EventSource spec, comment lines (`:` prefix) and `retry:` are
line-level fields, not frame-level. The previous early return at the
top of `parseFrame` dropped the entire frame when its first line was
a comment or retry directive — meaning an intermediary that prepends
`: keep-alive` or `retry: 5000` to every frame would cause the
embedded `data:` payload to be silently lost.

Removed the `startsWith` guard. The line-level `data:` collection
loop already produces an empty `dataLines` array for pure-comment /
pure-retry frames, so the existing `if (dataLines.length === 0)
return undefined` branch still skips them — without dropping real
events that just happen to be preceded by a comment line.

Existing test still pins the standalone-comment / standalone-retry
behavior; new test pins the leading-comment + data-line case.

* docs(sdk): close 1 review thread — sse MAX_BUF_CHARS comment was overpromising byte-equivalence (BRker)

The previous wording suggested "one code unit ≈ one byte" for
mostly-ASCII content, then qualified it with mixed BMP / supplementary
caveats. Reviewer flagged that JS string.length isn't a reliable byte
proxy in either direction — engine string representation (V8 Latin-1
path vs UTF-16) makes the actual memory cost vary in ways the comment
didn't capture cleanly.

Rewrote to state plainly: cap measures code units, not bytes; intent
is "stop runaway non-SSE bodies", not exact memory accounting;
byte-precise bounds belong at a front proxy. Threshold and code
unchanged — only the comment.

* fix(serve): close 7 review threads — atomic write, read-size cap, force-exit on 2nd signal, doc fixes

- httpAcpBridge.writeTextFile (BSA0D): atomic write-then-rename via
  `<path>.<pid>.<ts>.tmp` + `fs.rename`. Closes the SIGKILL-mid-write
  truncation hole. Tmp file lives in the target's directory so the
  rename can't cross filesystem boundaries; cleaned up on rename
  failure.
- httpAcpBridge.readTextFile (BSA0E): `fs.stat` pre-check rejects
  files past 100 MiB so a `{ line: 1, limit: 10 }` against a 500 MB
  log doesn't allocate 500 MB of RSS just to return 10 lines.
- runQwenServe SIGINT/SIGTERM (BSA0K): second signal during drain
  forces `process.exit(1)` with a stderr message instead of silently
  no-oping. Standard daemon behavior — `^C^C` works.
- commands/serve --hostname help text (BRqFe): now mentions the full
  loopback set (127.0.0.1, localhost, ::1, [::1]) so IPv6 users
  aren't misled into thinking ::1 needs a token.
- runQwenServe boot-refusal error (BRqFy): same correction — error
  message now lists all loopback aliases the operator can rebind to.
- httpAcpBridge withTimeout doc (BSA0C): explicit Stage 2 follow-up
  marker for the modelSwitchTimedOut / model_switch_late_success
  observability gap (already a known limitation).
- server.errorPayload (BSA0G): documented the multi-tenant info-leak
  trade-off (Stage 1 single-user/small-team trust model accepts
  verbatim ACP error data) and pointed to a Stage 2 --redact-errors
  follow-up.
- 2 new tests: writeTextFile leaves no tmp turd; readTextFile
  rejects 200 MiB sparse file via the size cap.

* fix(sdk): close 1 review thread — sse parseFrame must validate optional `id` (BSP1-)

The previous shape guard only validated `v === 1` and `type: string`,
leaving `DaemonEvent.id: number | undefined` unchecked. A misbehaving
proxy emitting `data: {"id":"1","v":1,"type":"x",...}` would survive
the cast and break consumer resume logic — Last-Event-ID resume does
numeric comparisons against the monotonic counter, and a string id
silently corrupts that math.

Reject the frame entirely when `id` is present but not a finite safe
integer (`Number.isSafeInteger`). Negative integers and missing-id
both still pass; the daemon never emits negative ids in practice but
the guard's responsibility is the type-cast contract, not the
daemon's id-allocation policy.

New test covers: string id, float id, > MAX_SAFE_INTEGER id (all
rejected); negative-id, no-id, plain integer (all pass).

* docs(serve): Stage 1.5 markers from chiga0 follow-up architecture review (#3889 c4427773706)

chiga0's follow-up review explicitly states "None of the findings
here block Stage 1. That holds." All 6 findings are Stage 1.5
convergence work for when downstream consumers attach. None require
code changes for this PR.

Adding inline FIXME(stage-1.5) markers at the natural pivot points
so the future refactor has clear breadcrumbs back to the audit
comment, instead of Stage 1.5 implementers having to re-discover
the convergence story:

- types.ts STAGE1_FEATURES → finding 5 (capability registry +
  extMethod HTTP route).
- eventBus.ts EventBus class → finding 2 (lift to
  packages/event-bus, multi-consumer subscribe).
- httpAcpBridge.ts BridgeClient.requestPermission → finding 3
  (PermissionMediator + policy plugin point; closes prior chiga0
  Risk 2 too).
- httpAcpBridge.ts BridgeOptions → findings 1 + 4 (split into
  AcpChannel + Transport packages; thread FileSystemService through
  BridgeOptions).

No behavior change. Each marker links to the audit comment for
traceability.

* docs(serve): tighten Stage 1 scope framing + durability + Stage 1.5 must-haves (#3889 c4427875644)

chiga0's third review walks three downstream-consumer scenarios (IM
bot, mobile companion, IDE extension) against Stage 1's runtime
guarantees. The bottom-line concern is framing: the PR body promises
"real workloads" but the protocol surface is sized for demo /
single-user / never-crashes. Reviewer offers two paths — tighten the
framing or add 7 must-haves to Stage 1.5. Author classifies all 10
must-haves as Stage 1.5/2, none as Stage 1 changes.

In-scope action for this PR (doc-only, no behavior change):

- `docs/users/qwen-serve.md` "Status" block: explicit scope-honesty
  note — Stage 1 is sized for prototyping clients + local
  single-user/small-team. Production-grade multi-client / mobile /
  flaky-network workloads need Stage 1.5+ guarantees.
- New "Durability model" section spelling out sessions-are-ephemeral
  (closes must-have 10): no resume on child crash / daemon restart,
  ring-overflow on long disconnects, writeTextFile atomic across
  crash but not across restart.
- New "Stage 1.5+ runtime guarantees" section listing the 10
  must-haves (blockers 1-3, reliability 4-7, ergonomics 8-10) with a
  link back to the audit comment for traceability.
- `httpAcpBridge.ts` BridgeOptions.sessionScope: FIXME(stage-1.5)
  marker referencing must-have 1 (per-request override), since this
  is the most prominent client-facing lock-in risk.

No code behavior changes — this is roadmap commentary surfaced into
the artifacts where downstream integrators will look (user docs +
code pivot points).

* fix(serve): close 2 correctness findings from tanzhenxin review

Two bugs surfaced in the CHANGES_REQUESTED review:

Issue 1 — `--max-connections 0` silently bricks the daemon on Node 22:
- Docs say "Set to 0 to disable" and the code did
  `server.maxConnections = opts.maxConnections ?? 256`, but on Node
  22.15.0 setting `server.maxConnections = 0` makes the listener
  refuse EVERY connection (every fetch → SocketError other side
  closed). The operator following the documented disable path got a
  daemon that boots cleanly, logs "listening on …", and then
  silently rejects health/session/SSE.
- Fix: treat 0 / Infinity / non-finite as "leave the property
  unset" (Node's default = unlimited at this layer). Reviewer
  verified the Node 22 quirk; verified locally that 100 still binds
  the cap, 0 and Infinity now both accept connections.

Issue 2 — Orphan agent child when both coalesced spawnOrAttach callers
disconnect:
- The BQ9tV `attachCount` race guard is monotonic. Once B's
  `spawnOrAttach` bumps it (synchronously, before the route handler
  can see `!res.writable`), the spawn-owner A's disconnect-reaper
  sees attachCount > 0 and skips the reap — permanently. If B then
  also disconnects, neither A nor B's route handler does anything,
  and the agent child stays alive with no client knowing the id.
- Fix: add `bridge.detachClient(sessionId)` that decrements
  attachCount and reaps iff (attachCount == 0 && subscriberCount ==
  0). Server's `POST /session` handler calls it on the
  `!res.writable && session.attached === true` branch (symmetric to
  the existing spawn-owner-disconnect reap).
- Subscriber-count check prevents reaping when a third client C is
  already on SSE — `detachClient` only fires when the session has
  no live consumers at all.

2 new tests for issue 1 (max-connections 0 + Infinity still accept
connections; 100 still binds as supplied). 2 new tests for issue 2
(detach reaps when alone; detach preserves when SSE subscriber
exists). fakeBridge updated with the new method.

* fix(serve): close 3 review threads — maxConnections NaN/negative validation + doc fix + close-contract honesty

- runQwenServe maxConnections validation (BUF9-): NaN / negative
  values previously slipped through `cap > 0 && Number.isFinite(cap)`
  to "leave unset = unlimited", silently fail-OPEN on a CLI typo and
  weakening the DoS / FD-exhaustion guard. Now throw TypeError
  upfront (before `app.listen()`) so a malformed cap fails the
  `runQwenServe` promise instead of escaping as an uncaught
  exception from the listen callback.
- types.ts maxConnections doc (BUb7C): comment said "Node treats 0
  as unlimited" but the runtime fix treats 0 as a sentinel and
  leaves `server.maxConnections` unset (Node 22 quirk). Updated to
  match.
- runQwenServe close()/force-timeout (BUb7h): the 100ms eager
  `setTimeout(() => finish(), 100)` after `closeAllConnections()`
  resolved the close promise WITHOUT waiting for `server.close()`'s
  callback — breaking the "fully closed" contract. Now: force-close
  just accelerates `server.close` by killing sockets; we still wait
  on the close callback. A secondary 2s deadline handles the
  pathological "server.close never fires" case (kernel-stuck
  socket) with a logged warning, so shutdown stays bounded.

* docs(serve): close 8 review threads — code-comment clarity + 3 new Stage 1 known gaps

8 threads in a single Claude Opus 4.7 review pass — 4 duplicate
existing chiga0 finding FIXME markers, 1 code-comment clarity, 3
real new doc-worthy Stage 1 known gaps.

Code clarity (BUy4U):
- The shutdown re-check at doSpawn (`if (shuttingDown) { kill; throw }`)
  is the LOAD-BEARING correctness contract, not a band-aid as the
  reviewer framed it. Updated comment to explain: shutdown() runs
  tear-down in parallel with awaiting `inFlightSpawns` (faster
  fan-out); the re-check catches spawns whose `newSession` returns
  AFTER the flag flipped. The alternative — await all inflight to
  settle BEFORE snapshotting byId — is cleaner to reason about but
  serializes shutdown by up to `initTimeoutMs` (10s) before any live
  session starts tearing down. Documented the trade-off.

New Stage 1 known gaps in docs/users/qwen-serve.md threat model:
- BUy4H (permission auth daemon-global): cross-session vote risk
  acceptable under Stage 1 single-user / small-team trust model;
  Stage 1.5 will scope to `POST /session/:id/permission/:requestId`
  + session-scoped pending map + per-client identity (closes
  must-have #3 from the downstream review).
- BUy4L (10 MB body limit on /prompt): multimodal content past
  10 MB hits a cliff; workaround via path reference; Stage 1.5
  accepts chunked encoding.
- BUy4e (CORS deny blocks `packages/webui`): document explicit
  deployment options (Electron/Tauri shell, same-origin reverse
  proxy); Stage 1.5 adds `--allow-origin <pattern>` for opt-in
  named frontends.

Already-marked duplicates (BUy4O, BUy4P, BUy4X, BUy4b) — covered by
existing `FIXME(stage-1.5, chiga0 finding N)` / `FIXME(stage-2)`
markers from prior rounds.

* fix(serve): close 1 review thread — catch --hostname localhost:4170 typo upfront (BU-sh)

The previous code path for unbracketed `host:port` typos went:
1. Loopback check fails (`localhost:4170` doesn't match the
   loopback set after lowercase normalization).
2. Throw "Refusing to bind localhost:4170:0 without a bearer token"
   — misleading because the operator's real bug is the colon in the
   hostname, not the missing token.

Alternative path if a token IS supplied: hostname flows through to
`formatHostForUrl` which sees the `:` and treats as IPv6, wrapping
to `[localhost:4170]:port` in the printed URL. Then `app.listen()`
fails with ENOTFOUND. Triple-unhelpful failure mode.

Fix: catch the typo BEFORE the loopback/token check. Unbracketed
input with exactly one `:` is unambiguously the host:port shape —
raw IPv6 literals always have ≥2 colons (shortest is `::`), and
bracketed IPv6 is handled by its own form check below.

Error message suggests the corrected form
(`--hostname localhost --port 4170`).

* docs(serve): two new Stage 1 scope boundaries (option A + option iii) from LaZzyMan reviews

LaZzyMan's two-part review surfaced two structural framing concerns
distinct from the chiga0 roadmap items. Neither requires code changes
in this PR — they want explicit scope honesty in the user docs:

1. TUI super-client framing (option A from the review): TUI UI is
   strictly larger than the wire protocol. The ~15 Ink dialogs and
   `local-jsx` slash commands are local-only; mutating commands like
   `/approval-mode`, `/memory`, `/mcp`, `/agents`, `/tools`, `/auth`,
   `/init` change agent behavior but emit no wire event. Documenting
   remote clients as sharing the agent↔user conversation axis only,
   NOT the full TUI session state. Implementers told to re-fetch
   state on reconnect, not rely on incremental events.

2. N parallel sessions cost N× (option iii from the comment): the
   "1 daemon = 1 session" axiom means N concurrent sessions on one
   workspace = N daemons with zero resource sharing. Concrete cost
   table at N=5 (~1.5-2.5 GB RSS, 15 MCP processes, 5× OAuth refresh)
   so users hit the wall with eyes open. Won't-fix on the main-line
   Stage 1/1.5/2 roadmap; alternatives (#3803 §21 Path A/B, in-project
   sidecars) materially change the architecture in ways we won't
   commit to mid-Stage-1. Peer-agent comparison noted (Cursor /
   Continue / Claude Code / OpenCode / Gemini CLI all do
   single-process multi-session).

Both choices are intentionally the less-ambitious option; the
substantive alternative (option B for taxonomy, option i/ii for N:1)
moves to #3803 if real-usage data ever justifies it.

* docs(serve): clarify option-A across Mode 1 (headless) vs Mode 2 (TUI co-host)

Previous wording treated "TUI is a super-client" as universal truth.
But Stage 1's actual shipping configuration is HEADLESS — no TUI
shell runs inside the daemon — and in that mode the slash commands
listed (`/approval-mode`, `/memory`, `/mcp`, `/agents`, `/tools`,
`/auth`, `/init`) simply don't exist. Session state is boot-time-
frozen from settings + disk, with only `/model` mutable via HTTP.

Restructured the section to split the consequences:

- **Mode 1 (headless `qwen serve`, this PR)**: no TUI exists; session
  state is boot-time-frozen + `model_switched` over HTTP; remote
  clients see the FULL session state; no drift possible.
- **Mode 2 (Stage 1.5 `qwen --serve` co-hosted TUI, future)**: TUI
  exists alongside remote clients; TUI slash commands mutate
  session state with no wire events; remote clients see a strict
  subset; drift possible — re-fetch state on reconnect.

The original "super-client" framing applies cleanly only to Mode 2.
Mode 1 has no asymmetry — same option-A choice, different
consequences.

* fix(serve,sdk): close 12 review threads — 6 critical bugs + 6 follow-ups

Six critical correctness fixes from the latest review pass:

- httpAcpBridge.readTextFile (BX8YO): reject non-regular files via
  `stats.isFile()`. Char devices / FIFOs / procfs entries report
  `size: 0` but stream unbounded data; the 100 MiB cap wasn't
  enough. New `describeStatKind()` helper for human-readable error
  message ("named pipe (FIFO)" / "character device" / etc.).
- httpAcpBridge.writeTextFile (BX8Yp + BX9_h): temp filename now
  includes randomUUID + exclusive flag `wx`. PID + Date.now() alone
  collides under concurrent writes within the same ms (sessionScope:
  'thread' or coalesced spawns on same workspace). Exclusive mode
  fails fast on any residual collision instead of silent overwrite.
- httpAcpBridge.writeTextFile (BX8Yw): resolve via `fs.realpath`
  before write-then-rename so symlinks are preserved. Pre-fix
  rename replaced the symlink with a regular file, leaving the
  real target unchanged while the write appeared successful.
  Test added covering both regular targets and symlink targets.
- server.parseLastEventId (BX9_I): log a stderr breadcrumb when
  rejecting a non-empty non-decimal Last-Event-ID header. Pre-fix,
  clients with a malformed resume header silently resumed from 0
  and lost every event buffered during the disconnect with zero
  evidence in logs.
- httpAcpBridge channel.exited (BX9_P): thread {exitCode,
  signalCode} from the spawn factory through `session_died` event
  payload. Operators triaging a crash can now read the cause from
  the SSE frame instead of grepping daemon stderr for the child's
  pid.
- httpAcpBridge spawnOrAttach in-flight coalesce path (BX9_U):
  defensive re-check that `byId.get()` is still defined after
  attachCount++ — if a concurrent kill tore down the entry, throw
  `SessionNotFoundError` instead of returning `attached: true` with
  a zombie sessionId.

Six follow-ups in the same diff:

- httpAcpBridge attachCount comment (BVryk + BWGSL): outdated
  "monotonic, we never decrement" claim — detachClient() now
  decrements. Comment rewritten to state the actual invariant
  ("reflects clients whose response was written or is about to be").
- runQwenServe.close() contract (BV-qW): bridge.shutdown errors are
  now propagated through the close promise (was: silently caught +
  resolved success). onSignal exits 1 instead of 0 when teardown
  fails. Server.close error takes precedence; bridge error is the
  fallback.
- sdk sse parseFrame id guard (BX8Y1): require id >= 1 (was: any
  safe integer including negative). The daemon's Last-Event-ID
  parser only accepts non-negative decimals and EventBus emits ids
  starting at 1; negative ids on the wire diverge from resume math.
  Existing test updated.
- runQwenServe server error listener (BX9_i): swap
  `server.once('error', reject)` for a persistent `server.on('error',
  log)` after listening. Pre-fix, a post-boot error (EMFILE etc.)
  was unhandled and crashed the daemon.

Tests: +2 for BX8YO (FIFO) and BX8Yw (symlink preserve). Test
infrastructure updated for the new `channel.exited` Promise<ExitInfo
| undefined> signature.

* fix(serve,sdk): close 4 more review threads — frame-scan perf + publish contract + AbortError narrowing + cross-module doc

- sse consumeFrames perf (BX9_a): short-circuit the LF path first.
  In the common LF-only case the CRLF scan was traversing the
  entire remaining buffer for nothing; now CRLF is only scanned
  when LF is absent or potentially appears later than a CRLF
  separator (mixed-encoding edge).
- EventBus.publish contract (BX9_p): explicit JSDoc says publish
  NEVER THROWS (closed-bus returns undefined, subscriber-enqueue
  errors caught internally). Historical try/catch wrappers in
  httpAcpBridge.ts are defense-in-depth, not load-bearing; new
  callers should not add them.
- canonicalizeWorkspace doc (BX9_q): elevate the cross-module
  contract from "undocumented" to explicit — config.ts /
  settings.ts / sandbox.ts / this file all canonicalize the same
  way for sessionScope: 'single' re-attach. A divergence silently
  forks sessions per spelling. The Stage 1.5 @qwen-code/acp-bridge
  lift (chiga0 finding 1) is the natural place to extract a shared
  primitive; until then, any change to those modules needs a
  matching change here.
- POST /session/:id/prompt AbortError swallow (BX9_k): narrow the
  swallow to only fire when `abort.signal.aborted` is true. The
  previous blanket `err.name === 'AbortError'` would also silently
  drop AbortErrors raised internally by the bridge (e.g. child
  process aborting mid-prompt), leaving the client with no response
  and no log trace.

* docs(serve): correct N:1 framing — qwen-code's ACP agent natively supports multi-session

Maintainer feedback (verified against the code): the ACP agent in
packages/cli/src/acp-integration/acpAgent.ts:194 has
`private sessions: Map<string, Session>` — one `qwen --acp` child
natively hosts multiple sessions, and yiliang114's VSCode plugin
already uses this pattern. The earlier "qwen-code is the only entry
treating no multi-session resource sharing as a feature" framing
(from the LaZzyMan reply + docs) was wrong.

Stage 1 bridge in this PR doesn't yet leverage that capability — it
spawns one `qwen --acp` child per session for simplicity (easier
debugging, no cross-session interference during initial
stabilization). That's a bridge-side design choice, not an ACP
limitation.

Revised docs/users/qwen-serve.md:

- "N parallel sessions cost N×" section now distinguishes Stage 1
  bridge (current N× cost) from Stage 1.5 bridge (multi-session per
  child, ~1/5th the cost at N=5). Cost table extended with the
  Stage 1.5 column. No more "won't fix on main-line roadmap"
  framing — the fix is a bridge refactor that pairs naturally with
  chiga0 finding 1 (`@qwen-code/acp-bridge` package lift), NOT the
  #3803 §21 Path A/B/C intra-daemon multi-session workstream
  (qwen-code already does that at the agent layer).
- Status block's "Scope honesty" note: removed the implicit
  permanent-cost framing; replaced with explicit "Stage 1 bridge
  pays N×; Stage 1.5 refactor closes the gap" pointer.
- Peer-agent comparison rewritten: qwen-code's *agent* matches
  Cursor / Continue / Claude Code / OpenCode / Gemini CLI on
  single-process multi-session; the bridge is the artifact.

`httpAcpBridge.ts:doSpawn`: inline `FIXME(stage-1.5)` marker
explaining the refactor (keep one child per workspace, call
`connection.newSession()` multiple times on the same channel), with
the link to `acpAgent.ts:194` so a future maintainer doesn't
re-derive the discovery.

* feat(serve): Stage 1 bridge now multiplexes sessions on one qwen --acp child per workspace

Per LaZzyMan / tanzhenxin reviews + maintainer feedback verified
against `packages/cli/src/acp-integration/acpAgent.ts:194` (the
agent's `private sessions: Map<string, Session>`): qwen-code's ACP
agent natively supports multi-session in one child process. The
Stage 1 bridge previously spawned one child per session for
simplicity, paying N× memory / OAuth / file-cache cost. Now refactored
to leverage the agent's existing multi-session capability — one
`qwen --acp` child per workspace, N sessions share it via
`connection.newSession({cwd, mcpServers})`.

Cost at N=5 sessions on same workspace:
- Before: 300-500 MB RSS (5 children), 5× OAuth refresh, 5× file
  cache, 5× CLAUDE.md parse, 5× cold start
- After: 60-100 MB RSS (one child), one OAuth path, shared
  FileReadCache, parsed once, <200ms cold start after first session

Architecture changes:

- New `ChannelInfo` type holds the shared channel + connection +
  BridgeClient + the set of session ids multiplexing on it.
- New `byWorkspaceChannel: Map<workspace, ChannelInfo>` + new
  `inFlightChannelSpawns` coalesce-map for concurrent channel
  creation.
- New `getOrCreateChannel(workspaceKey)` helper: reuse existing
  channel or spawn one (with `initialize` happening exactly once
  per channel, not once per session). Coalesced via
  `inFlightChannelSpawns` so two parallel callers don't both spawn.
- `doSpawn` now calls `getOrCreateChannel` + `connection.newSession`
  separately (was: spawn+initialize+newSession together per session).
- `BridgeClient` updated: `resolveEntry(sessionId?)` dispatches by
  the sessionId ACP carries in each request — one BridgeClient now
  serves all sessions on its channel. `sessionUpdate`,
  `requestPermission`, etc. all pass `params.sessionId`.
- `channel.exited` cleanup moved into `getOrCreateChannel` and now
  tears down ALL sessions on the channel (not one). Each session
  gets its own `session_died` event so SSE subscribers learn the
  bad news on their own stream.
- `killSession` now removes session from `channelInfo.sessionIds`
  and kills the channel ONLY when its sessionIds set drops to zero.
  Other sessions on the same channel keep running.
- `shutdown` tears down channels (the deduplicated set) and awaits
  both inFlightSpawns and inFlightChannelSpawns.

Cross-workspace channel sharing intentionally NOT done — `acpAgent.ts:
601 (this.settings = loadSettings(cwd))` reloads settings on each
newSession call with a different cwd, so different workspaces in
one child would step on each other. One channel per workspace is
the safe scope.

MCP server children stay per-session for now (each session can have
different mcpServers config). Stage 1.5 follow-up: refcount MCP
children by (workspace, config-hash) so identical configs share.

Tests:
- Updated `spawns fresh per call under sessionScope:thread` → now
  expects `handles.length === 1` (channel reused) but
  `sessionCount === 2` (distinct sessions).
- New: `Stage 1.5 multi-session: N sessions on same workspace share
  ONE channel` (5 sessions, 1 factoryCalls).
- New: `Stage 1.5: killSession on one of N sessions does NOT kill
  the shared channel` (kill 2 of 3, channel still alive; kill 3rd,
  channel killed).
- New: `Stage 1.5: channel.exited tears down ALL multiplexed
  sessions` (each gets its own session_died).
- FakeAgent.newSession suffixes call-count so multiple newSession
  calls on the same channel return distinct ids (matches real
  ACP behavior).

Docs:
- `docs/users/qwen-serve.md` N:1 section rewritten — no longer
  "Stage 1 pays N×, Stage 1.5 fixes". Cost table reflects current
  shared-channel architecture; MCP refcount called out as the one
  remaining Stage 1.5 follow-up; "1 daemon = 1 session" framing
  removed from related sections.

* fix(serve,sdk): close 12 review threads — 6 critical bugs + 6 follow-ups

Critical fixes:

- server.ts safeBody() helper (BZ9uv/va/vs/wD + Bd10m + Bd1zz):
  prototype-pollution sanitization at the body-spread boundary.
  `__proto__` / `constructor` / `prototype` keys are stripped and
  the result is an Object.create(null) target. Replaces 5 sites of
  copy-pasted `typeof req.body === 'object'...` preamble + makes
  the `...(body as object)` spread sites safe.
- httpAcpBridge requestPermission (Bd1yh): per-request wall-clock
  deadline (default 5 min, configurable via
  `BridgeOptions.permissionResponseTimeoutMs`). Without this, an
  agent calling requestPermission with no SSE subscriber connected
  would hang the per-session FIFO forever. After deadline, resolve
  as cancelled + log stderr warning.
- httpAcpBridge requestPermission (Bd1z5): per-session pending
  permissions cap (default 64, configurable via
  `BridgeOptions.maxPendingPermissionsPerSession`). New requests
  past the cap resolve as cancelled with stderr warning. Prevents
  a chatty agent from growing pendingPermissions unboundedly.
- runQwenServe onSignal double-signal force-exit (Bd1y6): new
  `bridge.killAllSync()` + `AcpChannel.killSync()` method
  synchronously SIGKILLs every live qwen --acp child BEFORE
  `process.exit(1)`. Previously double-Ctrl+C bypassed the async
  bridge.shutdown() and left children running as orphans.
- server.ts SSE subscriber-limit response (Bd1zJ): 429 +
  Retry-After instead of 200 + stream_error frame. EventSource
  treats 4xx as terminal (no auto-reconnect); the previous
  200+close-stream triggered EventSource's reconnect loop,
  amplifying the load the limit existed to prevent.
- doSpawn ghost sessionId guard (Bd1zc): re-check byId.has() after
  applyModelServiceId(). The model-switch yields and can race
  channel.exited; without this, caller got HTTP 200 with a
  sessionId that 404s on every subsequent request.

Follow-ups in the same diff:

- sse.ts consumeFrames CRLF scan comment (BcRh_): the comment
  claimed the CRLF scan was bounded to `[cursor, lf)`, but Node's
  `indexOf` has no upper bound. Rewrote to describe what the code
  actually does (scan full remainder; only USE the result if it
  falls before `lf`).
- sse.ts SseFramingError export (Bd10T): typed error class for
  framing-level failures so SDK consumers can distinguish "upstream
  isn't SSE" from generic network errors via instanceof check.
  Re-exported from @qwen-code/sdk.
- protocol doc /health auth (Bctum): document the loopback
  exemption — `/health` doesn't require Authorization on loopback
  binds even when a token is configured. Matches `createServeApp`'s
  registration order.

Bd1xz (cross-session permission escalation) acknowledged as
duplicate of BUy4H — already documented as a known Stage 1 gap
under the single-user / small-team trust model; fix is Stage 1.5
must-have #3 (per-client identity + per-session permission scope).

Tests:
- New: prototype-pollution test verifies `__proto__` spread
  doesn't pollute `Object.prototype`.
- All 70 server + 55 bridge + 16 daemon-sse + 60 DaemonClient
  tests pass (203 total).

`killSync()` stubbed on every inline test channel fake; fake
bridge has `killAllSync()`.

* fix(sdk): close 2 review threads — consumeFrames CRLF scan now actually bounded (BeFHR + BeFId)

Previous attempt at the BX9_a perf optimization left the CRLF scan
running over the full remainder of `buf` on every loop iteration
where an LF separator existed — only the LF-not-found fallback path
was actually bounded. Comments claimed the CRLF scan was restricted
to `[cursor, lf)` or "only fires when needed", but Node's
`String.indexOf` doesn't accept an end index.

Bound the scan via a `buf.slice(cursor, lf)` window before
`indexOf` so the assertion is now true: in the common LF-only case
we pay one full scan (for LF) plus one bounded scan over the
matched frame's bytes (small).

* fix(serve): close 3 review threads + Windows test skip — dangling symlink, no-sessionId throw

- httpAcpBridge.writeTextFile BfFvO: dangling-symlink case. `fs.realpath`
  throws ENOENT for a symlink whose target doesn't exist, and the
  blanket catch silently fell back to writing through the symlink
  itself — `rename(tmp, params.path)` then replaced the symlink with
  a regular file, exactly the bug BX8Yw was supposed to fix. Use
  `fs.readlink` to disambiguate "truly non-existent" from "dangling
  symlink"; resolve the dangling target manually and write through
  to it so the symlink stays a symlink. Regression test added.
- httpAcpBridge BridgeClient resolveEntry BfFut: defensive throw on
  no-sessionId ACP call against a multi-session channel. ACP today
  carries sessionId on every per-session call, but if a future
  no-sessionId call lands, silently dropping it on a multi-session
  channel would be invisible.
- httpAcpBridge.test.ts BX8YO Windows skip: hard-skip via
  `process.platform === 'win32'`. Git-Bash etc. ship a `mkfifo`
  binary that degenerates on Windows (creates a regular file or
  silently no-ops), making the assertion match the wrong error
  shape. Linux + macOS coverage is sufficient for a platform-
  agnostic `!stats.isFile()` check.

BfFvW (CRLF scan comment) was already addressed in 0a4146a02 — the
reviewer's diff was against the pre-fix version.

* fix(serve): close 6 review threads — 4 critical bugs + 2 doc updates

Critical fixes:

- httpAcpBridge.doSpawn newSession-failure cleanup (BkwQA): if
  `connection.newSession()` throws on a freshly-created channel
  whose sessionIds set is empty, tear the channel down rather than
  leaking the empty `qwen --acp` child in `byWorkspaceChannel`
  (invisible to `sessionCount` / `maxSessions`). Channels with
  other live sessions still survive — only the truly-empty case
  reaps.
- httpAcpBridge.detachClient + killSession tombstone (BkwQP):
  detachClient no longer reaps live sessions. Scenario: A spawns
  (attached: false, hasn't opened SSE yet), B attaches
  (attachCount: 1), B disconnects → previous code reaped A's
  still-valid session. New behavior:
  * killSession({ requireZeroAttaches: true }) sets
    `entry.spawnOwnerWantedKill = true` when it bails on
    attachCount > 0 (instead of just returning).
  * detachClient ONLY decrements attachCount. It completes the
    deferred reap only when (spawnOwnerWantedKill && attachCount
    === 0 && subscriberCount === 0).
  * Both-disconnected case still works (reap completes via B's
    detachClient seeing the tombstone). Spawn-owner-alive case
    no longer reaps. Existing tanzhenxin-issue-2 test rewritten;
    new test pins the spawn-owner-alive case.
- httpAcpBridge.writeTextFile mode preservation (BkwQW): stat the
  target before writing; if it exists, chmod the tmp file to the
  preserved mode (and chown owner/group — best-effort, EPERM
  ignored for non-root). Previously a 0600 secret/config edit
  would downgrade to umask-default 0644, exposing contents to
  other local users.
- bridge.respondToPermission option-ID validation (BkwQI): new
  `InvalidPermissionOptionError` thrown when the voter's `optionId`
  isn't in the set of options the agent originally offered in the
  `permission_request` event. PendingPermission now carries
  `allowedOptionIds`. Server route catches the error → 400 (vs.
  404 for unknown requestId). Prevents authenticated clients from
  forging hidden outcomes like `ProceedAlways*` when the prompt's
  `hideAlwaysAllow` policy intentionally suppressed them.

Doc fixes:

- httpAcpBridge top-of-file (BkdCg) + types.ts ServeMode (BkdC8):
  rewrite the "each session spawns its own qwen --acp child"
  framing to match the actual Stage 1.5 multi-session-per-channel
  architecture (one child per workspace, sessions multiplex via
  `connection.newSession()`).

* fix(serve): close 4 review threads — close write-mode race + 2 missing tests + 1 doc

- writeTextFile mode-bits race (Blehd): the BkwQW fix preserved
  mode via `chmod` AFTER `fs.writeFile`, leaving a brief window
  where a `0600` secret-edit was readable at the directory's
  umask default (commonly `0644`). Now pass `mode` to writeFile
  directly so the file is CREATED with the preserved mode atomically
  via the `open(O_CREAT, mode)` syscall. The post-write `chmod`
  remains as belt-and-suspenders against a tight operator umask
  (POSIX `mode & ~umask` could drop bits we wanted preserved).
- httpAcpBridge.test.ts: new bridge-level test for the BkwQI
  `InvalidPermissionOptionError` path (Blehk). Forge a vote with
  an `optionId` not in the agent-offered set; assert the throw
  AND that the pending permission survives so a valid vote can
  still resolve it.
- server.test.ts: new route-level test for the BkwQI 400 mapping
  (Blehl). Fake bridge throws `InvalidPermissionOptionError`;
  assert response is 400 with `code: 'invalid_option_id'`,
  `requestId`, and `optionId` in the body.
- commands/serve --http-bridge help text (Bk59I): updated to
  reflect Stage 1.5 multi-session — "one `qwen --acp` child per
  workspace, with multiple sessions multiplexed via the agent's
  native `newSession()`" (was: "per-session child").

* fix(sdk): close 1 review thread — parseSseStream abort path catches body-read rejection (BlqF_)

Some fetch impls (undici on abort) reject the in-flight `reader.read()`
with an AbortError after `reader.cancel()` fires. Pre-fix that
rejection bubbled to the consumer's `for await`, contradicting the
"abort cancels cleanly" public contract — code that called
`controller.abort()` to wind a subscription down saw an unexpected
throw on the next iteration.

Wrap `reader.read()` in try/catch:
- if `signal?.aborted` is true → treat the rejection as clean
  completion (return from the generator)
- otherwise re-throw, so real upstream failures (network drop,
  unexpected close, malformed body) still reach the consumer

Two regression tests pin the guard's scope: signal-aborted
mid-stream returns cleanly with the frames received so far; a
non-abort `streamController.error(...)` still bubbles via `rejects.toThrow`.

* fix(serve): close 1 review thread — eventBus eviction detaches abort listener (BmJT1)

Pre-fix: `publish()`'s eviction path deleted the sub from `this.subs`
but never invoked `dispose()`, leaving the AbortSignal abort-listener
registered in `subscribe()` attached. Because the consumer is by
definition stalled (that's what caused the overflow), `next()` /
`return()` never fire to detach the listener through the iterator
path. Closures over the queue + sub stayed live until the AbortSignal
itself went out of scope.

Under attack (thousands of opened-then-stalled SSE clients), this
amplified into significant heap retention.

Fix: store `dispose` on `InternalSub` and invoke `sub.dispose()` from
the eviction path. The same closure used by the abort listener / the
iterator's `next()`/`return()` cleanup now runs through the
eviction path too — idempotent through `disposed` so a
post-eviction abort or iterator-return is still safe. Regression
test pins the post-eviction abort + publish path producing zero
side effects.

* fix(serve): close 1 review thread — restore double-Ctrl+C force-kill broken by multi-session refactor (BkUyD)

The Bd1y6 design promised a second SIGINT/SIGTERM during graceful
drain synchronously SIGKILLs every live agent child via
`bridge.killAllSync()` before `process.exit(1)` — the operator-
visible "kill it now" path for a wedged child ignoring SIGTERM.

The Stage 1.5 multi-session refactor (commit 6a170ef8) inadvertently
broke this. `shutdown()` snapshots `byWorkspaceChannel` then CLEARS
the map BEFORE awaiting the per-child SIGTERM-grace kills (up to
~10s each). If the operator double-taps mid-window, `killAllSync()`
snapshotted from the now-empty `byWorkspaceChannel.values()` and
silently no-op'd — the for-loop iterated nothing, `process.exit(1)`
fired, and any child still inside its SIGTERM grace window was left
orphaned with dangling pipes. Exactly the scenario the force-kill
path was added to handle.

Fix: introduce a separate `liveChannels: Set<ChannelInfo>` as the
source of truth for "channels with potentially-alive child
processes". Added in `getOrCreateChannel` alongside
`byWorkspaceChannel.set(...)`; removed only when `channel.exited`
fires (the OS-level "really dead" signal). `killAllSync()` now
iterates `liveChannels`, so a mid-shutdown second signal still
sees every still-alive child regardless of where the graceful
drain currently is. Other paths (`killSession` last-session reap,
`channel.exited` crash handler) automatically remove via the same
exit-handler hook.

Regression test:
- Builds two sessions on different workspaces
- Replaces each channel's `kill()` with a never-resolving Promise
  (simulating stuck SIGTERM grace)
- Calls `bridge.shutdown()` to enter mid-drain state
- Yields twice so shutdown's sync prefix runs (clears
  byWorkspaceChannel, starts the never-resolving awaits)
- Calls `bridge.killAllSync()` — pre-fix this saw an empty
  `byWorkspaceChannel` and the spy array would have been empty;
  post-fix both channels' `killSync` is invoked.

(tanzhenxin's other observation — channels-package duplicate ACP
bridge — is the same architectural concern as chiga0 finding 1+5,
already tracked under existing FIXME(stage-1.5) markers. No code
change in this commit for that.)
2026-05-13 14:47:47 +08:00
qqqys
fd53527aad
feat(cli): support batch deletion of sessions in /delete (#3733)
* feat(cli): support batch deletion of sessions in /delete

Closes #3706

Add multi-select mode to the session picker so /delete can remove
multiple sessions at once. Space toggles a checkbox on the cursor
item; Enter commits the checked set, falling back to single-select
when nothing is checked. The current active session is rendered
disabled with a "current — cannot delete" hint and is also stripped
defensively before the service call.

Core gains a `removeSessions(ids)` batch API that returns
`{ removed, notFound, errors }` so the CLI can surface partial
failures with a single toast.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

* fix(cli): address /delete batch-delete review comments

- useSessionPicker: when checked items are all hidden by the branch
  filter, do not silently fall through to single-deleting the cursor row
  (data-loss path); stay in multi-select mode instead.
- SessionPicker footer: count only the checked-and-visible-and-committable
  rows so "N selected" matches what Enter would actually delete.
- useDeleteCommand: partial-failure toast switches to type=error and
  surfaces failing session ids (truncated, capped at 3 with overflow)
  plus the first underlying error message, instead of just an aggregate
  count masquerading as info.
- Docs: fix Tab→Space JSDoc / inline-comment drift across the picker
  surface (Space is the actual binding).

Co-Authored-By: Qwen-Coder <noreply@qwenlm.com>

* docs(cli): clarify disabledIds is multi-select-only on session picker

Reviewer flagged that disabledIds is silently inert in single-select
mode because both its visual dim and Space no-op gate on
enableMultiSelect. Spell that out on both prop JSDocs and point future
callers at filtering initialSessions for single-select use cases.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

* fix(cli): address /delete batch-delete review feedback (round 2)

- useSessionPicker: throw when enableMultiSelect is on without
  onConfirmMulti (footer would otherwise read "N selected" while
  Enter silently fell through to single-select on the cursor row)
- useSessionPicker: single-select Enter fallback now respects
  disabledIdSet so a stray Enter on the dimmed active-session row
  no longer closes the dialog and bounces back with an error
- useDeleteCommand: stop swallowing the outer catch — log + surface
  the underlying error message so on-call has something to grep
- useDeleteCommand: full-failure branch now mirrors the partial-
  failure branch (failing ids + first error reason) instead of a
  generic "Failed to delete sessions."
- useDeleteCommand: emit a "Deleting N session(s)..." progress
  toast before awaiting removeSessions so slow filesystems don't
  leave the user staring at a closed dialog
- sessionService: JSDoc {@link removed}=false → {@link notFound}

Tests: regression for the disabled-row single-select fallback,
two invariant-throw tests for the picker, a pre-await progress
toast test, and align the StandaloneSessionPicker branch-filter
tests with main's Ctrl+B-only binding (plain 'B' silently entered
search mode after the merge, masking the assertion).

Co-Authored-By: Qwen-Coder <noreply@qwen.ai>

* test(cli): drop wait-based multi-select picker tests, cover at hook level

The Multi-select describe block in StandaloneSessionPicker.test.tsx
was 7 ink-rendering tests that all relied on `await wait(N)` to
sync with stdin events — the same flaky shape #3978 already
purged from the search suite. CI flaked on them once and they're
gone for good.

Critical invariants are re-asserted at hook level in
useSessionPicker.test.tsx (toggleChecked add/remove + no-op on
disabled ids), which exercise pure state without keypress sim.
Footer-rendering and keypress-driven flows are intentionally
left to manual verification rather than carried as wait-based
integration tests.

Co-Authored-By: Qwen-Coder <noreply@qwen.ai>

* test(cli): cover batch delete keyboard and failure paths

* fix(cli): address /delete batch-delete review feedback (round 3)

- useSessionPicker: Enter now commits *every* checked id (minus
  disabled), not just the filtered intersection. Filter is a
  navigation aid; gating the commit on it would silently drop
  checks the user explicitly made (check A-E, type a query
  matching only C-E, lose A and B). Order by sessionState.sessions
  so the receiver sees display order even for filter-hidden items.
- SessionPicker: footer count switches from visibleCheckedCount
  to committableCheckedCount (all checkedIds minus disabled), so
  it can no longer say "0 selected" while Enter is about to delete
  three hidden checks.
- useDeleteCommand: hoist sampleIds/overflow/firstError/reason
  above the three-way branch so partial- and full-failure paths
  can't drift out of sync on a future tweak.
- useDeleteCommand: in-flight ref guard wrapped in try/finally
  drops re-entrant /delete invocations (closeDeleteDialog runs
  synchronously, so without this the user can re-open /delete
  and queue an overlapping batch). Guard releases on early
  return too, otherwise a "only current selected" rejection
  would lock out the rest of the session.
- useDeleteCommand: surface "Current active session skipped."
  info toast when the picker forwarded the active session,
  otherwise the progress toast lies about the count.

Tests:
- useDeleteCommand: full-failure branch (removed=0), re-entrant
  drop, guard-released-on-early-return, stripped-current info toast
- useSessionPicker: hook-level keypress suite covering Space toggle,
  Space-disabled no-op, Enter→onConfirmMulti, Enter→onSelect
  fallback, Enter-disabled no-op, Enter commits hidden checks,
  Enter refuses when every check is disabled. MockStdin pattern
  cloned from useKeypress.test.ts, no ink rendering and no wait().

Co-Authored-By: Qwen-Coder <noreply@qwen.ai>

* chore(cli): trim delete-many comments

* fix(cli): guard delete actions during batch delete

* test(cli): cover batch delete guard release

* fix(cli): address session delete review feedback

* fix(cli): address batch delete review follow-ups

---------

Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
Co-authored-by: Qwen-Coder <noreply@qwenlm.com>
Co-authored-by: Qwen-Coder <noreply@qwen.ai>
Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com>
2026-05-13 14:34:39 +08:00
pomelo
faf646b081
docs(auth): add custom API key wizard PRD (#3583)
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-13 14:04:41 +08:00
Shaojin Wen
d07daa3e69
fix(cli): auto-restore prompt and preserve queue on cancel (#4023)
* fix(cli): auto-restore prompt and preserve queue on cancel; align with Claude Code

When a user pressed ESC immediately after submitting a prompt (before the
model produced any meaningful output), qwen-code left the cancelled prompt
stranded in the transcript and in cross-session ↑-history. Cancelling
during tool execution also silently dropped any queued follow-up input.

Mirror Claude Code's auto-restore-on-interrupt:

  - Drain the queue back into the input buffer on EVERY cancel path,
    including tool-execution cancels (replaces the unconditional
    clearQueue() that motivated #3204 with a non-destructive pop).
  - When the user cancels with no draft text, no queued input, and no
    meaningful pending/committed assistant content, truncate the user
    item and trailing INFO from history and pull the prompt text back
    into the input box for editing.
  - Add Logger.removeLastUserMessage so the disk-backed cross-session
    ↑-history (getPreviousUserMessages) is also cleaned on cancel.

The "meaningful content" check matches Claude Code's
messagesAfterAreOnlySynthetic: gemini text and tool runs are meaningful;
info/error/warning/retry/notification/tool_use_summary/thoughts are
synthetic. truncateToItem uses functional setState so it batches with
the INFO addItem from cancelOngoingRequest in the same render pass —
no flicker.

Tests cover all five guard branches and the logger undo across normal,
no-op, one-shot, MODEL_SWITCH-interleaved, disk-rotation, and
uninitialized cases.

* fix(core): clear lastLoggedUserEntry on logMessage write failure

Without this, a transient writeFile error during a USER logMessage left
the undo tracker pointing at the previous successful entry. A subsequent
removeLastUserMessage (e.g., from auto-restore on cancel) would then
silently delete an unrelated earlier row from disk-backed history.

Add a regression test that mocks a writeFile rejection and asserts the
tracker is null and the prior entry survives.

Reported in PR review.

* fix(cli, core): share Logger across AppContainer/useGeminiStream and serialize writes

PR-review follow-up addressing two issues in the cancel-undo path.

1. Logger instance mismatch (Critical):
   `useGeminiStream` and `AppContainer` each called `useLogger()`, which
   instantiates a fresh `Logger` per call. `lastLoggedUserEntry` lives on
   the instance, so the undo invoked from `AppContainer` was always a
   no-op — the cancelled prompt still surfaced via cross-session
   `getPreviousUserMessages`. Move the `useLogger` ownership to
   `AppContainer` and pass the same instance into `useGeminiStream` via a
   new optional `logger` parameter.

2. Logger write ordering:
   Both `logMessage` and `removeLastUserMessage` do read → splice/append
   → writeFile without a lock. A fast cancel-then-resubmit could let
   `removeLast` clobber a just-appended new entry. Add a per-instance
   `serialize()` helper (a Promise-chained write queue) and route both
   mutating ops through it. Reset the queue on `close()`. New regression
   test fires removeLast and a fresh logMessage in parallel and asserts
   the resubmitted entry survives.

3. Stale React-state race in cancel guard (Suggestion):
   The auto-restore guard read `pendingGeminiHistoryItems` from React
   state, which can lag a stream chunk that just set
   `pendingHistoryItemRef.current`. Snapshot the pending item at the
   start of `cancelOngoingRequest` and pass it through the new
   `onCancelSubmit({ pendingItem })` info parameter. The guard combines
   it with the React-state items so any meaningful in-flight content
   blocks auto-restore even before re-render. New test covers the case
   where pendingHistoryItems is empty but info.pendingItem carries
   `gemini_content`.

All touched-area suites pass: 64 cli AppContainer, 9 historyUtils,
85 useGeminiStream, 46 core logger.

* fix(cli): unbreak build after import-merge regression and tighten cancel-handler test types

The pre-commit eslint --fix on the previous commit collapsed the two
consecutive `import { ... } from '@qwen-code/qwen-code-core'` blocks in
useGeminiStream.ts into a single statement, but kept the `import type`
modifier from the first block — silently turning every runtime symbol
(SendMessageType, MessageSenderType, GitService, ApprovalMode, …) into
type-only imports. tsc rejected with TS2206 + a wave of TS1361 errors
that only surfaced on CI.

Restore the two separate imports: pure-type symbols (Logger included)
in `import type { ... }`, runtime symbols in plain `import { ... }`.

Also: the AppContainer cancel-handler tests captured `onCancelSubmit`
as `() => void`, but the hook signature now takes an optional info
arg. Widen the captured-callback type so passing `{ pendingItem }`
typechecks (TS2554 on line 1053).

* fix(cli, core): tighten cancel-undo robustness from PR review batch 3

Four follow-ups from a /review pass on the auto-restore-on-cancel path.

* logger.ts — only invalidate `lastLoggedUserEntry` when the failed
  write was itself a USER attempt. A failed non-USER write (MODEL_SWITCH
  on a transient disk error, etc.) doesn't change which row was the
  most recent user prompt, so the prior undo target is still valid.
  Without this, MODEL_SWITCH disk hiccups silently disabled cancel-undo.

* useGeminiStream.ts — wrap `onCancelSubmit` in try/finally so a throw
  in AppContainer's cancel handler can't strand the stream in
  Responding (the UI would lock — Esc would no-op until process
  restart). `setIsResponding(false)` and `setShellInputFocused(false)`
  always run.

* useGeminiStream.ts — also document the three-way coupling between
  the INFO `addItem` here and AppContainer's auto-restore guard:
  the guard reads `historyRef.current` which doesn't yet contain
  this INFO (React batches), and the guard's correctness depends on
  the items added here staying synthetic.

* historyUtils.ts — make `isSyntheticHistoryItem` exhaustive over the
  35-member `HistoryItemWithoutId` union. Every case is explicit; the
  default branch carries a `_exhaustive: never` so adding a new
  HistoryItem variant without classifying it triggers a compile-time
  error rather than silently disabling auto-restore. Runtime fallback
  is "meaningful" (safe — bail rather than wipe content).

Tests: +1 logger case (non-USER failure preserves the USER tracker),
+1 useGeminiStream case (throwing handler still flushes Responding).

All touched suites pass: 47 logger, 9 historyUtils, 86 useGeminiStream,
64 AppContainer.

* docs(core): clarify Logger writeQueue scope (log-history only, not checkpoints)

Reword the comment above `writeQueue` and the `serialize()` JSDoc to
state explicitly that the queue only serializes log-history mutations
(`logMessage` / `removeLastUserMessage`). Checkpoint ops
(saveCheckpoint / deleteCheckpoint / loadCheckpoint) touch separate
files and intentionally don't share this queue, so the previous
"every disk-mutating op chains here" wording overstated the
guarantee.

* fix(cli): flush buffered stream events before snapshotting pendingItem on cancel

Stream content/thought events are throttled into a per-turn `bufferedEvents`
array; only when `flushBufferedStreamEvents` runs do they reach
`pendingHistoryItemRef.current`. Snapshotting BEFORE the flush meant cancels
that fired inside the throttle window (60ms) saw a null `pendingItem` even
when meaningful text was sitting in the buffer. AppContainer's auto-restore
guard then read null, decided "model produced nothing", and called
`truncateToItem` — which silently wiped the very content that the
subsequent `addItem(pendingHistoryItemRef.current)` had just committed.

Move the snapshot to AFTER the flush so it sees the same value as the
addItem call directly below it.

Regression test: yields a content event and cancels without advancing
fake timers, asserts `info.pendingItem` carries the buffered "partial
response" text rather than null.

* fix(core): apply Logger.removeLastUserMessage in-memory removal synchronously

AppContainer's `userMessages` effect calls `getPreviousUserMessages()`
on the same render that history truncation fires (it depends on
`historyManager.history`). The previous implementation only updated
`this.logs` after `await fs.writeFile(...)` settled, so the effect
read stale logs and ↑-history surfaced the cancelled prompt until
some unrelated future history change re-ran the effect.

Move the cache filter ahead of the serialize queue so consumers see
the removal immediately. The async serialize op continues to read,
splice, and write disk, then re-syncs `this.logs` from disk on
success or rotation.

Regression test fires removeLast without awaiting, then asserts the
very next `getPreviousUserMessages()` returns [] (no cancelled
prompt), and that the background promise still resolves to true.

* docs(core, cli): clarify removeLastUserMessage contract; observability for cancel-undo

* logger.ts — extend the JSDoc on `removeLastUserMessage` to spell
  out the two-phase semantics (sync optimistic in-memory removal +
  async serialized disk reconciliation), and explicitly document that
  the boolean return value reflects the *disk* outcome while the
  in-memory cache is updated unconditionally. Also explain why disk
  failures are NOT rolled back: rolling back would resurrect the
  cancelled prompt in ↑-history, which is worse UX than a temporary
  cache/disk divergence (which converges on next op or on
  `initialize()` of the next session).

* AppContainer.tsx — wrap the fire-and-forget
  `logger.removeLastUserMessage()` in `.catch(debugLogger.debug)`.
  The Logger's internal try/catches mean the Promise should never
  reject today, but a future code-path change shouldn't surface as
  an UnhandledPromiseRejection — and a debug-level log is the right
  observability hook for "cancel succeeded in UI but disk-undo
  failed silently".

* fix(core,cli): #4023 review wave — logger atomicity + observable undo failure

3 #4023 review threads addressed:

- core/logger.ts: `removeLastUserMessage` now ROLLS BACK the
  optimistic in-memory removal when the disk read or write fails.
  Previously the JSDoc/return contract was violated: the method
  returned `false` on failure but `this.logs` already showed the
  entry removed — callers (AppContainer's `userMessages` effect)
  saw the inconsistency and the cancelled prompt vanished from
  ↑-history despite the disk still carrying it. The rollback
  re-inserts the target at its original index when no concurrent
  mutation took its place, and restores `lastLoggedUserEntry` so
  a follow-up retry has a target. Regression test pinned: spy on
  fs.writeFile to throw, assert `removed === false` AND
  getPreviousUserMessages() still surfaces the entry.

- cli/AppContainer.tsx: `void logger?.removeLastUserMessage()` no
  longer silently swallows failures. Added `.catch` that routes
  through `debugLogger.debug` so a disk-write failure leaves a
  diagnostic trail; without it the cancelled prompt would
  resurrect next session via ↑-history with no observability into
  why.

- cli/historyUtils.ts: `gemini_thought` / `gemini_thought_content`
  classification reaffirmed as SYNTHETIC with explicit JSDoc on
  WHY (Claude Code parity + auto-restore is most valuable in the
  cancel-during-thinking case which is exactly the case where
  thoughts have appeared but no committed `gemini_content`).
  Future readers won't re-litigate the classification by accident.

Tests: 49/49 logger.test.ts pass; tsc + ESLint clean.

* docs(core): align removeLastUserMessage JSDoc with rollback-on-failure behaviour

The previous commit added a rollback path to `removeLastUserMessage`
(re-insert the optimistically-removed entry and restore
`lastLoggedUserEntry` when the disk read or write throws), but the
JSDoc still said the in-memory removal is "intentionally NOT rolled
back" — a copy-paste leftover from the earlier design that picked
optimistic-and-diverge. Rewrite the failure-handling paragraph and
`@returns` line to describe the rollback contract instead.

No code change.

* fix(cli, core): scope auto-restore to the cancelled turn + tighten typings/tests

Three follow-ups from PR #4023 review batch 5.

* cli — `CancelSubmitInfo` gains `lastTurnUserItem` carrying the user
  prompt text that THIS turn's `prepareQueryForGemini` added (or
  `null` for paths that don't push a user history item: Cron /
  Notification / slash `submit_prompt`). `cancelOngoingRequest`
  snapshots `lastTurnUserItemRef.current` and ships it through. The
  AppContainer auto-restore guard now requires
  `info.lastTurnUserItem` to be present AND match the candidate
  user item's text before truncating/rewinding — closing the case
  where an older user item happens to be followed by only-synthetic
  trailing content and the current cancelled turn never owned a
  user item to begin with.

  Two new regression tests pin both halves: cancel of a non-USER
  turn bails despite trailing-synthetic, and a deliberate text
  mismatch also bails.

* cli — `.catch((err)` widened to `(err: unknown)` on the
  fire-and-forget `logger.removeLastUserMessage()` call. Belt-and-
  braces: `Promise.catch`'s lib typing is `(reason: any) =>` so
  this is not currently TS7006, but tightening keeps the codebase
  ready for `@typescript-eslint/no-implicit-any-catch`-style rules
  and matches the rest of the codebase's strict-error patterns.

* core — Added a `removeLastUserMessage` regression test pinning
  the `_readLogFile` failure branch (mocks `fs.readFile` to throw
  Permission denied). The symmetric `writeFile` failure case was
  already covered; this closes the gap on the read leg.

Tests: AppContainer 67/67 (+2), useGeminiStream 87/87, historyUtils 11/11,
logger 50/50 (+1). Type-check and lint clean.

* chore(cli): add debug observability for each auto-restore-on-cancel bail-out

The cancel handler in AppContainer has seven independent guards that
silently `return` when auto-restore is unsafe (buffer non-empty, queue
non-empty, pending meaningful content, no last-turn user item, no user
in history, trailing items not all synthetic, candidate-text mismatch).
Until now, users reporting "I pressed ESC but my prompt didn't come
back" had no way to know which guard tripped without a debugger.

Log a specific `debugLogger.debug(...)` line at each bail-out and one
on the success path. Debug level keeps production output silent;
re-enableable by running with `DEBUG=1` (per existing convention in
this file). No control-flow change.

* docs(core): scope removeLastUserMessage's "false ⇒ observable in-memory" guarantee

The previous JSDoc implied the guarantee held for every `false` return,
but it only really holds on the disk read/write THROW path (where we
roll back the optimistic in-memory removal). Two other `false`-paths
behave differently:

  - Initial guards (logger uninitialized / no tracked entry): nothing
    was ever removed, nothing to restore — entry stays in whatever
    state it was already in.
  - Disk read succeeds but the tracked row is missing on disk (e.g. a
    concurrent rotation/clear): we adopt disk state into `this.logs`,
    so both sides agree the entry is gone — `false` is returned but
    the entry is NOT observable in-memory either.

Rewrite the failure-handling paragraph and `@returns` line to spell
out both branches explicitly. No code change.

* fix(core): shift lastLoggedUserEntry on USER logMessage duplicate-skip

When `_updateLogFile` detects another instance already wrote an
identical (sessionId, messageId, timestamp, message) row and returns
null, the previous logMessage code path left `lastLoggedUserEntry`
pointing at the prior USER entry. A subsequent cancel/auto-restore
would then call `removeLastUserMessage()` and silently delete the
wrong row — typically an older prompt that the user did not intend
to undo.

The fix: when the duplicate skip happens on a USER attempt, advance
`lastLoggedUserEntry` to the entry object we just tried to write.
`_updateLogFile` mutates that object's `messageId` in-place to align
with the disk row before the duplicate check, so the 5-tuple matches
the row that's actually on disk and an undo correctly targets it.

The natural race (`max+1` colliding with an existing `messageId`)
is not reachable by sequential awaits — the snapshot used for the
duplicate check is always max+1-strict. The regression test drives
the contract directly by mocking `_updateLogFile` to resolve to
null and asserting `lastLoggedUserEntry` shifts to the new entry.

* fix(cli, core): strip orphan user entry from chat history on auto-restore

The auto-restore branch was cleaning up two of the three places a
cancelled prompt lives — the UI transcript via `truncateToItem` and
the disk-backed ↑-history via `Logger.removeLastUserMessage`. The
third — the in-memory chat history on `GeminiChat` — was left
untouched. `sendMessageStream` appends the user content to
`chat.history` BEFORE the stream generator runs and the abort path
doesn't pop it. After a successful auto-restore the next request's
wire payload still carried the cancelled prompt as a leading user
turn alongside the new prompt, so the model saw context the user
believed had been undone (and in some shapes the API would reject
two consecutive user turns).

Mirror the existing strip the Retry submit path uses
(`GeminiClient.sendMessageStream` at the `Retry` branch): make
`GeminiClient.stripOrphanedUserEntriesFromHistory` public and call
it from the auto-restore success path, sitting next to the UI
truncate and the disk-log undo. The method already pops trailing
user entries and clears the `FileReadCache` (which can otherwise
hold dangling `read_file` results from the stripped turn).

End-to-end reproduction from the PR review:
1. Submit `what time is it?` → ESC during pre-token delay →
   auto-restore (UI rewound, buffer pre-filled).
2. Edit buffer to `what year is it?` → submit.
3. Pre-fix: outbound `messages` carried both prompts as consecutive
   user turns. Post-fix: only the new prompt.

Test: extend the auto-restore-success AppContainer test with a
mock `stripOrphanedUserEntriesFromHistory` spy and assert it fires.
The non-restore branches don't install the spy (it's optionally
chained at the call site).

* fix(core, cli): tighten Logger.serialize signature + pin lastTurnUserItem and dup-skip identity contracts

Three follow-ups from PR review batch:

* core/logger.ts — `serialize()` was `this.writeQueue.then(op, op)`.
  The second callback was dead code: `writeQueue` is seeded with
  `Promise.resolve()` and reassigned through `.catch(() => undefined)`,
  so the queue tail can never reject. Worse, `then(op, op)` reads as
  "retry op on rejection" — wrong intent. Switch to `.then(() => op())`
  with a comment spelling out the no-reject invariant.

* cli/useGeminiStream.test.tsx — add ownership-contract tests at the
  PRODUCER side of `info.lastTurnUserItem`. Until now only the
  AppContainer tests pinned the contract, and they fabricate the
  value, so a regression that drops `lastTurnUserItemRef.current = {
  text: trimmedQuery }` in `prepareQueryForGemini` would slip
  through. New tests:
    - normal `UserQuery` submit → cancel → assert
      `info.lastTurnUserItem === { text: 'what time is it?' }`.
    - `SendMessageType.Notification` submit → cancel → assert
      `info.lastTurnUserItem === null` (path doesn't push a user
      history item, the ref reset at the top of
      prepareQueryForGemini must keep it null).

* core/logger.test.ts — strengthen the duplicate-skip regression.
  The previous test only checked the tracker advanced text; the
  important identity contract is that the recalculated 5-tuple
  matches the disk row, so a subsequent `removeLastUserMessage()`
  removes the duplicate-skipped row rather than the older USER.
  New test seeds disk with [first, second], stubs `_updateLogFile`
  for the second call to mimic the duplicate-skip branch (mutate
  newEntryObject's messageId+timestamp to align with the disk row,
  return null), then asserts removeLastUserMessage() leaves
  ['first'] on disk and removes 'second'.

* fix(cli, core): close four cancel-auto-restore correctness gaps from PR review

Four critical findings from gpt-5.5 /review pass:

1. **Retry skipped the lastTurnUserItem reset** (useGeminiStream.ts)
   `Retry` bypasses `prepareQueryForGemini`, which is where the
   `lastTurnUserItemRef.current = null` reset lived. A retry that
   followed a normal `UserQuery` carried the stale ownership snapshot
   into `onCancelSubmit`, and cancelling the retry before any
   meaningful output let `AppContainer` auto-restore truncate the
   original failed prompt. Move the reset (and the new content-seen
   reset, see #4) to the top of `submitQuery`, gated only on
   "this is a top-level submit" — covers Retry, Cron, Notification,
   and ordinary UserQuery alike.

2. **Text-only ownership matched dedup'd duplicates** (AppContainer.tsx,
   useGeminiStream.ts) `useHistoryManager.addItem` skips inserting a
   consecutive-duplicate user message while still returning a freshly
   generated id. The text-only ownership check would match the OLDER
   identical-text USER row, so a re-submitted same prompt + cancel
   would wrongly truncate the prior turn. Carry id+text in
   `CancelSubmitInfo.lastTurnUserItem` (using `addItem`'s return
   value) and require both id AND text to match before truncating.

3. **stripOrphan left IDE context state advanced** (client.ts) Other
   history-mutating paths (`setHistory`, `truncateHistory`) set
   `forceFullIdeContext = true` after mutating; the orphan-strip
   didn't, so a subsequent request could send a diff against a
   removed baseline. Gate cache-clear + IDE-context invalidation on
   an actual before/after length drop, so no-op strips don't churn
   state.

4. **Flush-then-thought race let auto-restore wipe committed content**
   (useGeminiStream.ts, AppContainer.tsx) `cancelOngoingRequest`'s
   pre-cancel flush can `addItem` a meaningful `gemini_content` (via
   handleContentEvent's split path) and then a later thought event
   overwrites `pendingHistoryItem` with a synthetic value. The
   AppContainer guard's React history snapshot is stale, so the
   trailing-only-synthetic check passes and the just-committed text
   gets truncated. Track a synchronous `turnSawContentEventRef` set
   in handleContentEvent, ship it through `CancelSubmitInfo`, and
   make the guard bail when set.

Tests:
- core/client.test.ts: stripOrphan only forces full IDE context on
  actual removal; existing retry tests updated to mock
  `getHistoryLength`.
- cli/useGeminiStream.test.tsx: ownership uses { id, text }, Retry
  reset works after a prior UserQuery cancel,
  turnProducedMeaningfulContent flips true when content lands.
- cli/AppContainer.test.tsx: guard bails on `turnProducedMeaningfulContent: true`,
  guard bails on id mismatch (catches addItem dedup case).

cli 162/162 + core client 99/99 + core logger 51/51.

* fix(cli): repaint static transcript after auto-restore truncate

Reported by @tanzhenxin: auto-restore truncated React `history` state
but the cancelled `> prompt` and `Request cancelled.` lines stayed
printed in the terminal — Ink's `<Static>` region is append-only, so
shrinking the underlying array doesn't unprint already-flushed lines.
On the PR's golden path (type prompt → Enter → ESC) the user sees the
prompt twice: once in scrollback, once pre-filled in the input buffer.
Confirmed at multiple Enter-to-ESC delays, so it's not a timing fluke.

Call `refreshStatic()` immediately after `truncateToItem(...)` in the
auto-restore success path. `refreshStatic` writes the ANSI
clear-terminal escape AND bumps the static remount key — the exact
recipe `/clear` (`handleClearScreen`) already uses for the same
reason. The targeted-repaint helper used for terminal resizes is
intentionally NOT used here: it preserves scrollback, which would
leave the cancelled prompt visible above the new viewport.

Test: extend the existing auto-restore happy-path AppContainer test
to assert `mockStdout.write` was called with `ansiEscapes.clearTerminal`.
The other auto-restore-bail tests don't install the assertion so they
naturally verify the negative case (no clear when guard rejects).
2026-05-13 12:08:16 +08:00
ChiGao
720ccaccb7
fix(core): normalize cumulative OpenAI stream deltas to suffixes (#3896)
Some OpenAI-compatible upstreams (notably specific DashScope / 阿里云百炼 Coding Plan paths) send `delta.content` as accumulated full text instead of incremental suffixes. The Gemini stream pipeline appended every chunk verbatim, so the same content was concatenated repeatedly during streaming.

Adds a per-stream `normalizeStreamingTextDelta` that detects cumulative-mode chunks (current chunk starts with previously emitted text) and emits only the suffix. Once cumulative mode is locked in, exact-repeat (≥64 chars) and prefix-overlap chunks are silenced. Normal incremental streams flow through unchanged.

The fix applies to both `delta.content` and `delta.reasoning_content` / `delta.reasoning`, with separate state slots so a switch between text and reasoning channels does not cause false-positive suppression. A separate `emittedLength` counter tracks user-visible bytes so an incremental-then-cumulative hybrid stream slices the correct suffix even after the 1024-byte detection-window cap kicks in.

Fixes #3279, #1184, #3838, #3806.
2026-05-13 12:05:42 +08:00
Edenman
533daac316
feat(cli): wrap markdown links in OSC 8 so wrapped URLs stay clickable (#4037)
* feat(cli): wrap markdown links in OSC 8 so wrapped URLs stay clickable

Long URLs the model emits inside `[label](url)` or as bare `https://...`
get line-wrapped by the terminal, which prevents most emulators from
detecting them as a single clickable region. OSC 8 hyperlinks decouple
the link target from the visible label so the entire label remains one
clickable target regardless of where it wraps.

- Extract the existing OSC 8 helpers from AuthenticateStep into a shared
  packages/cli/src/ui/utils/osc8.ts util, plus a dependency-free
  capability detector that honors NO_COLOR / FORCE_COLOR=0 / CI /
  non-TTY stdout, with FORCE_HYPERLINK=1 and QWEN_DISABLE_HYPERLINKS=1
  overrides for explicit opt-in / opt-out.
- Wire InlineMarkdownRenderer to wrap markdown link labels and bare
  autolinks in an OSC 8 envelope when supported. Wrapping happens after
  the inline link token has been fully matched, so streamed partial
  chunks cannot split an envelope across flushes.
- Fall back to the legacy `label (url)` rendering byte-for-byte when
  the host terminal does not advertise OSC 8 support.

Closes #3954

* fix(cli): harden OSC 8 markdown wrapping after multi-round audit

Address findings from a multi-round design and code audit of the OSC 8
hyperlink feature:

Design fixes:
- Keep the visible `(url)` suffix in supported terminals too — preserves
  copy-paste UX and lets users preview suspicious URLs before clicking.
  OSC 8 is now purely additive (byte-identical unsupported output, plus
  envelope on supported terminals).
- Restrict OSC 8 wrapping to http/https/mailto/ftp/sftp/ssh schemes;
  javascript:/data:/file:/vbscript: fall through unwrapped so the user
  can read the target. Prompt-injection defense for LLM output.
- Reject URLs with whitespace — every terminal treats whitespace in an
  OSC 8 target as truncation/rejection, which would turn the whole
  region into an un-clickable trap.
- Block OSC 8 inside tmux/screen by default; require `FORCE_HYPERLINK=1`
  opt-in. The multiplexer hides the host terminal's capabilities, so
  emitting passthrough escapes on a host without OSC 8 prints garbage.
- Version-gate `supportsHyperlinks()` (iTerm ≥3.1, vscode ≥1.72, WezTerm
  ≥20200620, VTE ≥0.50 with 0.50.0 segfault carve-out), block CI /
  TEAMCITY / win32 (modulo WT_SESSION/Kitty/Ghostty/DOMTERM), mirror
  `supports-hyperlinks` semantics.
- Extend the link regex to allow one level of balanced parens in the
  URL group so `[wiki](https://en.wikipedia.org/wiki/Foo_(bar))` isn't
  truncated at the inner `)`.
- Trim trailing sentence punctuation off the OSC 8 *target* for bare
  URLs (`.`, `,`, `;`, `:`, `!`, `?`, `'`, `"`, `` ` ``) and unbalanced
  trailing `)]}` so the clickable URL resolves to a real page.
- Catch VTE 0.50.0 reported in packed form (`'5000'`) — the original
  string compare missed it and let the segfault through.

Code fixes:
- Consolidate `wrapForMultiplexer` with the pre-existing
  `packages/cli/src/utils/osc.ts` — no more duplicate helpers.
- Drop the `supportsHyperlinks` memoization cache so runtime env changes
  (NO_COLOR / theme toggles) take effect immediately.
- Extract `MD_LINK_PATTERN`, `MD_LINK_CAPTURE`, `shouldWrapMarkdownLink`,
  and `HYPERLINK_ENV_KEYS` into `osc8.ts` so the React and ANSI
  renderers stay in lockstep.
- Hoist `supportsHyperlinks()` once per render (both renderers).
- Apply the same OSC 8 treatment to `TableRenderer` so markdown links
  inside tables are clickable too.
- Rewrite `trimTrailingUrlPunctuation` to O(n) by pre-counting opens.

Tests cover: balanced parens in URL, dangerous-scheme rejection,
whitespace-URL rejection, trailing-punctuation trimming, tmux blocking,
version gating (iTerm/WezTerm/vscode/VTE incl. packed form), platform
fallbacks, mid-stream chunk balance, byte-identical legacy fallback.

* feat(cli): detect Alacritty / Konsole / Warp / JetBrains / mintty for OSC 8

Expand supportsHyperlinks() to recognize five more capable terminals
that the original detector silently treated as unsupported:

- Alacritty ≥ 0.11 via TERM=alacritty (the issue explicitly calls this
  one out)
- Konsole ≥ 21.04 via KONSOLE_VERSION
- WarpTerminal via TERM_PROGRAM=WarpTerminal
- JetBrains JediTerm (IDE integrated terminals) via TERMINAL_EMULATOR
- mintty (Git Bash on Windows, etc.) via TERM_PROGRAM=mintty

Hyper stays auto-detection-off (FORCE_HYPERLINK=1 override) because
plugin chains have a long history of breaking escape passthrough.
Apple_Terminal stays off because it has no OSC 8 support at all.

KONSOLE_VERSION and TERMINAL_EMULATOR added to HYPERLINK_ENV_KEYS so
the test isolation list stays in sync.

* chore(cli): polish OSC 8 detector after another audit round

Address findings from the final multi-round audit pass:

- Document `FORCE_HYPERLINK` and `QWEN_DISABLE_HYPERLINKS` in the
  user-facing env-vars table at docs/users/configuration/settings.md so
  the new opt-in / opt-out surface is discoverable without grepping
  source.

- Detect Alacritty even when the alacritty terminfo entry isn't
  installed (a common Linux distro scenario where Alacritty falls back
  to TERM=xterm-256color). Fall back to ALACRITTY_LOG /
  ALACRITTY_WINDOW_ID / ALACRITTY_SOCKET — Alacritty sets at least one
  of these unconditionally since 0.12.

- Trim a trailing `>` off the OSC 8 target so CommonMark autolinks
  (`<https://example.com>`) produce a clickable target that actually
  resolves instead of 404-ing because of the captured delimiter.

- Add OSC 8 / hyperlink env isolation to TableRenderer.test.tsx so a
  developer running the suite from iTerm2 / WezTerm / Kitty can't leak
  escape bytes into table output.

- Symmetric `isTTY` reset in osc8.test.ts `beforeEach` so the early
  describes (sanitizer, scheme, trim) don't inherit residual TTY state
  from a prior test.

- Document the deliberate security property of keeping the visible
  `(url)` suffix in OSC 8 mode (user always reads the destination
  before clicking) in the SAFE_OSC8_SCHEMES comment.

- Collapse the `wrapForMultiplexer` import + re-export to a single
  `export { wrapForMultiplexer }` after the local import.

- Add ALACRITTY_* keys to HYPERLINK_ENV_KEYS so test isolation lists
  stay complete.

Tests cover the new autolink `>` trim, the Alacritty env-var
fallbacks, and NBSP / Unicode-whitespace URL rejection.

* fix(cli): tighten OSC 8 gating per PR review

Two fixes from chiga0's review on PR #4037:

1. Move the non-TTY check above `FORCE_HYPERLINK` so a user with
   `FORCE_HYPERLINK=1` in their shell profile still gets a clean pipe
   when they run `qwen | cat` or `qwen > out.txt`. The "non-TTY stdout
   must suppress escapes" acceptance criterion now holds even under
   forced enable.

2. Version-gate the Konsole detection at `>= 21.04`. KONSOLE_VERSION
   is set by every Konsole release including ones that pre-date OSC 8
   support, so the existence check alone false-positives on Konsole
   20.x. Parse the packed integer (21.04 → 210400) and let older
   releases fall through to the legacy fallback.

Updates the docs row for FORCE_HYPERLINK to make the non-TTY caveat
explicit. Splits the prior "FORCE_HYPERLINK + isTTY=false" test into
two — one verifying force works on a TTY, one asserting it never
escapes the non-TTY guard. Adds a Konsole < 21.04 regression test.

* fix(cli): stop auto-detecting Warp Terminal as OSC 8 capable

Warp's current rendering engine doesn't honor OSC 8 envelopes — the
escape sequence is printed as visible garbage rather than recognized
as a clickable hyperlink. Falling through to the legacy `label (url)`
rendering avoids the regression on Warp.

Users on a Warp build that ever ships OSC 8 support can opt in with
`FORCE_HYPERLINK=1`; the case will be reinstated in the switch when
Warp lands real support upstream.

Test flipped from "enabled" to "not auto-detected, FORCE_HYPERLINK
opts in" to lock the new behavior.

* feat(cli): drop visible (url) suffix when OSC 8 wrapping is active

In the originally shipped renderer, `[label](url)` was rendered as
`label (url)` even when OSC 8 wrapped the region. With long URLs that's
clutter for no benefit — capable terminals already expose the target
via hover / status bar / right-click "copy link" without needing the
URL in the visible stream.

When `shouldWrapMarkdownLink(url, canHyperlink)` returns true, the
React renderer and the ANSI table renderer now emit only the markdown
label (link-colored), with the OSC 8 envelope pointing at the full URL.
Empty labels (`[](url)`) fall back to using the URL as the visible
label so the link stays discoverable.

When the predicate returns false (unsupported terminal, unsafe scheme,
whitespace URL) the legacy `label (url)` rendering is preserved
byte-for-byte — the scheme allowlist still guarantees the user sees
the destination before any click on a `javascript:` / `data:` / etc.
link.

Tests updated to assert label-only visible bytes in wrap mode and an
empty-label fallback case added. Comment block in `osc8.ts` updated to
reflect the new visibility contract.

* fix(cli): strip C1 controls in OSC 8 sanitizer

sanitizeForOsc() only removed C0 + DEL, so 8-bit ST (\x9c) and 8-bit
OSC (\x9d) bytes could still survive inside an OSC 8 target. On
terminals that honor C1 controls, those bytes act as the same sequence
boundaries as their two-byte ESC counterparts, which defeats the
escape-injection hardening this helper is meant to provide. Extend
the regex to also strip \x80-\x9f and cover the case with a test.

* fix(cli): harden OSC 8 link sanitization and tighten gating

Three independent issues found while auditing the markdown OSC 8 path:

1. sanitizeForOsc() previously left Unicode bidi controls (U+200E/F,
   U+202A-E, U+2066-9) and line/paragraph separators (U+2028/9) intact.
   A model-emitted RLO in a link label visually reverses trailing bytes,
   spoofing the host the user thinks they're clicking — exactly the
   click-deception attack the scheme allowlist is meant to block, just
   moved from the URL into the visible label. Extend the regex to strip
   those bytes too.

2. The visible label rendered inside the OSC 8 envelope went straight
   to the terminal without sanitization, so even with (1) the spoof
   would still land. Wire sanitizeForOsc() over the linkText in both
   InlineMarkdownRenderer and TableRenderer's OSC 8 branches. The
   legacy `label (url)` branches stay untouched so today's
   unsupported-terminal output remains byte-identical.

3. AuthenticateStep emitted osc8Hyperlink(authUrl) unconditionally,
   leaking escape bytes into pipes / non-OSC-8 terminals — inconsistent
   with the suppression contract documented for the rest of the PR.
   Gate it on supportsHyperlinks() so it falls back to the bare URL.

Test coverage added:
- sanitizeForOsc bidi/line-separator strip
- bidi spoof in the rendered markdown label
- byte-equality fallback on unsupported terminals
- TableRenderer markdown link → OSC 8 (positive, fallback, unsafe
  scheme, bidi-spoof) — the table renderer had zero OSC 8 coverage
  before this.

* fix(cli): keep `(url)` visible when an OSC 8 label looks like a different URL

Adversarial round-2 audit identified a label-as-URL deception attack:
when the OSC 8 branch elides the `(url)` suffix and shows only the
clickable label, a model-emitted `[https://google.com](https://attacker.com)`
renders a "google.com" link that resolves to attacker.com. Pre-OSC-8
rendering kept `(url)` visible so the user could see the real target;
hiding it makes the click-deception case land.

Mitigation: a new `labelMayDeceive(label, url)` predicate. When the
label contains a URL-shaped substring AND it doesn't equal the actual
target, both renderers keep the legacy `(url)` suffix while still
emitting the OSC 8 envelope — the link stays clickable, the user
still sees where the click goes.

Heuristic is permissive on purpose: false positives are harmless
(redundant `(url)` on niche labels), false negatives let a real spoof
through.

Tests: positive (mismatched URL labels), negative (label == url, plain
text labels), in both InlineMarkdownRenderer and TableRenderer.

* fix(cli): catch bare-host label deception in OSC 8 wrapping

Round-3 audit caught a false-negative in labelMayDeceive: the
`://` substring check only flagged labels with a fully-qualified URL
shape. The most natural markdown spoof — `[google.com](https://evil.com)`
— uses a bare host as the label and slipped past, so the OSC 8 branch
elided the `(url)` suffix and rendered a clickable "google.com" that
resolved to evil.com.

Add a third detection pattern: extract host-like tokens from the
label (`name.tld` with an alphabetic 2+ char TLD), and flag the link
when any of them doesn't equal the URL's parsed hostname. Plain
labels like `docs` / `click here` don't match the regex, version
strings like `1.2.3` are skipped (last segment is numeric), and
`[google.com](https://google.com)` is honest rendering — none of these
get flagged.

ASCII-only matching means an IDN-homograph attack on a bare-host label
(Cyrillic `о`) still escapes this layer; the fully-qualified form of
the same attack is still caught by the existing `://` rule, which is
the only form an LLM is realistically likely to emit.

Tests cover: bare-host mismatch, punycode IDN target, same-host /
different-path, label==target negative, plain-text labels, version
strings.

* fix(cli): handle mailto: target in labelMayDeceive

Round-4 audit caught a false positive: `new URL('mailto:x@y').hostname`
is empty, so targetHostname() returned undefined and the defensive
`return true` branch fired any time a mailto label contained an
email-shaped string. A perfectly honest
`[support@example.com](mailto:support@example.com)` was being flagged
as deceptive and getting a redundant `(url)` suffix on capable
terminals.

Special-case mailto: by pulling the domain from after the `@` in the
URL pathname, matching what the user would compare against.
A mismatched mailto (e.g. `[support@example.com](mailto:abuse@evil.com)`)
still flags correctly.

Also drop a dead `HOST_LIKE_RE.lastIndex = 0` reset — `.match()` doesn't
consult lastIndex, so the line was a no-op.

* fix(cli): catch IPv4-literal label deception in OSC 8 wrapping

Round-5 audit found another bare-host bypass: a label like
`[1.1.1.1](https://attacker.com)` (or any other dotted-quad such as
`[192.168.1.1]` / `[8.8.8.8]`) escaped labelMayDeceive because the
existing host regex anchors on a 2+ alphabetic TLD. The user would
see a clickable "1.1.1.1" that resolves to attacker.com with no
visible target.

Add a separate dotted-quad pattern and combine it with the host-token
list before comparing against the URL's hostname. False-positive
surface is small (over-permissive on octet ranges is harmless — worst
case is an extra `(url)` suffix on a label like `999.999.999.999`).

Tests cover mismatched IPv4, IPv4 spelled inside surrounding text,
and label-equals-target IPv4 (which must NOT flag).

* fix(cli): sanitize URL when rendered as visible text in OSC 8 path

Two PR review findings:

1. config-utils.ts dropped the `resolvePath(...)` call (and its import)
   that origin/main introduced in #4045 for tilde / relative `cwd` paths
   in channel configs. The auto-merge silently reverted it the same way
   it did `packages/channels/base/src/index.ts`. Restore main's content.

2. Anti-spoof sanitization was only applied to `linkText`, but the
   OSC 8 render path emits the URL as visible text in two places that
   bypassed it:
   - empty-label fallback `safeLabel || url` — `[](https://x/a‮evil)`
     would print the URL with RLO intact even though the OSC target was
     sanitized.
   - deceptive-label `(url)` suffix.

   Compute `safeUrl = sanitizeForOsc(url)` once in the OSC 8 branch and
   use it for both visible-URL renderings. The OSC target inside
   `osc8Open` keeps the raw URL (sanitization happens inside the helper
   anyway). Same fix mirrored in `TableRenderer.tsx`. The legacy
   `label (url)` branch on unsupported terminals stays untouched so its
   byte-identical-fallback contract holds.

Test added: `[](https://example.com/a‮evil)` round-trips through the
renderer with the RLO stripped from both the OSC target and the visible
URL fallback.
2026-05-13 11:37:27 +08:00
tanzhenxin
7099165dae
fix(core): log internal OpenAI JSON requests (#4081)
* fix(core): log internal OpenAI JSON requests

* fix(core): avoid duplicate OpenAI log metadata
2026-05-13 09:19:44 +08:00
jinye
aecea70114
docs(telemetry): align config and docs semantics for target, outfile, and CLI flags (#4066)
* docs(telemetry): align config and docs semantics for target, outfile, and CLI flags

- Remove stale warning note "This feature requires corresponding code
  changes" — the OTLP implementation is now complete (#3779, #4061)
- Clarify that `target` is an informational destination label and does
  not control exporter routing; `otlpEndpoint` or `outfile` must be set
  to configure where data is sent
- Mark `--telemetry-target` CLI flag as deprecated in the configuration
  table to match the deprecateOption() call in cli/src/config/config.ts
- Fix `outfile` / `QWEN_TELEMETRY_OUTFILE` descriptions: remove the
  incorrect "when target is local" qualifier — outfile overrides OTLP
  export regardless of the target value
- Simplify the file-based output example by removing the now-redundant
  `"target": "local"` and `"otlpEndpoint": ""` fields

Closes the "Align telemetry config and docs semantics for target,
useCollector, otlpEndpoint, otlpProtocol, and outfile" checklist item
in #3731.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* docs(telemetry): address Copilot review comments on outfile and target descriptions

- Fix outfile table row in telemetry.md: "overrides `otlpEndpoint`" →
  "overrides OTLP export" (outfile disables all OTLP exporting, not
  just the base endpoint)
- Use fully-qualified setting names (`telemetry.otlpEndpoint`,
  `telemetry.outfile`) in the target description in settings.md for
  consistency with the rest of the table

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* docs(telemetry): update QWEN_TELEMETRY_TARGET env var description and add outfile note

- Align QWEN_TELEMETRY_TARGET env var description with the updated
  telemetry.target setting semantics (informational label, not routing)
- Add a note after the file-based output example clarifying that outfile
  automatically disables OTLP export

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
2026-05-13 08:27:41 +08:00
jinye
14512080ed
feat(telemetry): add hierarchical session tracing spans (#4071)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* feat(telemetry): add hierarchical session tracing with interaction spans

Add OpenTelemetry-based session tracing that creates hierarchical spans
for user interactions. Interaction spans wrap the full sendMessageStream
lifecycle and are properly ended at all exit points (ok, error, cancelled).

- New session-tracing.ts module with concurrent-safe span management
- Interaction spans started for UserQuery/Cron/Notification message types
- Shutdown safety net ends active interaction span before SDK shutdown
- Memory-safe span storage using WeakRef + strongRefs + 30-min TTL cleanup
- NOOP_SPAN sentinel avoids leaked spans when SDK is not initialized

* fix(telemetry): address review comments on session-tracing

- TTL cleanup now sets ctx.ended=true before span.end() to prevent double-end
- Interaction spans stored in strongSpans for GC-safe TTL cleanup
- cancelled status uses SpanStatusCode.OK per codebase convention
- endLLMRequestSpan treats missing metadata as OK, not ERROR
- Module-level lastInteractionCtx fallback for shutdown outside ALS context
- try/finally safety net in sendMessageStream for uncaught exceptions

* fix: apply auto-fixes from /review

- Fix next-speaker continuation span leak by overriding options.type to Hook
- Add setStatus call to endToolExecutionSpan for consistent span status
- Sanitize API error messages in interaction span status

---------

Co-authored-by: jinye.djy <jinye.djy@alibaba-inc.com>
2026-05-13 00:03:31 +08:00
ChiGao
51ee87539c
revert(deps): downgrade ink 7 → 6 to fix Static-remount TUI regression from #3860 (#4083)
Some checks failed
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
SDK Python / Classify PR (push) Has been cancelled
SDK Python / SDK Python (3.10) (push) Has been cancelled
SDK Python / SDK Python (3.11) (push) Has been cancelled
SDK Python / SDK Python (3.12) (push) Has been cancelled
* revert(deps): downgrade ink 7.0.2 → 6.x to fix Static-remount regression from #3860

PR #3860 upgraded ink 6.2.3 → 7.0.2 with the claim of "no business code
changes." In production this turns out to break the TUI:

- After `/clear`, the next user message and AI response do not render
  to the static history area — only the dynamic spinner/input area is
  visible (#3860 + chore/upgrade-ink-7 branch reproduce this).
- After Ctrl+O (TOGGLE_COMPACT_MODE), the screen is cleared and stays
  blank.
- Any `refreshStatic()` call path (auth refresh, model change, render-
  mode switch, /clear, Ctrl+O) puts the UI into the same "muted" state.

Root cause is an ink 7 regression: when `<Static>` is remounted by
changing its `key` prop, the new instance's items are never written to
stdout. A 30-line minimal repro (pure ink + Static + key++) confirms
this independently of qwen-code.

Closest upstream issue: vadimdemedes/ink#773
(useLayoutEffect-driven child stripping in <Static>). PR #905
("Fix dangling staticNode reference") merged into ink 7 fixed the
unmount-OOM path but not this remount path. No upstream issue yet
matches the "remount loses content" case — we should file one and
ship a re-upgrade once it is resolved.

Scope of this revert (intentional partial revert of #3860):

- ink ^7.0.2 → ^6.2.3 (cli + root hoist)
- react / react-dom 19.2.4 pin → ^19.1.0 (cli direct, root overrides
  removed)
- wrap-ansi ^10.0.0 → 9.0.2 (cli direct, root override restored)
- react-devtools-core kept at ^6.1.5 (still ink-6 compatible — ink
  6.8.0's peerOptional requires >=6.1.2; downgrading to 4.x would
  re-introduce a conflict)
- @vitest/eslint-plugin pin "1.3.4" → "^1.3.4"
- "@types/node" override removed (was only needed for ink 7's Node 22
  type drift)

What this revert keeps:

- Node engines >=22 across root / cli / core / sdk / web-templates and
  the matching Dockerfile / .nvmrc / CI matrix work. PR #1876 followed
  up by adding Node 24 support to the matrix, and rolling those back
  would conflict with that work. The visible bug is the ink runtime
  regression, not the engine bump.
- doctorChecks.ts MIN_NODE_MAJOR = 22 (matches engines).
- The test gating that #3860 added for ink-7 input throttle (AuthDialog
  / AskUserQuestionDialog / InputPrompt). With ink 6 these tests would
  pass un-gated, but leaving the gate in place is harmless and a
  follow-up can un-gate them. Keeping this revert minimal.

Verification (local, ink 6.8.0 single instance):

- npm ls ink → single ink@6.8.0
- npm ls react → single react@19.2.4 (kept by vscode-ide-companion
  workspace pin; ink 6 is fine on 19.2)
- npm run typecheck --workspace=packages/cli → clean
- AppContainer.test.tsx 61/61 pass
- MainContent.test.tsx 6/6 pass
- clearCommand.test.ts 13/13 pass

Re-upgrade path: once ink ships a fix for the Static-remount
regression, redo this upgrade behind the feat/virtual-viewport-on-ink7
branch where the `<Static>` + clearTerminal combo is replaced by an
overflowY=hidden self-managed viewport.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* ci(fix): keep wrap-ansi 10 + skip 1 ink-7-specific TableRenderer test

The initial revert downgraded wrap-ansi to 9.0.2 (the pre-PR-#3860
state). After rebasing onto current main, PR #4050 (preserve table
ANSI color across wrapped lines) brought in a new test
("does not preserve foreground after an explicit foreground reset")
whose wrap point depends on ink 7's <Text> wrapping behavior.

Two-part fix:

1. Restore wrap-ansi to 10 (cli direct dep). The wrap-ansi version is
   independent of the ink regression we're reverting — wrap-ansi 10
   has no peer-dep tie to ink 7 — and #4050's TableRenderer code on
   main already assumes wrap-ansi 10. Keeping the wrap-ansi bump
   removes the root override for wrap-ansi (was forcing all transitives
   to 9.0.2) so cli's TableRenderer gets the wrap-ansi 10 it expects,
   while ink 6's transitive wrap-ansi naturally resolves to 9 (its own
   declared range) — no conflict.

2. Skip the one new test that asserts a specific wrap position. The
   other assertions in that test (foreground cleared, equal visible
   widths) still pass on ink 6 — only `expectWrappedContinuation` is
   ink-7-specific. The sibling test 'does not preserve foreground
   after an explicit reset' (using \\u001b[0m instead of \\u001b[39m)
   still passes unmodified on ink 6, so the ANSI-handling logic itself
   is verified end-to-end. The TODO marker references the re-upgrade
   path.

Local verification:

- TableRenderer.test.tsx: 54/54 pass + 1 skipped
- AppContainer.test.tsx: 61/61 pass
- MainContent.test.tsx: 6/6 pass
- clearCommand.test.ts: 13/13 pass
- npm run typecheck --workspace=packages/cli: clean
- npm ls ink → single ink@6.8.0
- npm ls wrap-ansi → cli direct: 10.0.0; ink 6 transitive: 9.0.2
  (no conflict, no override)

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-12 17:11:17 +08:00
ChiGao
dc7a90c4ac
fix(cli): preserve table ANSI color across wrapped lines (#4050)
Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
2026-05-12 16:09:39 +08:00
qqqys
e59c7b8b68
fix(channels): expand tilde in channel cwd config (#4045)
Channel `cwd` from settings.json was passed verbatim to
`child_process.spawn()` in AcpBridge, so `"cwd": "~/xomo"` made the
kernel fail to chdir and surfaced as a misleading
`spawn /usr/bin/node ENOENT`.

Expand `~` / `~\` and resolve relative paths to absolute in
`parseChannelConfig` so all downstream consumers (AcpBridge spawn,
SessionRouter, WeixinAdapter, ChannelBase) only ever see absolute
paths. Reuses the existing `resolvePath` helper from
`@qwen-code/channel-base` (previously used internally by
`getGlobalQwenDir`).

Fixes #3998

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-12 16:09:35 +08:00
易良
1936420dcb
ci(e2e): stabilize MCP/CLI flows and cancel stale main runs (#4039)
* test(e2e): stabilize MCP tool message flow

* ci(e2e): cancel stale main E2E runs

* test(e2e): accept paired MCP tool results

* test(e2e): stabilize monitor tool check

* test(e2e): stabilize run_shell_command file-listing assertion

The model consistently picks list_directory over run_shell_command
for file-listing prompts. Make the prompt explicit about which tool
to use, matching the approach taken for the MCP tool flow test.
2026-05-12 16:09:30 +08:00
jinye
826f9fd126
doc[sdk-python] Expand Python SDK usage documentation (#3995)
* docs(sdk-python): expand usage examples

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(docs): correct file_path key and update session resume examples

* fix(docs): add is_error handling and async iteration to SDK examples

- Session Resume examples now check is_error before printing result,
  consistent with the print_result helper in Quick Start
- Permission Callback examples now wrap query() in async def main()
  with async for iteration, so the CLI process actually starts

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* docs(sdk-python): address review feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-12 15:27:00 +08:00
Salman Chishti
70eecdbdf4
Upgrade GitHub Actions for Node 24 compatibility (#1876)
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-05-12 15:02:33 +08:00
Zach He
8e18e64594
feat(dashscope): support DASHSCOPE_PROXY_BASE_URL for prompt cache via API gateway (#3991)
Adds `DASHSCOPE_PROXY_BASE_URL` env var so DashScope prompt-cache headers fire when requests go through an internal API gateway proxy URL.

- New constant `DASHSCOPE_PROXY_BASE_URL` in `openaiContentGenerator/constants.ts`, read once at module load from `process.env`.
- `isDashScopeProvider()` now also returns true when `baseUrl` matches the configured proxy URL (case-insensitive, trailing slash on either side normalized).
- Debug log fires on the configured-but-mismatched path with a static message (no URL interpolation, so embedded credentials in the proxy URL cannot leak to `QWEN_DEBUG_LOG_FILE`).
- 9 new test cases covering match, mismatch, debug-log assertion, and trailing-slash normalization. Uses `vi.stubEnv` + `vi.unstubAllEnvs` in `beforeEach`.

Closes #3991
2026-05-12 14:48:10 +08:00
ChiGao
3d664336df
fix(cli): improve rendering on narrow terminals (#3968)
* fix(cli): improve rendering on narrow terminals

- TableRenderer: switch to vertical format when contentWidth < 60 cols,
  preventing wide horizontal tables from overflowing into scrollback on
  narrow terminals.
- Composer: suppress bottom loading indicator when terminal width ≤ 30
  cols during streaming, avoiding unnecessary redraws on ultra-narrow
  terminals.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): cover narrow-terminal rendering branches + tighten thresholds

Address review feedback on #3968:

- Composer: drop redundant `isStreaming &&` guard from
  `suppressBottomLoadingIndicator`; the trailing
  `=== StreamingState.Responding` already implies streaming, and the
  redundancy risked future drift if `isStreaming` were extended.
- Composer.test: add four cases pinning the suppression contract —
  Responding @ 25/30 cols hides, @ 31 cols shows, and
  WaitingForConfirmation @ 25 cols still shows so confirmation
  prompts never disappear on narrow terminals.
- TableRenderer: replace the content-agnostic 60-col floor with a
  column-aware threshold
  (`max(24, colCount * MIN_COLUMN_WIDTH + borderOverhead + SAFETY_MARGIN)`)
  so a 2-column table with short values renders horizontally on a
  ~30-col terminal instead of being forced into vertical mode. The
  existing `maxLineWidth` post-build check still catches actual overflow.
- TableRenderer.test: add explicit horizontal-vs-vertical threshold
  cases (2 cols @ 60/30/20 and 5 cols @ 30) and bump the alignment
  tests to contentWidth=60 with `┌` guards so they fail loudly if the
  threshold ever pushes them back into vertical no-op mode.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): preserve esc-to-cancel on narrow terminals + boundary tests

Address review feedback on the narrow-terminal rendering changes:

- Composer: when the full LoadingIndicator is suppressed on ≤30-col
  terminals during Responding, render a minimal "(esc to cancel)" text
  fallback so users retain the cancel affordance. Suppressing the full
  indicator still avoids layout breakage, but the affordance now stays
  visible.
- TableRenderer: clarify that `borderOverhead` is reused by the
  horizontal-vs-vertical layout threshold so a future change to the
  border-width formula does not silently shift the threshold.
- TableRenderer tests: add equality boundary cases at
  `ABSOLUTE_MIN_HORIZONTAL_TABLE_WIDTH` (24) and at the 5-column
  column-budget threshold (35), plus one-below cases, so a future
  `<` → `<=` regression on the strict comparator is caught.
- Composer tests: assert the fallback string is rendered when (and
  only when) the full indicator is suppressed.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): use existing 'Esc to cancel' i18n key for narrow-terminal fallback

The 169031d9c fix passed `'(esc to cancel)'` to `t()` but no such key
exists in any locale file. `t()` falls back to returning the key
verbatim, so English users saw correct text but non-English locales got
the untranslated English string — i.e. zero i18n coverage.

The repo already ships a translated `'Esc to cancel'` key in all 9
locales (used by QwenOAuthProgress and similar). Reuse it and move the
parentheses outside the call so the surrounding `()` is layout-only,
not translatable.

* test(Composer): update esc-fallback assertions to match i18n key casing

The cada422f8 fix switched the fallback from `t('(esc to cancel)')` to
`({t('Esc to cancel')})` (using the existing translated key). The
Composer tests still asserted lowercase 'esc to cancel' which no longer
appears in the rendered output.

Bump the two assertions to match the new casing.

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-12 13:58:38 +08:00
Matyokubov Umar
664208c420
feat(core): replace fdir crawler with git ls-files + ripgrep fallback (#3214)
Replaces the fdir filesystem crawler with a three-tier strategy for `@` file mention autocomplete.

Strategy:
1. `git ls-files --cached` + `git ls-files --others --exclude-standard` for git repos — reads from the git index, gitignore correctness delegated to git.
2. `ripgrep --files` for non-git directories when `rg` is available.
3. `fdir` as the original last-resort fallback.

Additional improvements:
- mtime-based change detection on `.git/index` skips re-crawl when nothing changed.
- 5s refresh throttling avoids rebuilding the index on every keystroke.
- Async chunked indexing yields to the event loop every 1000 entries to stay responsive on 200k+ file trees.
- Background untracked-file merge with a 10s timeout.
- `resolveGitDir()` correctly handles `.git` as a file (worktrees) including relative `gitdir:` pointers and walks up to parent repos.
- POSIX-style path normalization on Windows.
- Streaming `spawn()` with line-by-line `onLine` processing and an enforced `maxFiles` budget that terminates the child early, preventing the previous 20MB stdout buffer hazard on large repos.
- `--exclude-standard` on the untracked listing so gitignored directories (e.g. `node_modules/`, `dist/`) are not enumerated.

Closes #3137
2026-05-12 13:58:16 +08:00
Shaojin Wen
55893875b0
feat(cli): add tools.toolSearch.enabled setting for prefix-caching models (#4069)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* feat(cli): add tools.toolSearch.enabled setting to disable ToolSearch for prefix-caching models

ToolSearch (PR #3589) defers MCP tool loading to reduce prompt size, but
breaks prefix-based KV caching for models like DeepSeek V4 where cached
token pricing is 1/120 of uncached. Users reported cache hit rates
dropping from ~98% to ~81% and 3x cost increases (discussion #4065).

Add a `tools.toolSearch.enabled` setting (default: true) that disables
ToolSearch by adding tool_search to the deny list, triggering the
existing eager-reveal fallback in client.ts. All deferred tools are then
included in the initial declaration list, restoring prompt prefix
stability.

Auto-disable ToolSearch for deepseek-v4-* models when the setting is not
explicitly configured, since their extreme cache discount makes prefix
stability far more valuable than the ~15K token savings from deferral.
Users can override with `tools.toolSearch.enabled: true`.

* fix: address PR review — expand model detection, add tests, regenerate schema

- Remove ^ anchor from regex to handle provider-prefixed model names
  (e.g. openrouter/deepseek/deepseek-chat)
- Expand auto-detection to all DeepSeek models with prefix caching:
  deepseek-v3, deepseek-v4-*, deepseek-chat
- Add 6 tests covering: explicit disable, auto-detect for v3/v4/chat
  with provider prefix, non-deepseek skip, explicit enable override
- Regenerate settings.schema.json for vscode-ide-companion
2026-05-12 12:04:26 +08:00
tanzhenxin
d6fe59a3b5
fix(test): repair stale --json-schema integration assertion (#4075)
The "fails fast at CLI parse time on invalid JSON Schema" integration
test stopped exercising the Ajv strict-compile path once the
`--json-schema` root-accepts-object precheck landed. The precheck rejects
`{type: "this-is-not-a-real-type"}` before Ajv runs, so the CLI exits
with the "root must accept object-typed values" error instead of the
"is not a valid JSON Schema" error the test expects.

Move the bogus `type` into a property so the root precheck passes and
Ajv catches the unknown type, restoring the test's original intent.
2026-05-12 12:00:57 +08:00
Shaojin Wen
76d8c0ce83
chore(core): runtime.json sidecar follow-ups from PR #3714 review (#4030)
- Drop dead `typeof !== 'boolean'` check in the integer-type guard
  (Number.isInteger(true) is already false; rename to isFiniteInteger).
- Drop dead utf-8-message branch in readRuntimeStatus's catch:
  fs.readFile(p, 'utf-8') returns replacement chars rather than throwing,
  so that branch never fires; the JSON.parse fallback already handles
  the truncated-UTF-8 case the test exercises.
- Move runtimeStatus.js export from between fileUtils and filesearch
  into alphabetical position next to runtimeFetchOptions.
- Add Config.startNewSession integration tests pinning the
  runtimeStatusEnabled gate: flag-off leaves sibling sidecars alone,
  flag-on swaps old->new on the same PID, no-op when sessionId is
  unchanged.

No behavior change for the runtime-status sidecar itself; this just
trims dead defensive code and adds the integration coverage that was
missing in #3714.
2026-05-12 06:55:52 +08:00
jinye
32a49b4ddb
refactor(telemetry): remove dead useCollector setting and unreachable TelemetryTarget.QWEN (#4061)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
useCollector was plumbed through config (interface, constructor, getter,
env var resolution) but never consumed by the telemetry SDK — the setting
had no runtime effect. TelemetryTarget.QWEN existed in the enum but
parseTelemetryTargetValue() only accepted 'local' and 'gcp', making
'qwen' unreachable (it would throw FatalConfigError).

Remove both dead code paths along with their tests and documentation.

Part of #3731
2026-05-11 23:22:53 +08:00
JerryLee
4bba75f765
fix(cli): keep long model stats header on one line (#4032)
Some checks failed
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
SDK Python / Classify PR (push) Has been cancelled
SDK Python / SDK Python (3.10) (push) Has been cancelled
SDK Python / SDK Python (3.11) (push) Has been cancelled
SDK Python / SDK Python (3.12) (push) Has been cancelled
* fix(cli): keep long model stats header on one line

* test(cli): cover fixed model stats columns
2026-05-11 20:35:55 +08:00
Shaojin Wen
bdd5b602de
feat(core): improve Anthropic proxy compatibility and enable global prompt cache scope (#4020)
* feat(core): improve Anthropic proxy compatibility and enable global prompt cache scope

- Use authToken instead of apiKey to send Authorization: Bearer header,
  avoiding dual-header conflicts with IdeaLab-style proxies
- Set User-Agent to claude-cli format and add x-app header for proxy
  Team rule compatibility
- Add adaptive thinking support for Claude 4.6+ models
- Enable prompt-caching-scope-2026-01-05 beta header and scope: global
  on system prompt cache_control to improve cross-session cache hit rates

* test: update tests for User-Agent, beta header, and cache_control changes

* fix(core): scope anthropic proxy workarounds to non-native baseURLs only

Address review feedback on PR #4020 by narrowing each workaround to where
it actually applies, instead of shipping it globally.

- Gate `Authorization: Bearer` (`authToken`), `claude-cli` User-Agent, and
  `x-app: cli` to non-Anthropic-native baseURLs. Direct `api.anthropic.com`
  users keep the SDK-default `x-api-key` (`apiKey`) auth and a truthful
  `QwenCode` User-Agent so usage isn't misattributed in Anthropic's
  logs/quotas, and so a stricter Anthropic backend doesn't 401 on a
  `Bearer`-shaped header.
- Gate the `prompt-caching-scope-2026-01-05` beta on `enableCacheControl`.
  When the converter isn't attaching `cache_control` to the body the beta
  is dead weight and risks 4xx responses from anthropic-compatible
  backends that don't recognize it. Restores the `betas.length === 0`
  early-return for the all-disabled case.
- Detect adaptive-thinking models with numeric major/minor compare instead
  of `[6-9]`. The character class missed `claude-haiku-4-6` entirely and
  would silently fall through to `budget_tokens` on `claude-opus-4-10` /
  `claude-opus-5-1` once those ship — tripping HTTP 400 with a shape the
  server no longer accepts.
- Honor explicit `reasoning.budget_tokens` before the adaptive branch.
  Adaptive omits `budget_tokens` from the wire shape, so checking it
  second silently dropped a user-supplied escape-hatch budget on Claude
  4.6+ models.
- Add `scope: 'global'` on the tool `cache_control` entry so the largest,
  slowest-changing prefix actually participates in cross-session caching
  under the new beta — the system-only attachment was capturing maybe
  half the available hit-rate improvement.
- Replace the misleading `as { type: 'ephemeral' }` cast on the system
  block (which erased `scope` from the type while leaving it on the
  wire) with a `AnthropicTextBlockParam` type that mirrors the existing
  `AnthropicToolParam` widening, so types match the runtime shape.

* fix(core): keep enableCacheControl live in the converter

Follow-up on PR #4020 review: `Config.setModel()` mutates
`enableCacheControl` in place (it's in `MODEL_GENERATION_CONFIG_FIELDS`),
but the converter captured it once at construction. On a hot flip the
generator's per-request `prompt-caching-scope-2026-01-05` beta gate would
sample the new value while the converter still emitted the old body-side
`cache_control` — beta-header and body could disagree.

- Thread the live `contentGeneratorConfig.enableCacheControl` into the
  converter via a per-call options override on both
  `convertGeminiRequestToAnthropic` and `convertGeminiToolsToAnthropic`,
  falling back to the constructor-time default when the caller doesn't
  pass one. The generator samples the value once per `buildRequest` and
  forwards it to both convert calls so the body and beta header always
  agree within a request, even across `setModel` flips.
- Regression test: hot-flip `enableCacheControl` from `true` to `false`
  on a live generator, verify the 2nd request drops both the beta header
  AND the body-side `cache_control` in lockstep.
- Tighten two existing beta-header tests that used `toContain` only on
  `interleaved-thinking` / `effort` — they now also assert
  `prompt-caching-scope-2026-01-05` is present (per-request keep-default
  and streaming paths), so accidental removal trips the test.
- Add coverage for the two previously-uncovered branches of
  `isAnthropicNativeBaseUrl`: `*.anthropic.com` subdomains
  (Anthropic-native) and a malformed baseURL (URL parse failure → proxy
  fallthrough). Also add an `anthropic.com.evil.com` hostname-spoof case
  mirroring the existing DeepSeek spoof test.

* refactor(core): consolidate anthropic generator shapes & document edge cases

Follow-up on PR #4020 review:

- Extract `AnthropicThinkingParam` type alias. The thinking union
  `{ type: 'enabled'; budget_tokens } | { type: 'adaptive' }` was repeated
  verbatim in three places: the `MessageCreateParamsWithThinking` field,
  the streaming-request intersection, and `buildThinkingConfig`'s return
  type. Once a third shape ships, forgetting one site would silently
  narrow a runtime value — single alias keeps them locked.
- Compute `useProxyIdentity` once in the constructor and pass it into
  `buildHeaders`. Previously `useBearerAuth` and `useProxyIdentity` named
  the same predicate at two call sites; collapsing them clarifies that
  Bearer auth + `claude-cli` UA + `x-app: cli` are one bundle that
  should never be split.
- Document that `modelSupportsAdaptiveThinking`'s regex is intentionally
  unanchored so reseller-prefixed names (`bedrock/claude-opus-4-7`,
  `vertex_ai/claude-sonnet-4-6@…`, `idealab:claude-opus-4-6`, …) keep
  matching. Tightening to `^claude-` would silently regress those.
- Soften the `prompt-caching-scope` beta comment so it describes what
  the code enforces (gate on the `enableCacheControl` flag) rather than
  promising a stronger "only ship when cache_control is on the body"
  invariant — the converter still skips `cache_control` on niche shapes
  (e.g. no system text, no tools, last user block isn't text). The
  looser gate is intentional; Anthropic-native ignores unused betas.
- Pin the wire shape for the `reasoning: undefined` + 4.6+ model corner.
  `resolveEffectiveEffort` returns undefined on `reasoning === undefined`,
  so `buildThinkingConfig` ships `{ type: 'adaptive' }` with no
  `output_config` and no `effort-2025-11-24` beta. If Anthropic ever
  starts requiring `output_config.effort` alongside adaptive, this test
  will fail at CI rather than at runtime as a server 400.

* fix(core): gate cache-scope on Anthropic-native baseURL, mirror auth gate

Follow-up on PR #4020 review: the `prompt-caching-scope-2026-01-05` beta
header and the body-side `scope: 'global'` field together comprise an
Anthropic-only wire-shape extension. Shipping them to non-Anthropic
backends (DeepSeek, IdeaLab) leaned on "unknown betas are ignored" —
true on Anthropic-native, but unverified for proxies and silently
inconsistent with the auth/identity gate, which already uses
`isAnthropicNativeBaseUrl` to bind Bearer / claude-cli / x-app to the
proxy path only.

- Add `useGlobalCacheScope` predicate on the generator. True iff
  `enableCacheControl !== false` AND the resolved baseURL is
  Anthropic-native. Plumbed per-request into both
  `convertGeminiRequestToAnthropic` and `convertGeminiToolsToAnthropic`;
  the same predicate also gates the `prompt-caching-scope-2026-01-05`
  beta in `buildPerRequestHeaders` so beta + scope field always travel
  together.
- Converter emits `cache_control: { type: 'ephemeral' }` (per-session)
  when scope is off and `{ type: 'ephemeral', scope: 'global' }` when on.
  Non-Anthropic baseURLs go back to their pre-PR per-session caching
  shape; existing prompt caching keeps working with no new beta.
- Document the intentional `scope: 'global'` omission on
  `addCacheControlToMessages`. The last user message changes every
  turn (live prompt + immediate tool_results), so cross-session reuse
  has effectively zero hit rate; cross-session caching is concentrated
  on system + tool prefixes only.

Tests:
- DeepSeek baseURL pins the proxy auth/identity path
  (`authToken` / claude-cli UA / `x-app: cli`). Documents the
  contract assumption that DeepSeek's anthropic-compatible endpoint
  accepts `Authorization: Bearer` — any future deviation surfaces
  here rather than at runtime for users.
- Non-Anthropic baseURL strips the cache-scope beta AND
  `scope: 'global'` from the wire shape, while keeping per-session
  `cache_control: { type: 'ephemeral' }` on system / tools.
- Hot-flip test extended to assert tool `cache_control` flips alongside
  system / user / beta header.
- Converter-level tests for per-call `enableCacheControl` and
  `useGlobalCacheScope` overrides — both directions of the constructor
  default (true→false, false→true) and the scope-independent-of-source
  case (cache on, scope off → per-session shape).
- baseConfig in the per-request anthropic-beta block now targets
  `api.anthropic.com` so cache-scope assertions remain meaningful; the
  proxy-baseURL behavior is covered separately.

* docs(core): tighten useGlobalCacheScope JSDoc — baseUrl is NOT hot-mutated

#4020 review: the JSDoc claimed `Config.setModel()` mutates both
`enableCacheControl` AND `baseUrl` in place. Per the current Config
implementation, only the qwen-oauth hot-update path mutates
`enableCacheControl` in place; non-qwen-oauth providers go through
the refresh path which recreates the ContentGenerator (so `baseUrl`
is captured fresh at construct time, not mutated).

Tightened the wording to reflect actual behavior + kept the
read-both-each-request defense (cheap and avoids stale-cache
surprises if the hot-update list ever expands).

* fix(core)!: suppress env back-fill so proxy auth doesn't leak real Anthropic key

#4020 review (tanzhenxin, severity high): the IdeaLab-proxy branch
spread `{ authToken: <key> }` and omitted `apiKey` entirely. The
Anthropic SDK constructor destructures with defaults
(`apiKey = readEnv('ANTHROPIC_API_KEY') ?? null`), and destructuring
defaults only fire for `undefined` — so an omitted `apiKey` lets
`ANTHROPIC_API_KEY` back-fill it. The SDK's auth resolver then prefers
`apiKey` over `authToken`, shipping `X-Api-Key` (not
`Authorization: Bearer`) on the wire. Concrete impact: a user with
`ANTHROPIC_API_KEY=sk-ant-…` exported (normal for anyone also running
Claude Code in the same shell) configuring qwen-code with an IdeaLab
proxy plus an IdeaLab token would leak their real Anthropic key as
`X-Api-Key` to the third-party proxy endpoint.

- Pass `apiKey: null` explicitly on the proxy branch and `authToken: null`
  on the Anthropic-native branch. Explicit `null` suppresses the
  destructuring default; the env back-fill no longer fires.
- New helper `resolveEffectiveBaseUrl` mirrors the SDK's own
  destructuring order (config → `ANTHROPIC_BASE_URL` env → SDK default).
  `isAnthropicNativeBaseUrl` now consults the env too, so a user
  configuring the proxy purely through `ANTHROPIC_BASE_URL` (qwen-code
  `baseUrl` unset) gets the proxy identity bundle instead of silently
  shipping native auth + UA + cache-scope beta to the proxy.

Tests:
- ANTHROPIC_API_KEY env + proxy baseURL → ctor receives `apiKey: null`
  and `authToken: our-key`. Locks in the credential-leak fix.
- ANTHROPIC_AUTH_TOKEN env + Anthropic-native baseURL → ctor receives
  `authToken: null` and `apiKey: our-key`. Symmetric guard for the
  inverse direction.
- ANTHROPIC_BASE_URL env points to proxy, config.baseUrl unset → proxy
  identity bundle (claude-cli UA, x-app, Bearer auth) applies.
- ANTHROPIC_BASE_URL unset → SDK default api.anthropic.com path keeps
  native identity (predicate doesn't misclassify the SDK default as a
  proxy).
- config.baseUrl wins over ANTHROPIC_BASE_URL — mirrors the SDK's own
  resolution order.
- Existing 7 identity tests updated from `toBeUndefined()` to
  `toBeNull()` to match the new explicit-suppression contract.

* refactor(core): gate cache-scope beta on body presence, not just predicate

#4020 review (Copilot): the comment promised "beta and body-side
scope: 'global' field always ship together" but the gate was just
`useGlobalCacheScope()`. In the degenerate case where the predicate is
true but the request body has no system text AND no tools, the beta
would still ship without any matching `cache_control.scope: 'global'`
on the wire — overstating the contract and shipping dead weight.

- New `hasGlobalCacheScopeOnWire(req)` scans the assembled request body
  (system block when shaped as `TextBlockParam[]`, tools array) for any
  `cache_control: { …, scope: 'global' }` entry. `buildPerRequestHeaders`
  gates the `prompt-caching-scope-2026-01-05` beta on this scan, so the
  beta and the body field share a single source of truth. No window
  between sampling the predicate and emitting the body where the two
  could diverge.
- `useGlobalCacheScope()` is still sampled once per `buildRequest` and
  threaded into the converter to decide whether to ATTACH
  `scope: 'global'` to the body. The body-scan downstream then derives
  the beta from what actually landed.

Tests:
- New: empty systemInstruction + no tools + Anthropic-native + cache on
  → beta NOT shipped (degenerate body-scan case).
- New: empty systemInstruction + non-empty tools → beta shipped
  (tool scope:'global' triggers the scan).
- Existing per-request beta tests now include a `systemInstruction` so
  the body has the scope field; degenerate case is covered by the new
  dedicated test.

Also tightened two stale comments (#3217834451, #3217834505) that
claimed `Config.setModel()` mutates both `enableCacheControl` and
`baseUrl` in place — only `enableCacheControl` is hot-mutated (qwen-oauth
path); non-qwen-oauth providers recreate the generator on refresh, so
`baseUrl` is captured fresh at construct time. Comments now describe the
real in-place mutation and note the qwen-oauth boundary.

* fix(core): trim config.baseUrl and document x-app in buildHeaders

#4020 review (Copilot): two low-stakes follow-ups on 491a441f9.

- `resolveEffectiveBaseUrl` trimmed `ANTHROPIC_BASE_URL` env but
  returned `contentGeneratorConfig.baseUrl` as-is. A copy-pasted baseURL
  with leading/trailing whitespace would trip `new URL(...)` in
  `isAnthropicNativeBaseUrl` and fall through the catch branch to the
  proxy identity bundle — meaning real api.anthropic.com would receive
  Bearer auth + claude-cli UA and 401. Apply the same trim() +
  empty-as-missing normalization on the config side. New regression
  test pins the contract with `'  https://api.anthropic.com  '`.
- `buildHeaders` docstring said constructor headers carry only
  User-Agent + customHeaders (excluding anthropic-beta). The PR also
  added `x-app: cli` on the proxy path; updated the comment so a future
  maintainer reading the "no duplicate headers" rationale doesn't
  miss the x-app addition.
2026-05-11 19:15:36 +08:00
tanzhenxin
8beb97eecf
feat(tools): keep ask_user_question always-visible to surface clarification UX (#4041)
Previously ask_user_question was deferred behind ToolSearch (shouldDefer: true),
so the model only saw it as a name in the deferred-tools section and had to
discover it via tool_search before invoking. In practice the model rarely
went through that discovery step and instead asked clarifying questions in
plain prose, losing the structured multiple-choice UX the tool exists to
provide.

Flip shouldDefer to false so the schema lives in the initial tool list. Add a
unit-test guard so a future refactor doesn't silently put it back behind
ToolSearch.
2026-05-11 19:06:03 +08:00
tanzhenxin
d7a25682e6
refactor(core): route side-query LLM calls through runSideQuery chokepoint (#3775)
* refactor(core): route side-query LLM calls through runSideQuery chokepoint

Folds every one-shot side-query call site through a single `runSideQuery`
entry point with `thinkingConfig.includeThoughts: false` and `fastModel`
(falling back to main) as the default policy. Adds a text-mode sibling
to the existing JSON-mode helper, plus a `BaseLlmClient.generateText`
primitive that calls `ContentGenerator.generateContent` directly so
side queries get neither user-memory wrapping nor the main-prompt
fallback that `geminiClient.generateContent` applies.

Migrated call sites: session title, recap, tool-use summary, /rename,
follow-up suggestion (direct path), ACP rewrite, project /summary,
arena approach summary, chat compression, web-fetch, insight analysis,
subagent spec generation. Six call sites override the helper defaults
explicitly (subagent gen, suggestion, ACP rewrite, /summary, compression,
insight) where main-model quality or caller-supplied model matters.

The /summary path additionally fixes a latent bug: text extraction
previously did not strip thought parts, so on thinking models the
saved `.qwen/PROJECT_SUMMARY.md` could leak `reasoning_content` into
the file. The chokepoint now strips thought parts and the request
itself goes out with thinking off.

Best-effort cosmetic callers (recap, tool-use summary, kebab rename,
suggestion) opt into `maxAttempts: 1` so transient outages don't burn
seven retries on output the user will likely never see. `isInternalPromptId`
recognises the `side-query:` prefix automatically so new call sites are
filtered without per-site allowlist updates.

Removes the `getAgentContentGenerator` workaround in `InProcessBackend`
and the `getAgentSummaryGenerator` indirection in `ArenaManager` —
arena approach summaries now run through the chokepoint against
`fastModel`, giving every agent a neutral arbiter rather than a
self-summary on its own model.

* fix(core): guard isInternalPromptId against undefined prompt_id

logToolCall calls isInternalPromptId(event.prompt_id), and tool-call
events from useToolScheduler can carry an undefined prompt_id. The
side-query refactor added promptId.startsWith(SIDE_QUERY_PROMPT_PREFIX)
without a falsy guard, so the missing id crashed the logger and broke
six useToolScheduler tests across all OS / Node matrix entries on CI.

* fix(cli,core): polish runSideQuery callers from review feedback

- Cap web-fetch, chat-compression, and ACP rewrite at maxAttempts: 1.
  These paths degrade gracefully on failure (tool error, NOOP fallback,
  null return), so 7 retries only delays the user-visible outcome.
- /summary now carries the main session's system instruction so the
  summarizer keeps the coding-assistant role, project context, and
  user memory instead of summarizing the chat in isolation.
- Add isInternalPromptId tests for the side-query: prefix so future
  callers minted via runSideQuery stay filtered out of recordings.

* refactor(core): document runSideQuery defaults and surface promptId in errors

- Add JSDoc on the model and config fields of SideQueryJsonOptions and
  SideQueryTextOptions so the fastModel-first defaulting and the
  thinkingConfig.includeThoughts: false default are visible at the API
  surface, not buried in resolveDefaultModel / applyThinkingDefault.
- BaseLlmClient.generate{Json,Text} error wraps now include promptId
  in the message and pass { cause: error }, so a side-query failure
  identifies which call site failed and preserves the original stack.
- Add tests covering maxAttempts forwarding (present + omitted) and
  rejection propagation for both JSON and text modes — the conditional
  spread is non-trivial and was previously unverified.

* fix(core): preserve per-model provider routing in side queries

BaseLlmClient was bound to the main session's ContentGenerator and only
swapped the request `model` field, so side queries targeting a fast or
alternate model inherited the main provider's baseUrl, credentials, and
sampling settings — breaking cross-provider configurations.

Move per-model generator/authType resolution out of GeminiClient and into
BaseLlmClient as `resolveForModel`. Both generateJson and generateText
now build a per-model ContentGenerator (with cache) when the request
targets a non-main model and pass the resolved retry authType through
to retryWithBackoff. GeminiClient.generateContent delegates to the same
resolver so there is a single source of truth.

Also pin the /forget destructive selector to the main model — the
runSideQuery default moved to fast model in this branch, but /forget
acts on the selection without confirmation, so a weaker fast model
could silently delete the wrong managed-memory entries.

* test(core): assert thinkingConfig/maxAttempts/model forwarding in compression

The compression caller of runSideQuery sets thinkingConfig.includeThoughts=true
and maxAttempts=1. A future refactor that silently drops either would degrade
compression quality without test failure; this assertion locks the contract.

* fix(cli): route dynamic localization through side query

* refactor(core): remove unused memory governance review
2026-05-11 19:03:14 +08:00
BingqingLyu
28a3439307
feat(skills): Add codegraph skill for PR review risk analysis and conflict detection (#3910)
* feat(skills): add codegraph skill for PR review risk analysis and conflict detection

Add .qwen/skills/codegraph/ with PR analysis, bug analysis, schema,
patterns, and eval support. Enables per-PR risk scoring (blast radius,
interface changes, test coverage), cross-PR conflict detection, and
automated GitHub labeling via the codegraph-ai pip package.

* chore: ignore .venv and .codegraph directories

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(skills): add HF mirror and ModelScope fallback for embedding model downloads

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(skills): address PR #3910 review feedback for codegraph skill

- Rename skill from codegraph-qa to codegraph for consistency
- Broaden description to cover index creation and inspection
- Add HuggingFace/ModelScope model download tips
- Document codegraph ingest command for adding git history
- Fix stray '@' in stdout Tee pattern code snippet
- Remove dead reference to PRReview.md
- Add repo_dir parameter to CrossPRAnalyzer examples
- Update CHANGES edge types (hunk/deleted/related/new) and
  resolve_pr_functions internals
- Add Conflict Detection Dimensions table
- Replace codegraph query with PRReview Python API examples
- Remove OPTIONAL MATCH limitation from schema (now supported)
- Update codegraph-ai link to PyPI
- Add HF model download entry to Troubleshooting table

---------

Co-authored-by: pomelo-nwu <czynwu@outlook.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-11 18:14:40 +08:00
ChiGao
cadda23782
chore(deps): upgrade ink 6.2.3 → 7.0.2 + bump Node engine to 22 (#3860)
* chore(deps): upgrade ink 6.2.3 -> 7.0.2 + bump Node engine to 22

ink 7 requires Node >=22 and react-reconciler 0.33 with React >=19.2,
so this PR also bumps:

- Node engines (root + cli + core) 20 -> 22
- React/react-dom 19.1 -> 19.2.4 (pinned exact via overrides to keep
  the transitive React graph deduped to a single instance)
- @types/node pinned to 20.19.1 via overrides to avoid an unrelated
  Dirent NonSharedBuffer regression in sessionService tests
- @vitest/eslint-plugin pinned to 1.3.4 to avoid an unrelated lint
  regression introduced by the 1.6.x rule additions
- react-devtools-core 4.28 -> 6.1 (ink 7 peerOptional requires >=6.1.2)
- ink hoisted to root devDeps so workspace-private peer-dep contention
  doesn't push ink-link/spinner/gradient into nested workspace
  installs (which would skip transitive resolution for terminal-link)

Workflow + image + installer alignment:

- .nvmrc 20 -> 22
- Dockerfile node:20-slim -> node:22-slim
- CI test matrix drops 20.x (keeps 22.x + 24.x)
- terminal-bench workflow Node 20 -> 22
- Linux/Windows install scripts upgrade their Node version targets

Documentation alignment:

- README.md badge + prerequisites
- AGENTS.md, CONTRIBUTING.md, docs/users/quickstart.md,
  docs/users/configuration/settings.md, docs/developers/contributing.md,
  docs/developers/sdk-typescript.md, docs/users/extension/extension-releasing.md,
  packages/sdk-typescript/README.md, packages/zed-extension/README.md,
  scripts/installation/INSTALLATION_GUIDE.md

Test gating:

- Two AuthDialog/AskUserQuestionDialog tests that drive <SelectInput>
  through ink-testing-library now race ink 7's frame-throttled input
  delivery and land on the wrong option. The maintainers had already
  marked one of them unreliable (skip on Win32 + CI+Node20). Extend
  that gate to cover all environments until upstream
  ink-testing-library ships an ink-7-compatible release that flushes
  input deterministically. The other test now uses it.skip with the
  same comment. No business code changes.

Verified locally:

- npm run typecheck across all workspaces: clean
- npm run lint (root): clean
- npm run test --workspaces:
    cli  312/312 files, 4918 passed, 9 skipped
    core 266/266 files, 6836 passed, 3 skipped
    webui 6/6, 201 passed
    sdk  40/40, 283 passed, 1 skipped
- npm ls ink: single ink@7.0.2 instance across all peer deps
- single react@19.2.4 instance

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* chore: align Node 22 floor across all shipping artifacts

Reviewer (tanzhenxin) flagged five surfaces where the >=22 engine bump
leaked: SDK package metadata, web-templates engines, /doctor runtime
check, main bundler target, and SDK bundler target. Each was a separate
escape hatch letting Node 18/20 consumers install or run the artifact
on an unsupported runtime.

- packages/sdk-typescript/package.json: engines.node >=18.0.0 -> >=22.0.0
- packages/web-templates/package.json: engines.node >=20 -> >=22
- packages/cli/src/utils/doctorChecks.ts: MIN_NODE_MAJOR 20 -> 22
- esbuild.config.js: target node20 -> node22 (main CLI bundle)
- packages/sdk-typescript/scripts/build.js: target node18 -> node22 (esm + cjs)
- packages/cli/src/utils/doctorChecks.test.ts: rename test label to v22+

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* ci(e2e): bump E2E workflow Node matrix to 22.x

Reviewer (tanzhenxin) flagged that e2e.yml still pinned node-version
20.x while root engines is now >=22, so every E2E run on push would
either fail at npm ci with engine error or silently exercise the bundle
on a runtime that's no longer in ci.yml's test matrix.

The macOS job in the same workflow already reads .nvmrc (which is 22)
so this only updates the Linux matrix.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(deps): drop root wrap-ansi override so ink 7 gets its declared dep

Reviewer (tanzhenxin) flagged that the root overrides.wrap-ansi: 9.0.2
predates this upgrade and forces every consumer (including ink) to v9,
while ink 7 declares wrap-ansi: ^10.0.0. The lockfile had no nested
install under node_modules/ink/, so ink 7 was running with a transitive
dep one major below its declared minimum.

Dropping the global override lets ink resolve its own wrap-ansi 10
nested install (now visible in the lockfile under
node_modules/ink/node_modules/wrap-ansi), while the cli package's own
direct `wrap-ansi: 9.0.2` dependency keeps the cli code path
(TableRenderer.tsx) on the version it has been tested against. The
nested cliui override is preserved for yargs which still needs v7.

Verified via `npm ls wrap-ansi`:
- ink@7.0.2 -> wrap-ansi@10.0.0 (newly nested)
- @qwen-code/qwen-code -> wrap-ansi@9.0.2 (unchanged)
- yargs/cliui -> wrap-ansi@7.0.0 (unchanged)

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(InputPrompt): un-skip placeholder ID reuse after deletion

Reviewer (tanzhenxin) flagged that the new it.skip on the
'should reuse placeholder ID after deletion' test was undisclosed in
the PR description and removed coverage of real product behavior
(freePlaceholderId / bracketed-paste backspace path) without a
TODO(#NNNN) link.

Their argument was sound: the skip rationale pointed at ink 7's input
throttle, but this same file just bumped the wait helper from 50ms to
150ms specifically to give ink 7 frame time. Re-running the test under
the bumped wait shows it passes reliably (5/5 runs in the full-file
context, 9/10 alone), so the skip was masking the throttle-flake that
the wait bump already addresses, not a real product bug.

Drop the it.skip and the now-stale comment so coverage of the
freePlaceholderId reuse logic is restored.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(InputPrompt): bump first prompt-suggestion test wait to 350ms

The "accepts and submits the prompt suggestion on Enter when the buffer
is empty" test is the first in its describe block, so it pays the
renderer cold-start cost. On macOS-22.x CI runners that pushes the
Enter → onSubmit microtask past the default 150ms post-Enter wait. Match
the 350ms initial render wait used immediately above to absorb the cold
start.

* Revert "test(InputPrompt): bump first prompt-suggestion test wait to 350ms"

This reverts commit 6add83b62e.

* test(InputPrompt): wait for followup suggestion debounce before pressing Enter

Root cause of the failing prompt-suggestion tests on macOS and Windows
CI is not flaky timing of the test post-Enter wait — it's the 300ms
debounce inside createFollowupController.setSuggestion (shared core).
The Enter handler reads followup.state.isVisible synchronously, so if
the debounce timer has not fired before stdin.write('\\r'), the
suggestion path is skipped and onSubmit never runs. No amount of
post-Enter wait can recover from that — the keypress was already
processed against stale state.

The original wait(350) only left ~50ms margin over the 300ms debounce,
which ink 7 / React 19.2 mount overhead consumed on slow Windows
runners. Bump the initial wait to 700ms (named SUGGESTION_VISIBLE_WAIT_MS)
to give the debounce timer + cold-start render a generous buffer.

Apply to the two sibling tests too — without the wait their "does not
accept" assertions pass trivially when suggestion is never visible,
which is a false green that hides regressions in the actual reject path.

* fix(deps): align cli wrap-ansi with ink 7 (9.0.2 -> ^10.0.0)

Ink 7 ships its own wrap-ansi@10. CLI's direct dep was pinned to 9.0.2,
causing two copies of wrap-ansi in node_modules and a potential drift in
CJK width / ANSI handling between ink's internal text wrapping and our
TableRenderer.

Upgrading the CLI's direct dep to ^10.0.0 lets npm dedupe to a single
wrap-ansi@10 used by both ink and TableRenderer. API surface is
identical; the only documented behaviour change is that tabs are
expanded to 8-column tab stops before wrapping, which TableRenderer
doesn't feed in.

TableRenderer test suite (43 tests) passes against wrap-ansi@10.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* chore(deps): document @types/node 20.x pin in overrides

The override pinning @types/node to 20.19.1 (while engines require
Node >=22) is intentional: bumping to @types/node@22.x re-introduces
a Dirent<NonSharedBuffer> type regression that breaks
@qwen-code/qwen-code-core/sessionService tests.

Add a sibling "//@types/node" note inside `overrides` so future
maintainers see the rationale and know when to revisit the pin
without having to dig through PR #3860 history.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(AskUserQuestionDialog): link skipped Submit-tab test to tracking issue

The 'shows unanswered questions as (not answered) in Submit tab' test
was switched to `it.skip` in the ink 7 upgrade because
`ink-testing-library@4.0.0` doesn't flush input deterministically
through ink 7's 30fps throttle.

Add a `// TODO(#4036):` marker so the skip is greppable and can be
re-enabled once upstream ships an ink-7-compatible release.

Refs #4036

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(deps): move @types/node pin comment out of overrides block

npm's `overrides` field requires every key to be a real package name —
the `"//@types/node"` comment-key added in 205855875 trips Arborist with
"Override without name" and breaks `npm ci` across all CI jobs.

Move the explanation to a sibling top-level `"//overrides"` key, which
npm ignores at the document root. Same documentation value, no
override-parser collateral damage.

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-11 17:29:50 +08:00
pomelo
7e11428545
refactor(cli): remove legacy qwen auth CLI subcommand, redirect to /auth TUI dialog (#3959)
The `qwen auth` CLI subcommand (with subcommands like qwen-oauth,
coding-plan, api-key, openrouter, status) has been superseded by the
richer /auth TUI dialog introduced in the provider-first auth registry
(#3864). Running `qwen auth` now prints a deprecation notice pointing
users to the /auth TUI dialog (interactive), env vars (CI/headless),
or /doctor (status check).

Changes:
- Replace auth.ts with a stub that prints a removal notice and exits
- Delete handler.ts (734 lines), interactiveSelector.ts, and their
  tests (interactiveSelector.test.ts, openrouter.test.ts, status.test.ts)
- Update /auth slash command to handle non-interactive/ACP modes gracefully
- Enrich /doctor auth check with provider-aware diagnostics using
  findProviderByCredentials
- Mark `auth` as a subcommand that handles its own exit in config.ts

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-11 16:44:09 +08:00
Shaojin Wen
84ecb5b8a3
feat(cli): add --json-schema for structured output in headless mode (#3598)
* feat(cli): add --json-schema for structured output in headless mode

Registers a synthetic `structured_output` tool whose parameter schema IS the
user-supplied JSON Schema. In headless mode (`qwen -p`), the first successful
call terminates the session and exposes the validated payload via the result
message's `structured_result` field. Invalid schemas are rejected at CLI parse
time via a new strict Ajv compile helper so they can't silently no-op at
runtime.

* fix(cli): honour "first structured_output call ends session" + reject non-object root schemas

Two review fixes for the `--json-schema` feature:

1. `runNonInteractive` now breaks out of the tool-call loop as soon as the
   first successful `structured_output` invocation is captured, rather than
   continuing to execute any trailing tool calls the model emitted in the
   same turn. This restores the documented single-shot contract and prevents
   side-effecting tools from running after the final answer has already
   been accepted.

2. `resolveJsonSchemaArg` rejects schemas whose root `type` is anything
   other than "object" (or a type array including "object"). Function-
   calling APIs require tool arguments to be JSON objects, so a schema
   like `{"type": "array"}` would have registered an unusable synthetic
   tool the model could never satisfy. Absent `type` and `type: "object"`
   remain accepted.

Adds tests for both paths and updates the existing Ajv-compile test to
exercise that path without tripping the new root-type guard first.

* fix(cli): also reject root anyOf/oneOf schemas whose branches can't accept objects

Addresses a review follow-up: the previous root-object check only inspected
the top-level `type` keyword, so a schema like
`{"anyOf":[{"type":"array"},{"type":"string"}]}` slipped through even though
none of its branches can ever validate the object-shaped arguments that
function-calling APIs send.

Replace the single `type` check with `schemaRootAcceptsObject`, which
recursively walks root-level anyOf/oneOf branches and requires at least one
to accept objects. Absent `type`, `type: "object"`, `type: ["object", ...]`,
and mixed anyOf branches where one accepts object all still pass. `allOf`
is left to Ajv's runtime behaviour — guessing intent across contradictory
allOf branches at parse time is fragile.

* fix(cli): propagate exitCode from --json-schema failure path + tests

Address two PR-3598 review findings:

1. gemini.tsx unconditionally called process.exit(0) after
   runNonInteractive/runNonInteractiveStreamJson, clobbering the
   process.exitCode = 1 set by nonInteractiveCli.ts when the model
   emits plain text instead of the structured_output tool. Switch
   both call sites to process.exit(process.exitCode ?? 0) so CI can
   detect the failure via the exit code.

2. nonInteractiveCli.test.ts: strengthen the structured-output
   success path to assert registry.abortAll() is called and that the
   stdout result envelope carries the JSON-stringified args under
   `result` plus the raw object under `structured_result`. Add a
   retry-path test that mocks executeToolCall to return an error on
   the first structured_output call, then verifies sendMessageStream
   is called a second time so the model can retry rather than the
   session terminating early.

* fix(cli): suppress non-structured tool calls when structured_output is in the same turn

When --json-schema is active and the model emits a batch like
[write_file(...), structured_output(...)], the previous implementation
ran the leading side-effecting tool before accepting the structured
result, violating the "structured_output is the terminal contract"
guarantee. The trailing-only break also let an invalid first
structured_output fall through to subsequent tools before the retry
turn.

Pre-scan the batch: if a structured_output request is present, execute
ONLY the first one and skip everything else (leading and trailing).
This is consistent with the existing terminal-path semantics — the
suppressed tool_use blocks lack a matching tool_result, the same way
max-turns / cancellation leave the stream.

Adds a test covering the reverse-order [side_effect, structured_output]
case alongside the existing trailing-suppression and retry tests.

* fix(cli): tighten --json-schema root validation per review feedback

Three small holes flagged in the latest pass:

1. `schemaRootAcceptsObject` returned early when a root `type` keyword
   was present, ignoring sibling `anyOf`/`oneOf`. JSON Schema applies
   keywords at the same level conjunctively, so e.g.
   `{type:"object", anyOf:[{type:"string"}]}` is unsatisfiable for any
   value but used to pass. Now both `type` AND any sibling
   `anyOf`/`oneOf` must independently admit object.

2. The FatalConfigError text said "Every branch of a root anyOf/oneOf
   must be satisfiable by an object", but the actual logic only
   requires *at least one* branch (and tests still accept
   `anyOf:[object, string]`). Reworded to "at least one branch" so the
   message matches the behaviour.

3. `compileStrict` used `typeof schema !== 'object'` to gate input,
   which lets arrays through (`typeof [] === 'object'`). The contract
   says "schema must be a JSON object", so add an `Array.isArray`
   check so array input gets the intended error rather than a less
   helpful Ajv compile message.

Tests cover the new rejection paths and the array case.

* fix(cli): handle root $ref and allOf in --json-schema accept-object check

`schemaRootAcceptsObject` previously only inspected `type`, `anyOf`,
and `oneOf` at the root, so a couple of unsatisfiable shapes still
slipped through:

1. `{"$ref":"#/$defs/Foo","$defs":{"Foo":{"type":"array"}}}` would be
   accepted because we don't follow $refs, but registers a synthetic
   tool whose params resolve to "array" — the model can never produce
   a valid object. Now reject any root $ref unless the user adds a
   sibling `type:"object"` as an explicit anchor.

2. `allOf` was deferred to Ajv runtime, but allOf is conjunctive at
   the same level as `type` / `anyOf` / `oneOf`, so an entry like
   `{"allOf":[{"type":"object"},{"type":"string"}]}` is unsatisfiable
   for any value. Walk it like the others, requiring every branch to
   admit object.

Tests cover the new $ref-rejected / $ref+anchor-accepted paths and the
allOf reject/accept paths.

* fix(cli): explicit exit code from runNonInteractive + pair suppressed tool calls

Three review threads on the structured-output flow:

1. The break that ends the for-loop on a successful structured_output
   call sat *before* the responseParts.push and modelOverride capture.
   SyntheticOutputTool currently returns neither, so it was safe today
   — but anyone wiring extra signals into the synthetic tool later
   would see them silently dropped. Move the break after both captures
   so the contract is explicit, not implicit.

2. The failure path used to set process.exitCode = 1 and return void,
   relying on global mutable state across an async boundary. Any
   cleanup task between runNonInteractive and process.exit could
   silently turn the structured-output failure into exit 0. Switch
   runNonInteractive to Promise<number>, return 0 / 1 directly from
   each function-level exit, and have gemini.tsx use the captured
   return value.

3. The pre-scan from the prior commit suppresses sibling tool calls
   when structured_output is in the same turn. On the retry path —
   when structured_output fails validation — the next-turn payload
   has tool_result for structured_output but no entry for the
   suppressed siblings, leaving the prior assistant turn's tool_use
   blocks unpaired. Anthropic and OpenAI both reject that batch
   shape, so the retry would surface as an opaque provider error.
   Synthesize a "skipped" functionResponse for every suppressed call
   so every tool_use in the prior assistant message has a matching
   tool_result.

Tests cover the new retry-pairing contract and update the existing
plain-text-failure test to assert on the return value rather than
process.exitCode.

* fix: address Copilot follow-up review on --json-schema scaffolding

Five small but real findings flagged on the latest pass:

1. core/src/index.ts re-exported `SyntheticOutputTool` via `export type`,
   but it's a runtime class — that erased it from the emitted JS and
   would break value imports. Split into a value `export { ... }` and a
   `export type { StructuredOutputParams }`.

2. The structured-output success path returned without flushing
   `localQueue` notifications or finalising one-shot monitors. If a
   background task had already emitted `task_started`, exiting here
   could drop its paired `task_notification` and leave SDK consumers
   with unpaired lifecycle events. Mirror the regular terminal path's
   `flushQueuedNotificationsToSdk` + `finalizeOneShotMonitors` calls
   before `emitResult`.

3. `schemaRootAcceptsObject` ignored the `not` keyword, so
   `{not:{type:"object"}}` (which forbids every object value) slipped
   through. Add a best-effort `not` check that rejects when
   `not.type` directly excludes object. Deeper negated patterns still
   fall through to Ajv at runtime.

4. `compileStrict`'s JSDoc claimed it errored on "Ajv versions we can't
   support", but the function doesn't actually check Ajv versions. Reword
   to "malformed or uses unsupported draft/features for our Ajv
   configuration" so the contract matches the implementation.

5. The pre-scan suppressed sibling tool calls but only synthesised
   tool_result events for them on the retry path — the success path
   left those tool_use blocks unpaired in the emitted JSONL/stream-json
   event log. Move the synthesis after the for loop so it runs for both
   the success break and the validation-failure fall-through; the event
   log is now consistent regardless of which path the run takes.

Tests cover the new \`not\`-rejection paths, the success-path tool_result
synthesis, and the existing retry-pairing test still passes against the
restructured emit ordering.

* fix(cli): tighten --json-schema parse-time gate per Copilot review

Two more shapes that used to slip through:

1. `schemaRootAcceptsObject` defaulted to true when no narrowing
   keyword was present, so root-value constraints like `{const: 1}`
   or `{enum: [1, 2]}` registered an unsatisfiable structured_output
   contract — the model could never produce a value matching the
   tool's parameter schema, and the run would loop on validation
   failures until max-turns. Reject `const` whose literal isn't an
   object, and `enum` whose members include no object.

2. The yargs check rejected `--json-schema` with `-i` and with no
   prompt, but not with `--input-format stream-json`. Stream-json
   keeps the process open waiting for protocol messages, so
   "terminate on the first valid structured_output call" silently
   drops everything queued after that point. Refuse the combination
   at parse time so the contradiction surfaces immediately.

Tests cover the new const/enum reject and accept paths.

* fix(cli): handle empty/boolean subschemas + allow stdin-only prompt

Three more shapes flagged on the latest review pass:

1. `schemaRootAcceptsObject` treated an empty root `anyOf`/`oneOf`
   as "no constraint" (skipped when length === 0), but per JSON
   Schema an empty union is unsatisfiable — no value can match a
   member of the empty set. Reject those at parse time so users
   get a clear parse error instead of an opaque runtime
   never-validates loop.

2. JSON Schema (draft-06+) allows boolean subschemas anywhere a
   schema is accepted: `true` matches every value, `false` matches
   nothing. The `anyOf`/`oneOf`/`allOf` walks were rejecting
   booleans via the typeof-object guard, which incorrectly
   rejected `{anyOf:[true]}` and `{allOf:[true,{type:"object"}]}`
   while letting `{anyOf:[false]}` slip through. Replace the
   per-branch object guard with a `variantAcceptsObject` helper
   that treats `true` as accepting and `false` as rejecting, then
   recurses on object subschemas.

3. The yargs `.check` rejected `--json-schema` when no `-p` /
   positional prompt was given, but the headless CLI also reads
   the prompt from stdin (`cat prompt.txt | qwen --json-schema
   '...'`) — a legit usage pattern that was being blocked. Drop
   the parse-time no-prompt rejection; the existing runtime "No
   input provided via stdin..." error in gemini.tsx still catches
   genuinely empty input.

Tests cover the empty-union, all-`false`, mixed-boolean accept,
and `false`-in-allOf reject paths. Live-verified against the
bundled CLI: `echo "..." | qwen --json-schema '...'` now reaches
the model call, and the four schema edge cases all surface the
expected error text or proceed past parse time.

* docs(core): note SyntheticOutputTool as the value-export exception

The block comment above the lazy-load type re-exports said tool classes
"are now lazy-loaded and are not exported as values from the package
root", but `SyntheticOutputTool` was just promoted to a runtime export
in 62038527c so the CLI's `--json-schema` flow can construct it from
the package root. Document that exception inline so downstream
consumers reading the comment don't get told the wrong story.

* fix(cli): try every structured_output in a same-turn batch in order

The pre-scan used to pick only the FIRST structured_output call from a
turn and suppress everything else, even other structured_output calls.
That created two avoidable failure modes:

1. `[structured_output(bad), structured_output(good)]` would attempt
   only the bad one, fail validation, and force a full retry turn.
   The model already produced a valid structured payload — we should
   try it before asking again.

2. The trailing structured_output's tool_result was synthesised with
   the "Skipped: structured_output was also requested in this turn..."
   message, which is misleading because that call WAS the structured
   output we should have tried.

Filter `requestsToExecute` to ALL structured_output calls (in original
order) when --json-schema is active, and let the existing loop break
on the first success. Track an `executedCallIds` set, then synthesise
tool_result + retry parts after the loop for every tool_use the model
emitted that we never actually executed — covering both non-structured
siblings (always suppressed) and any structured_output left over after
the success break (only one terminal contract per turn).

Reworded the synthesised "skipped" output to "this turn's
structured_output contract took precedence" so it reads correctly
regardless of whether the suppressed call was structured or not.

Tests cover the multi-structured retry-free success path; the
existing single-structured retry and trailing/leading suppression
tests still pass against the updated emit ordering.

* fix: address gpt-5.5 review on --json-schema (privacy + $ref + core-tools)

Three findings, three changes:

1. Reject every root `$ref` in --json-schema, even with a sibling
   `type: "object"` anchor. Ajv applies `$ref` conjunctively with
   sibling keywords, so the previous "accept when type:object is
   present" carve-out was unsound: `{type:"object",$ref:"#/$defs/Foo",
   $defs:{Foo:{type:"array"}}}` parsed fine but no object value can
   satisfy both at runtime, leaving the model to loop until maxTurns.
   Updated docstring + test cases (replaced the accept-with-anchor
   case with a reject case for both anchored and well-formed $ref
   shapes — users wanting composition should inline at the root).

2. Redact `function_args` for structured_output in ToolCallEvent.
   The args ARE the user's structured payload (already emitted via
   stdout `result` / `structured_result`); recording them again as
   ordinary tool-call function_args duplicates that data into OTLP
   exports, QwenLogger, ui-telemetry, and the chat-recording UI
   event mirror — surfaces that can leak off-device. Replace with a
   stable `__redacted` placeholder so consumers still see the call
   happened (duration, success, decision metrics preserved) but the
   payload itself doesn't ride along. Two new uiTelemetry tests
   cover the redacted vs non-redacted paths.

3. Document and test that structured_output bypasses the --core-tools
   allowlist (same as agent / skill / exit_plan_mode / ask_user_question
   etc.). The synthetic tool only exists when --json-schema is set,
   so adding it to CORE_TOOLS would let `--core-tools read_file
   --json-schema X` silently drop the terminal contract and loop the
   model until maxTurns — bypass is intentional. Expanded the
   CORE_TOOLS docstring to enumerate the synthetic-tool exclusions
   and added a permission-manager test mirroring the pattern used
   for agent / skill / exit_plan_mode.

* fix(cli): apply structured_output terminal handling to drain turns

The synthetic structured_output tool is registered for the entire
headless session, so it can be invoked from EITHER the main
assistant-turn loop OR from a drain turn (queued cron-job /
notification reply). The drain path (drainOneItem) was treating it
like any other tool: execute, append the response back into
itemMessages, keep going. The submitted args were never captured and
no structured_result envelope was emitted, so a run that legitimately
satisfied --json-schema mid-drain ended up failing the contract with
"Model produced plain text..." anyway.

Apply the same terminal handling to drain turns:

- Hoist `structuredSubmission` to session scope so both paths write
  to one variable.
- In `drainOneItem`, run the same pre-scan: when --json-schema is
  active and structured_output is in the batch, execute every
  structured_output in original order until one succeeds; suppress
  every non-structured sibling. Synthesise tool_results for any
  unexecuted tool_use the model emitted, mirroring the main path.
- On capture, return early from drainOneItem so the drained item's
  inner while loop stops.
- `drainLocalQueue` short-circuits when a captured submission is in
  flight, so subsequent queued items don't run.
- The cron `checkCronDone` watches the same flag and stops the
  scheduler immediately on capture, releasing the surrounding
  `await new Promise(...)`.
- The final holdback loop bails out on capture so monitor lifecycle
  doesn't extend past the structured submission.
- After the holdback, before the existing failure / regular-success
  emit, emit the structured success envelope and return 0.

Adds a focused unit test that drives the drain path end-to-end via a
synchronously-fired monitor notification: main turn produces plain
text, the drain reply calls structured_output, and the test asserts
exit 0 + structured_result populated + no "Model produced plain
text..." error.

* fix(cli): address gpt-5.5 review follow-ups on --json-schema scaffolding

Six review findings, six small fixes:

1. **Nested $ref incorrectly rejected.** `schemaRootAcceptsObject`
   recurses into anyOf/oneOf/allOf branches and used to apply the
   root-only $ref rejection at every level, blocking common
   composition shapes like
   `{anyOf:[{$ref:"#/$defs/Foo"},{type:"string"}]}`. Add an
   `isRoot=true` parameter; non-root recursion treats `$ref` as
   opaque and defers to Ajv at runtime. Tests cover nested refs in
   anyOf / oneOf / allOf.

2. **Inaccurate package-root export comment.** `core/src/index.ts`
   claimed `SyntheticOutputTool` was exported as a runtime value
   for the CLI's --json-schema flow, but the only construction is
   inside `Config.registerLazy` via a relative dynamic import — no
   value consumer reaches into `@qwen-code/qwen-code-core`. Revert
   to a type-only re-export so `SyntheticOutputTool` lines up with
   every other lazy-loaded tool class.

3. **Unused constructor parameter.** `SyntheticOutputTool` took
   `(_config: Config, schema)` but never read `_config`. Drop the
   parameter (and the corresponding pass-through at the registration
   call site) so readers don't wonder why a Config is being threaded
   through.

4. **Tool description claimed "exactly once".** The retry path
   explicitly tolerates multiple calls until one validates, so
   "Call this tool exactly once" is misleading to a model that
   tried twice. Reword to "Call this tool to deliver the final
   result; the first call with valid arguments ends the session" so
   the description matches the actual contract.

5. **Asymmetric shutdown on the structured-output success path.**
   The regular terminal path waits in a holdback loop until
   `hasUnfinalizedTasks()` is false; the structured-output path
   used to call `abortAll()` and flush immediately, dropping the
   matching `task_notification` for any agent whose natural handler
   hadn't yet enqueued it. Add a bounded holdback (capped at 500ms
   via STRUCTURED_SHUTDOWN_HOLDBACK_MS) — long enough for typical
   abort callbacks to enqueue, short enough that a hung agent can't
   block exit.

6. **gemini.tsx exit-code asymmetry.** `runNonInteractive` returns
   an explicit exit code, but `runNonInteractiveStreamJson` still
   reads `process.exitCode` after `runExitCleanup`. Currently safe
   because the yargs `.check` rejects --json-schema with stream-json
   input, but a future stream-json equivalent of structured output
   would need to plumb the exit code through the return value too.
   Document this in a comment so the constraint is visible at the
   call site.

Plus: strengthen `synthesises tool_result for suppressed sibling
calls when structured_output fails validation` to assert the failed
structured_output's `functionResponse.response` carries the actual
validation error string ("args invalid"), not the synthesised
"Skipped:" prose — a regression that overwrote it would otherwise
slip past the existing pairing assertion.

* fix(cli): close --json-schema gaps surfaced in self-audit + review

Five fixes layered onto the same robustness pass over the
`--json-schema` flow:

1. **bare-mode registration** (`packages/core/src/config/config.ts`):
   `qwen --bare --json-schema X -p "..."` previously skipped the
   synthetic `structured_output` registration entirely (the
   registration block lives below the bare-mode early-return), so the
   model had no way to terminate and the run looped to
   `maxSessionTurns`. Register the synthetic tool inside the bare
   branch too.

2. **TTY interactive rejection** (`packages/cli/src/gemini.tsx`):
   `qwen --json-schema X` on a TTY with no `-p` and no piped stdin
   routes to `isInteractive=true` (priority-3 fallback) and would
   launch the TUI, where `structured_output` is just an inert tool
   that prints "accepted" and lets the chat continue. Parse-time
   gating can't catch this (stdin isn't probed yet at parse time), so
   reject at runtime before the UI launches; runs `runExitCleanup`
   first so MCP subprocesses get torn down.

3. **drain-turn structured-success flush**
   (`packages/cli/src/nonInteractiveCli.ts`): when a drain turn
   captures `structured_output`, `drainLocalQueue` returns early,
   leaving any items the drain didn't process in `localQueue`. The
   prior emit path then ran `registry.abortAll()` + `emitResult`
   without flushing — stream-json consumers saw `task_started` events
   without paired `task_notification`. Add the same 500ms holdback +
   `flushQueuedNotificationsToSdk` the main-turn structured-success
   path uses, so the two paths agree.

4. **ACP mutual-exclusion** (`packages/cli/src/config/config.ts`):
   `--acp` runs an independent `runAcpAgent` turn loop that doesn't
   honour the synthetic-tool terminal contract, so `--acp
   --json-schema X` would register the tool but never terminate. Add
   a yargs `.check` rejection covering both `--acp` and the
   deprecated `--experimental-acp` alias.

5. **max-turns + Skipped wording** (review comments
   #3198579251/#3198579389/#3198579567 from yiliang114):
   - `handleMaxTurnsExceededError` now appends a `--json-schema`-
     specific hint pointing at the common stuck-run causes
     (structured_output denied by `permissions.deny` /
     `--exclude-tools`, unsatisfiable schema, prompt didn't
     instruct the model). Without this, three different failures
     all surfaced as the same generic "increase maxSessionTurns"
     line.
   - The synthesised "Skipped:" tool_result for suppressed sibling
     calls drops the trailing "Re-issue this call in a separate
     turn if needed." sentence on the success path, where the
     session terminates immediately and no consumer (model or SDK)
     can act on the advice. Retry path keeps the sentence — the
     model is about to receive these parts and may legitimately
     re-issue.

Tests cover each fix: bare-mode registration order, ACP / experimental-
acp rejection (×2), `--json-schema` hint in both text and JSON max-turns
output, and explicit Skipped-text assertions on the success and retry
paths.

* fix: address 9 self-qreview comments on --json-schema PR

Folds the 9 Suggestion-level comments from the previous /qreview pass
into code/test fixes. Each one is a real issue, but mostly defensive —
none changes the user-visible happy path.

**Refactors (F4/F5/F6 — code-quality)**

- F4 `nonInteractiveCli.ts`: extract `SUPPRESSED_OUTPUT_SUCCESS` /
  `SUPPRESSED_OUTPUT_RETRY` module-level constants and a
  `suppressedOutputBody(structuredCaptured)` helper. Both the main-turn
  and drain-turn synthesis sites previously had a 4-way duplicated
  ternary; future wording changes can no longer drift between them.
- F5 `nonInteractiveCli.ts`: extract `emitStructuredSuccess()` closure
  inside `runNonInteractive`. The "abortAll → bounded holdback → flush
  → finalize one-shot monitors → emitResult → return 0" terminal block
  is now defined once and called from both the main-turn and drain-turn
  success paths. `finalizeOneShotMonitors` is idempotent
  (`oneShotMonitorsFinalized` guard) so the unconditional invocation is
  safe even when the drain-turn already finalized monitors before
  reaching the helper.
- F6 `core/config/config.ts`: extract
  `registerStructuredOutputIfRequested()` helper. The synthetic-tool
  registration block is no longer duplicated between the bare-mode
  early-return branch and the regular registration branch.

**Tests (F7/F8/F9 — pin existing behaviour)**

- F7 `nonInteractiveCli.test.ts`: new test "holds back for in-flight
  background tasks before emitting structured success" — flips
  `hasUnfinalizedTasks: true → false` mid-poll so the holdback `while`
  body actually executes; spies on `abortAll` and asserts ordering of
  `task_notification` (must precede the result envelope) and the
  bounded elapsed-time cap. None of the existing structured-output
  success tests entered this branch (they all pinned
  `hasUnfinalizedTasks: () => false`).
- F8 `gemini.test.tsx`: new test "rejects --json-schema when running
  in interactive (TUI) mode" — pins the TUI guard at gemini.tsx:694,
  asserting the headless-only stderr message AND the exact ordering
  `writeStderrLine → runExitCleanup → process.exit(1)` so a future
  refactor can't swap any of those steps.
- F9 `cli/config.test.ts`: pin the two previously-untested
  `--json-schema` mutual-exclusion branches: `-i`/`--prompt-interactive`
  and `--input-format stream-json`. The stream-json check is
  load-bearing — `gemini.tsx:768` explicitly relies on this rejection
  holding (the parse-time `process.exitCode ?? 0` plumbing in the
  stream-json branch is only safe because `--json-schema` can't reach
  it).

**Behaviour fixes (F1/F2/F15 — privacy / security / correctness)**

- F1 `core/core/geminiChat.ts`: redact `functionCall.args` for
  `structured_output` tool calls before passing them to
  `chatRecordingService.recordAssistantTurn`. Without this, the
  user's structured payload (already emitted on stdout via
  `result` / `structured_result`) was persisted verbatim to
  `<projectDir>/chats/<sessionId>.jsonl` and re-fed into model
  context on `--continue` / `--resume`, contradicting the privacy
  contract documented next to the existing `ToolCallEvent` redaction.
  Each validation-failure retry was also recorded. Now mirrors the
  same `__redacted` placeholder. Helper extracted as
  `redactStructuredOutputArgsForRecording` so it's unit-testable.
- F2 `cli/config/config.ts`: `resolveJsonSchemaArg`'s `@path` reader
  now (a) `fs.statSync`s first to refuse non-regular files (FIFOs,
  character devices like `/dev/zero`, directories), (b) caps the
  schema file at 1 MiB so an attacker who can influence the path
  through a wrapping process can't OOM the run, and (c) on JSON
  parse failure for `@path` source emits a generic "content of
  <path> is not valid JSON" instead of echoing the SyntaxError —
  Node ≥18's SyntaxError embeds a ~10-char file-content prefix in
  its message, which would otherwise ride out on stderr through any
  wrapper that surfaces the error. Inline (non-`@path`) JSON keeps
  the SyntaxError detail because the user is the source.
- F15 `core/tools/tool-registry.ts`: `registerTool` now also checks
  the lazy `factories` map for name collisions, not just the eager
  `tools` map. An MCP server registering a tool whose name shadows a
  built-in lazy factory (e.g. `structured_output`) now gets
  auto-qualified to `mcp__<server>__<name>`, instead of silently
  winning the resolution. The synthetic structured-output tool no
  longer needs renaming for the corner case to be safe.

Targeted suite (13 changed-area test files): 883/886 pass — 3
pre-existing skips. Typecheck clean on both packages.

* fix: address 3 deepseek-v4-pro qreview comments on --json-schema PR

Three Suggestion-level comments from the latest /qreview pass.

**N1 — `schemaRootAcceptsObject` skips `if/then/else`** (cli/config/config.ts):
A schema like `{"if": true, "then": {"type": "string"}}` passed parse-time
gating but is unsatisfiable for object-typed tool args at runtime — the
model would loop until maxSessionTurns. Add a best-effort check for the
two decidable shapes:
- `if: true` → object MUST match `then`; if `then` excludes objects
  (boolean `false`, non-object `type`, etc.), reject at parse time.
- `if: false` → object MUST match `else` (`true` if absent); same check.
Object-schema `if` cases stay runtime-decidable and fall through to Ajv,
matching the existing best-effort scope on `not`. 4 new test cases pin
both reject and accept paths.

**N2 — subagent registries register `structured_output` too** (core/config/config.ts,
core/tools/agent/agent.ts, core/agents/backends/InProcessBackend.ts):
`createApprovalModeOverride` and `buildSubagentContextOverride` rebuild
the tool registry on a `Object.create(base)` config. `this.jsonSchema`
propagates through the prototype chain, so
`registerStructuredOutputIfRequested` was firing for every subagent
registry rebuild — but only `runNonInteractive`'s main / drain loops
detect a successful `structured_output` call as terminal. A subagent
that called the tool would receive "Session will end now" and then keep
running because its own loop has no terminator: wasted tokens, no
structured payload on stdout.

Add a `forSubAgent: true` option to `createToolRegistry` (alongside the
existing `skipDiscovery`), and propagate it from both subagent rebuild
sites. The structured-output registration helper short-circuits when
the flag is set. Bare-mode init does NOT set the flag, preserving the
F6 fix where `qwen --bare --json-schema X -p "..."` still gets the
synthetic tool. New test asserts the registry rebuilt with
`forSubAgent: true` registers READ_FILE / EDIT / SHELL but NOT
STRUCTURED_OUTPUT.

**N3 — TEXT-mode `structuredResult` not integration-tested** (nonInteractiveCli.test.ts):
All 8 existing `--json-schema` tests pin `OutputFormat.JSON` or
`STREAM_JSON`. TEXT (the default for `qwen -p ...`) has no integration
coverage, so a regression in
`BaseJsonOutputAdapter.buildResultMessage`'s
`hasStructured ? JSON.stringify(structuredResult) : resultText`
contract or in `JsonOutputAdapter.emitResult`'s text-mode
`process.stdout.write(`${result}\n`)` path would only surface to plain
`qwen -p` users. New test pins TEXT-mode behaviour: stdout is exactly
`${JSON.stringify(structuredArgs)}\n` — no JSON envelope, no event
log.

Targeted suite (13 spec files): 945/948 pass — 3 pre-existing skips.
Typecheck clean on both packages.

* fix(cli): narrow `not` rejection in schemaRootAcceptsObject

Address Critical review comment #3216123734.

`schemaRootAcceptsObject`'s `not` handler previously rejected any schema
whose `not.type` included `"object"`, regardless of what other
constraints `not` had. That's a false positive for schemas where the
extra constraints NARROW what `not` excludes:

    { "not": { "type": "object", "required": ["error"] } }

excludes only objects with an `error` key — the value `{}` satisfies
this schema fine, but the old check rejected it at parse time with
"--json-schema root must accept object-typed values".

Fix: only reject when `not` is exactly `{type: ...}` with no narrowing
siblings (the unambiguous "every object is excluded" case). When other
keywords are present (`required`, `properties`, `minProperties`,
`enum`, etc.), defer to Ajv at runtime — same best-effort scope as the
sibling `anyOf`/`oneOf`/`allOf` deep-content checks.

3 new test cases pin the fixed accept paths
(`{not:{type:"object",required:[...]}}`,
`{not:{type:"object",properties:...,required:[...]}}`,
`{not:{type:"object",minProperties:1}}`). The existing reject test for
bare `{not:{type:"object"}}` still passes.

* refactor: dedupe structured_output handling per qreview C1/C2/C3

Three Suggestion-level review comments from the latest /qreview pass.

**C1 — main-turn / drain-turn `structured_output` dispatch was
duplicated ~120 lines** (`nonInteractiveCli.ts`)

The two batch-handling sites had near-identical bodies (filter
`structured_output` from the batch when `--json-schema` is active →
iterate with `executeToolCall` → write to `structuredSubmission` on
first valid call → synthesise tool_result events for suppressed
siblings). The only meaningful difference was which `modelOverride`
binding the loop wrote to (session-scoped `modelOverride` for the
main turn vs per-drain-item `itemModelOverride`). Extracted
`processToolCallBatch(batchRequests, setModelOverride)` defined
inside `runNonInteractive`:

- Closes over session-scoped state (`adapter`, `config`,
  `abortController`, `options`, `structuredSubmission`,
  `executeToolCall`, `handleToolError`, `suppressedOutputBody`,
  the progress-handler helpers).
- Takes the `modelOverride` setter as the one call-site-specific
  parameter so the main turn binds to the session var and the drain
  binds to the per-item var.

Main-turn body went from ~120 lines to a single call; drain-turn body
likewise. Net file shrink ~80 lines, no behaviour change. All 42
existing structured-output tests still pass (including
`stops executing remaining tool calls...`,
`tries multiple structured_output calls in the same turn...`,
`synthesises tool_result for suppressed sibling calls...`,
`captures structured_output emitted from a drain-turn (queued notification)`).

**C2 + C3 — `{__redacted: '…'}` placeholder duplicated in two files**
(`telemetry/types.ts` + `core/geminiChat.ts`)

The `ToolCallEvent` constructor (for telemetry surfaces — OTLP /
QwenLogger / ui-telemetry / chat-recording UI event mirror) and
`redactStructuredOutputArgsForRecording` (for the on-disk
chat-recording JSONL) each had a verbatim copy of:

    { __redacted: 'structured_output payload (see stdout result)' }

If the redaction wording (or the `__redacted` key, or the placeholder
text) ever drifted between the two surfaces, the privacy contract
would be subtly broken on one and not the other.

Hoisted to `STRUCTURED_OUTPUT_REDACTED_ARGS` exported from
`packages/core/src/tools/syntheticOutput.ts`, imported in both sites.
The constant carries its rationale in a JSDoc block so future readers
see both call sites at once.

Targeted suite (13 spec files): 961/964 pass — 3 pre-existing skips.
Typecheck clean on both packages.

---------

Co-authored-by: wenshao <wenshao@U-K7F6PQY3-2157.local>
2026-05-11 14:21:55 +08:00
Shaojin Wen
e2f7661ef1
feat(cli): Ctrl+B promote keybind (#3831 PR-3 of 3) (#3969)
* feat(cli): Ctrl+B promote keybind — wire UI to PR-2's promoteAbortController (#3831 PR-3 of 3)

Final piece of the foreground → background promote feature. PR-1
(#3842) landed the `signal.reason` foundation; PR-2 (#3894) wired
`shell.ts` to detect a `{ kind: 'background' }` abort, snapshot
output, register a `BackgroundShellEntry`, and stash the promote
`AbortController` on `TrackedExecutingToolCall`. This PR exposes
the user-visible surface: pressing Ctrl+B during an in-flight
foreground shell command transfers ownership to a background task
the user can inspect via `/tasks` or stop via `task_stop`.

## Changes

- `keyBindings.ts`: new `Command.PROMOTE_SHELL_TO_BACKGROUND` bound to
  `Ctrl+B`. JSDoc explains the no-shell-running no-op semantics.

- `useReactToolScheduler.ts`: project `promoteAbortController` from
  the core's `ExecutingToolCall` through `TrackedExecutingToolCall`
  so the React layer (AppContainer keypress handler) can find it
  by callId without re-plumbing through the scheduler.

- `AppContainer.tsx`: `handleGlobalKeypress` gains a
  `PROMOTE_SHELL_TO_BACKGROUND` branch that walks
  `pendingToolCallsRef.current` (the ref, not the destructured
  array — keeps the deps list stable so the handler isn't re-bound
  on every tool-call status update), finds the executing tool call
  with a defined `promoteAbortController`, calls
  `.abort({ kind: 'background' })`, and returns early.
  No-op when no foreground shell is executing — Ctrl+B then falls
  through to the input layer's existing cursor-left binding.

- `keyboard-shortcuts.md`: documents Ctrl+B with explicit
  fall-through behavior so the conflict with the prompt-area
  cursor-left binding is intentional + understandable.

## Tests

- `keyMatchers.test.ts` (+1): Ctrl+B positive / bare-b + meta+b +
  Ctrl+other negatives.
- `AppContainer.test.tsx` (+2):
  - **Ctrl+B promotes** — pendingToolCalls includes an executing
    shell with a stubbed `AbortController` + spy; firing Ctrl+B
    asserts `abort({ kind: 'background' })` is called once.
  - **Ctrl+B no-op** — empty `pendingToolCalls` + Ctrl+B must NOT
    throw (pins the safety contract for the typing-mid-prompt
    case where the input layer's own Ctrl+B should still fire).
- 37/37 keyMatchers + 58/58 AppContainer pass; tsc + ESLint clean.

## E2E (manual, PR description guidance)

The unit / integration tests cover the keybind → abort wiring and
the promote handler's downstream behavior (PR-2's tests). Real-PTY
E2E is intentionally manual since headless test infrastructure
doesn't drive a real shell child + Ctrl+B keystroke; documented in
the PR description checklist.

Closes the 3-PR sequence for #3831 (Phase D part b of #3634).

* fix(cli): #3969 review wave — broadcast comment + debug log + redundancy

5 #3969 review threads addressed:

- **AppContainer.tsx Ctrl+B handler**: documented the
  KeypressContext.broadcast caveat (after `return`, the same Ctrl+B
  is still dispatched to text-buffer cursor-left + DebugProfiler;
  visible cursor-left side effect is cosmetic) so future readers
  understand why the prompt cursor moves on a successful promote.
  Added `debugLogger.debug` calls on both branches (matched callId
  on success; streamingState + pendingToolCalls.length on no-op
  fall-through) so "Ctrl+B doesn't work" reports are debuggable.

- **useReactToolScheduler.ts TrackedExecutingToolCall**: dropped
  the redundant `pid?` and `promoteAbortController?` declarations
  — both come through the `& ExecutingToolCall` intersection
  unchanged. Fixed the JSDoc that wrote `{ kind: 'background',
  shellId }`: callers don't generate `shellId` (it's optional on
  the abort-reason union and `handlePromotedForeground` produces
  it downstream). The corresponding executing branch in
  `toolCallsUpdateHandler` no longer projects pid /
  promoteAbortController explicitly — `...coreTc` already spreads
  them; the explicit-undefined clearing in the non-executing
  branch is also dropped (those fields aren't on coreTc when
  status !== 'executing', so `...coreTc` doesn't carry them).

- **AppContainer.test.tsx**: replaced two `as unknown as Key`
  double-casts with direct `: Key` annotations on the literal —
  the object already conforms to the Key interface, double-cast
  was bypassing type safety needlessly.

Tests: 37/37 keyMatchers + 58/58 AppContainer pass; tsc + ESLint
clean. No behavior change beyond the new debug log lines.

* fix(cli): #3969 wave — tool-name guard + non-shell test + defensive clear

3 #3969 review threads addressed; 1 deferred:

- AppContainer.tsx: Ctrl+B `find()` predicate now also checks
  `tc.request.name === ToolNames.SHELL` before matching the executing
  tool call. Defense-in-depth — today only the shell tool wires
  `promoteAbortController`, but a future copy-paste / type confusion
  that adds the property to a non-shell tool would otherwise let
  Ctrl+B mistakenly fire `abort({kind:'background'})` on a tool
  whose service has no promote-handoff handler.

- useReactToolScheduler.ts: re-added explicit `pid: undefined` and
  `promoteAbortController: undefined` to the non-executing return.
  Previously dropped on the assumption that `...coreTc` doesn't
  carry these fields when the status isn't `executing` — true today,
  but the explicit clearing is defense-in-depth against a future
  core change that adds either field to a non-executing status type
  (would surface as a stuck PID display or a Ctrl+B handler that
  matches a no-longer-executing tool call).

- AppContainer.test.tsx: replaced the placeholder "no-op when no
  pending tool calls" framing on the empty-array case (it does
  exercise the `executing-status` predicate but NOT the tool-name
  guard) with TWO tests:
    1. existing empty-array no-throw test (renamed for clarity)
    2. NEW: executing non-shell tool with a hostile-shape
       `promoteAbortController` — asserts `abortSpy` is NOT called.
       This is the regression test for the new tool-name guard above.

Tests: 61/61 AppContainer.test.tsx pass; tsc + ESLint clean.

Deferred to follow-up (replied + tracked):
- `debugLogger.debug` is file-only; success-path "agent unblocks +
  next message says 'promoted to bg_xxx'" is the user-visible signal.
  Adding a synthetic history item or stderr line for the gap between
  keypress and agent message conflicts with Ink rendering and is
  better as a focused UX PR.

* test(cli): pin inheritance of pid + promoteAbortController via type assertions

#3969 review: the earlier "redundant declaration" review removed the
explicit `pid?: number` and `promoteAbortController?: AbortController`
from `TrackedExecutingToolCall`, relying on the `& ExecutingToolCall`
intersection to inherit them. Current review flags the type-safety
regression: if core renames or removes either field, the React-side
build won't catch it locally — Ctrl+B handler silently breaks at
runtime.

Compromise: keep the type minimal (no re-declaration noise the prior
review flagged) but add compile-time `extends keyof ExecutingToolCall`
assertions that fail loudly + locally if either field disappears.
The assertions are evaluated at compile time and zero-cost at
runtime; the dummy `const` pins them so they aren't dead code.

61/61 AppContainer tests pass; tsc clean.
2026-05-11 14:03:38 +08:00
易良
cb7059f54d
feat(installer): add standalone archive installation (#3776)
* feat(installer): add standalone archive installation

* fix(installer): harden standalone archive installs

* fix(installer): address standalone review findings

* chore(installer): clarify review followups

* fix(installer): stabilize standalone script checks

* chore(installer): remove internal planning docs

* chore(installer): simplify standalone release review fixes

* test(installer): add Windows batch install smoke

* test(installer): fix Windows batch smoke quoting

* test(installer): preserve Windows cmd quotes

* fix(installer): use robust Windows checksum hashing

* ci: narrow installer debug matrix

* fix(installer): address standalone review hardening

* fix(installer): avoid Windows validation parse errors

* fix(installer): simplify Windows option validation

* fix(installer): harden standalone review fixes
2026-05-11 13:25:48 +08:00
易良
576bd8e0a7
ci: skip unnecessary release and SDK checks (#3984)
* ci: skip unnecessary release and SDK checks

* ci: guard release skip classification for non-pr events

* ci: harden release sync skip gate

* ci: refine release sync skip fallback
2026-05-11 13:20:20 +08:00
Shaojin Wen
b949ae070c
feat(tools): defer low-frequency built-in tools to reduce initial prompt size (#4022)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* feat(tools): defer low-frequency built-in tools to reduce initial prompt size

Mark Monitor, SendMessage, Skill, TaskStop, TodoWrite, and WebFetch as
shouldDefer=true so their full schemas are excluded from the initial
function-declaration list. The model discovers them on demand via
ToolSearch, aligning with Claude Code's deferral strategy for
infrequently used tools.

* fix(tools): don't defer TodoWrite/Skill — they're required by the system prompt

TodoWrite is mandated as high-frequency by the core system prompt
("Use VERY frequently", "IMPORTANT: Always use the TODO_WRITE tool to
plan and track tasks throughout the conversation"). Deferring it forces
a ToolSearch round-trip before every required call, adding latency and
hurting prompt compliance.

SkillTool's full description carries the dynamically generated
<available_skills> listing and the BLOCKING invocation rules
("invoke the relevant Skill tool BEFORE generating any other response").
The deferred-tool summary truncates this, so the model loses both the
list of skills and the activation contract from the initial prompt.

Keep Monitor / SendMessage / TaskStop / WebFetch deferred — those are
genuinely infrequent and have no system-prompt requirement.
2026-05-11 11:40:13 +08:00
jinye
0a05ea8004
feat(telemetry): inject traceId/spanId into debug log files for OTel correlation (#3847) 2026-05-11 07:42:56 +08:00
Gordon Lam
464e4cf343
feat(core): write runtime.json sidecar for active sessions (#3714)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* feat(core): write runtime.json sidecar for active sessions

Port kimi-cli PR #2082 part 1 to qwen-code.

On interactive session start, atomically write a small JSON sidecar at <projectDir>/chats/<sessionId>.runtime.json recording the (pid, session_id, work_dir, hostname, started_at, qwen_version) tuple.

External tools (terminal multiplexers, IDE integrations, status daemons) can map a running PID to its session id and work dir without parsing argv. Write is best-effort: a read-only filesystem must not block UI startup.

OS process title (was #3713) and dynamic OSC tab title (kimi #2083) remain out of scope.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(core): refresh runtime.json on same-PID session swap

Config.startNewSession() reassigns this.sessionId in the same process,
which is reached by /clear, /reset, /new and /resume. Previously the
old <oldId>.runtime.json was left behind, falsely claiming the still-
live PID for a session no longer being served, and no new sidecar was
written for the incoming session.

Centralize the swap by clearing the old sidecar and writing a fresh
one for the new session id from inside startNewSession itself, so all
same-PID transitions are covered. The refresh runs as a fire-and-
forget best-effort; failures must not block the session switch.

Mirrors the post-merge Codex P1 fix on kimi-cli PR #2082 (the source
of the runtime.json sidecar pattern this PR ports).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(core): only refresh runtime.json when this process owns it

Mirrors kimi-cli PR #2082 commit e237951f (Codex P1 r3158754463): a
short-lived non-interactive invocation (qwen --prompt, ACP, etc.) that
runs `/clear` would otherwise call `Config.startNewSession()`, delete
a concurrent shell's runtime.json sidecar (same outgoing session id),
and never write a replacement — leaving the shell discoverable to
nobody.

Add a `runtimeStatusEnabled` flag on Config, flipped on by the
interactive UI bootstrap immediately after the first successful
sidecar write, and gate the swap-time refresh in
`startNewSession()` on it. Non-interactive entry points never reach
the bootstrap, so they won't touch sibling sidecars.

Kimi later reverted the equivalent `write only from shell mode`
guard (commit 7083975a) in favor of writing from every long-lived
mode, but qwen's wire point is already interactive-only, so the
narrower guard is the right shape here.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 01:21:49 +08:00
Yan Shen
9bd5a0180b
feat(cli): core built-in i18n coverage (#3871)
* feat(i18n): expand built-in locale coverage

* feat(cli): add dynamic slash command translation

* test(cli): stabilize session picker assertions

* fix(core): close jsonl readers before cleanup

* fix: address i18n review regressions

* fix(cli): address dynamic i18n review findings

* fix(cli): address i18n review follow-ups

* fix(cli): address i18n review feedback

* test(cli): align i18n parity coverage with strict locales

* fix(cli): address i18n review findings
2026-05-10 22:35:03 +08:00
易良
04729d646c
test: stabilize main e2e flakes (#3992)
* test: stabilize main e2e flakes

* test: stabilize macos e2e assertions
2026-05-10 21:50:04 +08:00