Commit Graph
6751 Commits
Author SHA1 Message Date
f11bb31df2 fix(cli): fix thought viewer truncation, layout gaps, and choppy scrolling in VP mode (#6002)
* fix(cli): wrap thought viewer text to visual lines

The full-screen ThinkingViewer split `data.text` on '\n' and rendered each
logical line with `wrap="truncate-end"`. A thought is usually a single long
paragraph with no newlines, so it collapsed to one ellipsised row above an
empty box and `maxScroll` stayed 0 (could not scroll).

Pre-wrap the text to visual rows at the inner content width (border + paddingX
= 4 cols) before slicing, reusing the existing `wrapToVisualLines` helper (now
exported from ConversationMessages). Scrolling and rendering now operate on the
same rows the user sees.

Generated with AI

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): collapse VP viewport to content and sink the composer

In terminal-buffer (VP) mode the conversation wasted vertical space:

- VirtualizedList pinned its root box to the full `containerHeight`, so short
  content left a tall blank gap and pushed the composer far down. Collapse the
  box to `min(containerHeight, totalHeight)` so it grows with its content like
  the legacy <Static> path; the scroll math still uses the full height, so
  overflow scrolling is unchanged.
- `availableTerminalHeight` subtracted `staticExtraHeight` +
  `MAIN_CONTENT_HEIGHT_RESERVATION`, the <Static> overflow-flicker guards. VP
  clips natively and does not need them, so they stranded ~5 blank rows below
  the composer (input never reached the bottom). Drop the reservation in VP;
  non-VP keeps it unchanged.

Generated with AI

Co-authored-by: Qwen-Coder <[email protected]>

* perf(cli): coalesce VP scroll events per frame

Terminal mouse reporting emits one event per row crossed, so a brisk wheel
spin or scrollbar drag fired a rapid burst, each applied synchronously with a
full Ink reflow + terminal flush — the source of the choppy scroll. Accumulate
wheel deltas / the latest drag row in refs and flush at most once per ~16ms
frame. A press still applies instantly; under NODE_ENV==='test' updates apply
synchronously so the existing timer-free tests keep passing.

Generated with AI

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): cancel queued scroll on scrollbar press

A wheel burst schedules a 16ms coalescing flush. If the user clicked the
scrollbar within that window, the press applied its row immediately but the
still-armed timer then fired `scrollBy` with the leftover wheel delta, yanking
the view off the clicked row. Clear the pending wheel/drag intent and cancel
the timer when a scrollbar press takes over.

Generated with AI

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): keep a small height slack in VP mode

Zeroing the VP layout reservation removed all slack, so when the composer grew
(multi-line input) the one-frame-late controlsHeight measurement briefly
oversized the list and overflowed the terminal — the exact jitter the layout
change aimed to remove. Drop only the Static-specific staticExtraHeight (3) and
keep MAIN_CONTENT_HEIGHT_RESERVATION (2) as a transient-measurement buffer; the
composer still sits far closer to the bottom than before (was ~5 stranded rows).

Generated with AI

Co-authored-by: Qwen-Coder <[email protected]>

* refactor(cli): move wrapToVisualLines to textUtils

It lived in the 1000-line ConversationMessages component and ThinkingViewer
imported it across components. Co-locate it with its sibling visual-wrapping
helper (sliceTextByVisualHeight) in utils/textUtils.ts and import from there.

Generated with AI

Co-authored-by: Qwen-Coder <[email protected]>

* refactor(cli): share frame-coalesced scroll hook and test it

Extract the per-frame scroll coalescing into useFrameCoalescedFlush and use it
from both ScrollableList and ThinkingViewer (whose wheel handler was still
un-batched, so a brisk spin in the expanded thought viewer stuttered).

Drop the NODE_ENV==='test' escape hatch that made the production timer path
unreachable in tests: the mouse-scroll tests now advance a real frame before
asserting, exercising the batching/accumulation/precedence logic. Adds a
regression test for a scrollbar press canceling a still-pending wheel flush.

Generated with AI

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): match legacy bottom spacing in VP mode (no reservation)

The static/<Static> path reserves no blank rows under the composer — the
staticExtraHeight + MAIN_CONTENT_HEIGHT_RESERVATION budget only caps inline
streaming-message height, while the composer flows to the very bottom of the
terminal. Reserve nothing in VP so its composer reaches the bottom the same
way, instead of leaving a 2-row gap. The one-frame controlsHeight measurement
lag on composer growth mirrors legacy mode letting the terminal scroll.

Generated with AI

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: 秦奇 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-06-29 12:41:55 +00:00
e2b85559a3 feat(cli): add --safe-mode flag to disable all customizations for troubleshooting (#4943)
* feat(cli): add safe mode

* fix(test): add isSafeMode to test mocks and config stubs

* fix(ui): hide memory toggles in safe mode

* fix(cli,core): harden safe mode guards for permissions and extensions

Add safeMode guards to all permission-related settings in loadCliConfig
that previously only checked bareMode — mergedAllow, mergedAsk,
mergedDeny, resolvedCoreTools, disabledSlashCommands, disabledTools,
coreTools, allowedTools, autoMode, and approvalMode. Without these
guards, a malicious repo settings.json could auto-approve all shell
commands even in --safe-mode.

Refactor the extension loading conditional to eliminate the empty if-body
(safeMode branch) that was fragile against linter auto-fixes.

Add diagnostic log line in Config constructor when safe mode activates,
so daemon/headless/CI contexts have a log entry explaining which
subsystems are disabled.

Add comprehensive safe mode test coverage (7 tests) in CLI config tests.

Co-Authored-By: Qwen Code <[email protected]>

* fix(test): add isSafeMode to config stub in gemini.test.tsx

The 'writes non-interactive warnings' test from main was missing
isSafeMode() on its configStub, causing a TypeError after the merge.

Co-Authored-By: Qwen Code <[email protected]>

* fix(cli,core): close safe-mode bypass gaps in context loading and child agents

Add isSafeMode() guards to code paths that previously only checked
bareMode, allowing project/user customizations to leak through in
safe mode:

- AppContainer performMemoryRefresh: skip context file loading
- /directory add: skip include-directory memory reload
- CLI config: output-language.md, includeDirectories, sandbox,
  loadMemoryFromIncludeDirectories
- Config.getProjectHooks/getUserHooks: return undefined in safe mode
- Daemon workspace agents: pass real safe-mode state instead of
  hardcoded false

Propagate safeMode through AgentPersistedCliFlags so child agents
spawned via the agent tool inherit safe mode.

Add debug logging when subagent-manager overrides a requested level
in safe mode. Add unit tests for safe-mode branches in
subagent-manager and skill-manager that were previously untested.

Co-Authored-By: Qwen Code <[email protected]>

* fix(test): add isSafeMode to directoryCommand mock config

The new config.isSafeMode() call in directoryCommand.tsx threw
TypeError in tests because the mock config did not stub it. The
error was caught by the surrounding try/catch, flipping
messageType from info to warning and failing 4 tests.

Co-Authored-By: Qwen Code <[email protected]>

* fix(safe-mode): address code review feedback and improve consistency

- Fix type error in config test (approvalMode string literal)
- Fix dead else-if branch in extension loading logic
- Remove unnecessary definite assignment assertion on safeMode field
- Add safe-mode guards for disabledSkillNamesProvider and getMcpServers()
- Gate getPreventSystemSleepEnabled() with isSafeMode() check
- Extract shared isSafeModeEnv() helper to deduplicate env var resolution
- Refactor comma operator in subagent-manager ternary for readability
- Use config.getWorkingDir() instead of process.cwd() in AppContainer
- Add warning when --core-tools flag is used in safe mode
- Add test coverage for getMcpServers() returning empty in safe mode

* fix: rename safeMode.ts to safe-mode.ts for kebab-case convention

* fix(safe-mode): close extension leak and restore bare-mode sleep prevention

- Remove dead else-if branch: adding !isSafeMode() guard made it unreachable
  since the first if already covers !safeMode && !bareMode
- Remove unused getExplicitExtensionNames() private method
- Remove unrelated !getBareMode() from getPreventSystemSleepEnabled to
  preserve bare-mode sleep inhibition

* fix(safe-mode): address review feedback on bare-mode extensions, env parsing, and test mock

- Add missing Protocol export to contentGenerator mock in safe-mode test
- Restore bare-mode explicit extension loading that was dropped in previous fix
- Use shared isTruthy helper for consistent env var parsing in isSafeModeEnv

* fix(core): add missing DEFAULT_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH to safe-mode test mock

The telemetry mock in config.safe-mode.test.ts was missing the
DEFAULT_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH export, causing all 14
tests to fail with:
  No "DEFAULT_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH" export is defined
  on the "../telemetry/index.js" mock

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen Code <[email protected]>
Co-authored-by: 易良 <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-06-29 12:29:48 +00:00
DennisYu07andQwen-Coder 0af3cd8131 fix(core): filter thought parts from Stop hook last_assistant_message (#6009)
getLastModelMessageText() concatenated all text parts from the last model
message without filtering out thought parts (part.thought === true).
This caused the Stop hook's last_assistant_message to include the model's
internal reasoning text, making structured output validation difficult.

Added !part.thought to the filter condition in both GeminiChat and the
client fallback implementation, consistent with existing thought filtering
in copyCommand, partUtils, and stripThoughtPartsFromContent.

Added two unit tests:
- filters out thought parts from mixed model messages
- returns undefined when all text parts are thoughts

Co-authored-by: Qwen-Coder <[email protected]>
2026-06-29 12:16:30 +00:00
TianyuanandClaude Opus 4.8 35a8851a38 feat(core): add --insecure flag to skip TLS verification for self-signed endpoints (#3535) (#5962)
* feat(core): add --insecure flag to skip TLS verification for self-signed endpoints (#3535)

Enable skipping TLS certificate verification for outbound model API connections via a new --insecure CLI flag, QWEN_TLS_INSECURE, or NODE_TLS_REJECT_UNAUTHORIZED=0. The setting is applied to the undici dispatcher Qwen Code installs: a direct connection uses connect TLS options, while a proxied connection disables verification for the upstream origin (requestTls) and a self-signed HTTPS proxy (proxyTls). Off by default; behavior is unchanged when not enabled.

Fixes #3535

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(core): address review on --insecure (security + simplification) (#3535)

- Block project .env from enabling QWEN_TLS_INSECURE by adding it to PROJECT_ENV_HARDCODED_EXCLUSIONS, so an untrusted repo cannot silently disable TLS verification for all API connections.
- Remove the unverifiable Bun fetch `tls` special-casing; Bun users can still opt out via NODE_TLS_REJECT_UNAUTHORIZED=0, which Bun honors natively.
- Do not suggest `--insecure` in the TLS error hint when verification is already disabled; show a network/protocol-oriented message instead.
- Add tests: env-flag regex/falsy branches, loadCliConfig env side-effect, fetch hint variants, and a security guard for the project .env exclusion.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(core): harden --insecure per review (#3535)

- Block NODE_TLS_REJECT_UNAUTHORIZED from project .env too (initial load only consults PROJECT_ENV_HARDCODED_EXCLUSIONS), since isTlsVerificationDisabled() honors it.
- When opting out, set NODE_TLS_REJECT_UNAUTHORIZED=0 process-wide in loadCliConfig and emit a stderr MITM warning. This makes the opt-out effective on the Bun runtime and the proxy-creation fallback path (which the undici dispatcher does not cover), and gives a user-visible signal.
- Avoid evaluating isTlsVerificationDisabled() twice on the proxy path via a default parameter on getOrCreateSharedDispatcher.
- Update tests accordingly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(cli): broaden --insecure warning and log it; add env-path tests (#3535)

- Broaden the TLS-disabled warning to state the process-wide blast radius (API, OAuth, MCP servers, child processes), since NODE_TLS_REJECT_UNAUTHORIZED=0 is set process-wide.
- Also emit the warning via debugLogger so the state is discoverable in ~/.qwen/debug/ after terminal scrollback is gone.
- Add tests for the env-var-only path (pre-set QWEN_TLS_INSECURE) and the already-disabled guard (no duplicate assignment/warning).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-29 12:03:43 +00:00
易良 1af1bb8a55 fix(ci): cover release integration regressions (#5994)
* fix(ci): cover release integration regressions

* fix(ci): retry linter archive downloads

* fix(ci): keep release CI PR focused
2026-06-29 11:54:11 +00:00
ytahdnandytahdn 8babaa47e0 fix(web-shell): improve follow-up suggestion handling (#5996)
Co-authored-by: ytahdn <[email protected]>
2026-06-29 10:27:14 +00:00
jinyeandQwen-Coder c90e6e7ba4 feat(channels): Add channel agent bridge abstraction (#5978)
* feat(channels): add channel agent bridge abstraction

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): handle bridge session lifecycle cleanup

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): close bridge lifecycle review gaps

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#5978)

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#5978)

Co-authored-by: Qwen-Coder <[email protected]>

* fix: address channel bridge review feedback

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#5978)

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#5978)

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-06-29 10:03:28 +00:00
callmeYe dd077b9eeb fix(daemon): filter specialized models from selectors (#5993) 2026-06-29 09:36:03 +00:00
ytahdnandytahdn b737364892 fix(web-shell): prefer raw file diffs in tool output (#5992)
Co-authored-by: ytahdn <[email protected]>
2026-06-29 08:00:16 +00:00
jinyeandQwen-Coder 23830a180b fix(cli): avoid ACP runtime preload on serve fast path (#5989)
Route serve request helper imports through lightweight ACP bridge subpaths so runQwenServe can keep the ACP runtime behind its existing dynamic boundary.

Add source graph and esbuild metafile guards that fail if the serve fast path statically reaches the ACP runtime modules again.

Refs #4748

Co-authored-by: Qwen-Coder <[email protected]>
2026-06-29 07:41:15 +00:00
jinye 9601d90b78 fix(ui): display output tokens instead of cumulative API throughput for subagents (#5972)
Multiple UI components displayed executionSummary.totalTokens (cumulative
sum of total_tokens across all API rounds) as the subagent token count.
For a subagent making 87 requests with ~51K prompt each, this inflated
to 4.4M — misleading users into thinking it was context window usage.

Switch all subagent token displays to use outputTokens (what the model
actually generated), aligning with the loading indicator which already
correctly shows output tokens only.

Closes #5683
2026-06-29 07:27:41 +00:00
68348e236a feat(channels): qwen tag — RFC + Phase 0 (multiplayer channel-resident agent) (#5888)
* docs(channels): add "qwen tag" RFC — channel-resident multiplayer agent

Design for a persistent, multiplayer agent that lives in a chat channel
(DingTalk-first), built on the existing channel adapters (qwen channel start,
packages/channels/*) and the qwen serve daemon rather than a new service.
Covers the phased plan: Phase 0 multiplayer identity, Phase 1 proactive engine
(scheduler + cold-group push, daemon migration), Phase 2 channel memory +
governance, plus tradeoffs and resolved design decisions.

Part of #5887

Co-Authored-By: Qwen-Coder <[email protected]>

* feat(channels): qwen tag Phase 0 — multiplayer identity & group-safe commands

In a thread-scoped group every member shares one session, which surfaces a few
gaps in the channel layer. Phase 0 closes them as a backward-compatible
increment on the existing AcpBridge channel path:

- Inject a [sender] marker into the prompt for group turns so the agent can tell
  speakers apart; skipped for 1:1 chats and for already-prefixed re-entries.
- Add Envelope.alreadyPrefixed so collect-mode coalescing does not double-prefix
  the already-tagged buffered text.
- Require "/clear confirm" in groups — a bare /clear no longer wipes the shared
  session for the whole channel; DMs still clear directly.
- Add a read-only /who reporting channel / workspace / session scope without
  creating a session.
- Drop DingTalk group messages with no conversationId, so the shared session is
  never keyed on the expiring sessionWebhook.
- Fix the stale ChannelConfig.dispatchMode JSDoc (runtime default is 'steer').

Adds unit tests for sender attribution, the collect double-prefix guard, the
group /clear confirmation, and the read-only /who.

Part of #5887

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): scope group clear confirmation

* fix(channels): address review on qwen tag Phase 0

- Restrict /clear in a shared (thread) group to config.allowedUsers (when set)
  and require an explicit "confirm"; only the shared case is gated — DMs and
  per-user groups clear directly. Reconcile the RFC (OD-4) with this approach
  (a hyphenated /clear-channel isn't parseable; per-member owner-gate waits on
  the identity model, OD-3/OD-11).
- Sanitize the injected [sender] marker (strip brackets/CR/LF, cap length) so a
  crafted nick can't break out of or spoof the attribution tag.
- Surface to stderr when a collect-mode coalesced re-entry drops buffered turns
  instead of swallowing the error.
- Narrow Envelope.alreadyPrefixed to the literal `true` (internal-only flag).
- Expose DingtalkChannel.isUnroutableGroupMessage and test the
  group-without-conversationId drop; replace flaky setTimeout sequencing in the
  collect test with vi.waitFor; add tests for /who (active + private scope) and
  /clear authorization in a shared group.

Part of #5887

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): preserve group command routing

* fix(channels): harden group routing (map leak, injection, slash attribution)

Address the surviving Phase-0 review criticals:

- /clear now purges every per-session map (sessionQueues, activePrompts,
  collectBuffers), not just instructedSessions, so a long-running gateway
  doesn't leak dead session entries.
- QQ group adapter sanitizes the self-prefixed sender name. It sets
  alreadyPrefixed, which bypasses ChannelBase's [..]/newline/length guard,
  so a crafted QQ nickname could otherwise inject brackets/newlines.
- Unrecognized slash commands in a shared group keep their [sender]
  attribution; recognized commands (a local handler or a forwarded agent
  command) still reach the agent verbatim.
- Sanitize quoted referencedText (strip control chars, cap at 500) so it
  can't inject newlines/instructions or balloon the prompt.
- /who reports only the workspace basename, not the absolute cwd.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): close Phase-0 review criticals (quote injection, /clear cancel, drain log)

- referencedText: strip the wrapper's own delimiters (" [ ]) in addition to
  control chars, so a quoted message can't break out of [Replying to: "..."]
  and inject its own top-level instructions.
- /clear: cancel any in-flight bridge.prompt() for the cleared session(s) and
  drop their buffered follow-ups before purging the maps, so a running turn
  can't deliver a stale response into — or resurrect — the cleared session.
- collect-drain failure log now includes the sessionId and last sender so
  concurrent sessions are diagnosable.
- Extract the duplicated sender-name sanitizer into a shared sanitizeSenderName
  helper used by both ChannelBase and QQChannel; reuse isSharedGroupSession for
  the /who scope note.
- Tests: quote-breakout payload, /clear cancels in-flight, /who in a DM,
  single-scope hasSession/removeSession, and a sanitizeSenderName unit test.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): make group slash-command pass-through race-free and invalidate queued turns on /clear

CRITICAL 1 (sender attribution race): availableCommands is populated
asynchronously by the ACP available_commands_update notification, so a
registration check could see an empty list on a fresh session and wrongly
[sender]-prefix the first real /command — corrupting it into plain text.
Stop inline-prefixing ANY slash-shaped message: pass it through verbatim so
it always parses as a command regardless of load/registration state. Also
widen the command token to include - and : so /compress-fast and /git:commit
parse as commands. Tradeoff: an UNREGISTERED group slash command no longer
carries [sender] attribution (inverse of the earlier R2 ask) — not breaking
real commands is the safer default; flagged for maintainers.

CRITICAL 2 (/clear vs queued followups): a teammate turn that entered
handleInbound() before /clear confirm had already captured the prev.then()
chain and would still run bridge.prompt() against the just-cleared session.
Add a per-session generation counter bumped up-front by /clear; a queued turn
snapshots it at enqueue and bails if it was bumped before the turn dequeued.

Tests: fresh-session/empty-availableCommands pass-through, hyphenated command,
unrecognized command (flagged behavior), non-slash still prefixed; /clear
genuinely awaits a PENDING in-flight turn before confirming; /clear confirm
invalidates an already-queued followup (no resurrection); collect-drain
failure logs the lost count with sessionId + sender to stderr.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): only skip group [sender] tag for real slash commands

The group attribution check keyed off the lenient parseCommand(), which
accepts slash-prefixed paths (e.g. /tmp/foo) as commands, so that prose
reached the model without the [sender] tag. Decide command-vs-prose with
a dedicated isSlashCommand() that mirrors the CLI's classifier
(cli ui/utils/commandUtils.ts isSlashCommand): reject //, /*, a bare /,
and any path separator in the first token. Slash-prefixed paths and
comments now keep their speaker attribution, while real commands
(/compress, /git:commit) still pass through verbatim. Purely lexical, so
it stays race-free against the async command list.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): bound /clear's wait on a wedged in-flight turn

doClear awaited active.done after cancelSession, but active.done only
resolves in the prompt() finally. If the ACP child is wedged (stuck tool
call, not reading stdin, or crashed without closing), cancelSession may
throw (swallowed) or succeed yet the prompt never returns, so active.done
never resolves and /clear — and the whole channel — hangs forever.

Race the wait against a CLEAR_CANCEL_TIMEOUT_MS (3s) timeout and purge
anyway when it wins: the per-session maps are still purged and the
generation already bumped, so a turn that settles later is invalidated.
Cancellation stays best-effort.

Tests: a wedged session whose active.done never resolves still completes
/clear (purges every map, replies "Session cleared") within the timeout
via fake timers — no real wait(ms); plus /clear Confirm / CONFIRM
(mixed-case) is accepted, guarding the handler's .toLowerCase().

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): test QQ group slash branch, log dropped queued turns, reclaim cleared generations

Address review feedback on the Phase-0 qwen-tag /clear + group-slash work:

- QQChannel: cover the previously-untested `isSlash` branch of handleGroup
  — a group `/clear` is forwarded verbatim (no `[sender]` tag) and must NOT
  set `alreadyPrefixed`. Catches a regression that always sets it.
- ChannelBase: when a followup turn is dequeued after `/clear` bumped the
  session generation, log the drop (with sessionId) instead of returning
  silently, so an unanswered queued message is diagnosable.
- ChannelBase: reclaim the bumped `sessionGenerations` entry once the cleared
  session's queue drains (or immediately when nothing was queued), so a
  long-running gateway no longer leaks one entry per `/clear`. Reclamation is
  deferred and guarded so it can't delete an entry a still-queued turn needs,
  and a wedged in-flight turn can't block `/clear`.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): harden qwen-tag group session gating, cancel bounds, and prompt sanitization

Address the latest PR #5888 review batch (4 Criticals + suggestions):

- Treat sessionScope 'single' as a SHARED group session: the shared-scope
  predicate now covers both 'thread' and 'single' (via SHARED_SCOPES), so a
  'single'-scoped group can no longer bypass /clear confirm + allowlist gating
  and wipe the channel-wide __single__ session with a bare /clear.
- Bound the steer-mode wind-down wait: race active.done against
  CLEAR_CANCEL_TIMEOUT_MS (and fire-and-forget the cancel) so a wedged ACP child
  can't pin the session queue forever, matching the /clear hardening.
- Make /clear's cancelSession request fire-and-forget so a wedged cancel request
  can't hang /clear before the bounded active.done wait even starts.
- Strip Unicode line/paragraph separators (U+2028/U+2029) and bidi overrides
  (U+202A-U+202E, U+2066-U+2069) from sender names and quoted text via shared
  sanitize helpers (sanitizeSenderName, new sanitizeQuotedText); route
  referencedText and attachment filenames through the shared quoted sanitizer.
- Add sender/conversation context to the generation-bail drop log and the
  DingTalk unroutable-group-message drop log.
- Tests: fix TS4111 bracket-access in ChannelBase tests (test-inclusive
  typecheck clean); add load-bearing coverage for single-scope sharing, wedged
  steer, wedged /clear cancel, Unicode/bidi sanitization, and the
  generation-reclamation guard fire paths.
- Correct the isSlashCommand JSDoc to document the intentional bare-'/'
  divergence from the CLI classifier; note SessionRouter's by-sender scan does
  not match single-scoped keys (latent today).

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): stop block-streaming on /clear, not just cancel it

doClear flipped active.cancelled = true but never called
active.stopStreaming(), unlike the /cancel handler. In block-streaming
channels, text already buffered in the BlockStreamer could still be
emitted by the idle timer during/after /clear, leaking a stale response
into the just-cleared session. Mirror /cancel: also tear down the
streamer on the cancelled prompt in the clear path.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): gate /clear for single-scope DMs and unstick wedged steer queue

Two follow-up fixes in ChannelBase, both on my recent fixes.

CRITICAL 1: `single` scope maps EVERY sender — group OR DM — to the one
`__single__` session, but the shared-session guard also required `isGroup`,
so anyone who could DM the bot could bare-/clear the channel-wide session
without the confirm + allowedUsers gate. The shared-session predicate now
treats `single` as shared regardless of isGroup (`thread` stays group-only);
/help and /who follow the same predicate.

CRITICAL 2: the bounded steer wait stopped `await active.done` from hanging,
but the replacement turn was still chained behind the wedged turn's
never-resolving sessionQueues tail, so the follow-up (and every later
message) hung forever. On the steer timeout we now re-seed the chain
(prev = resolved) so the next turn starts a fresh chain; the wedged turn
stays cancelled, so a late settle still can't deliver a stale response.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): sanitize attachment filePath and tighten isSlashCommand

The attachment filePath was embedded raw in the prompt while the filename beside it was sanitized. Adapters build the path by basename()-ing the user-supplied filename, so its last segment carries the same attacker-controlled chars (brackets, newlines, U+2028, bidi overrides), letting a crafted filename inject prompt lines via the path. Route the rendered path through the same sanitizeQuotedText neutralization; att.filePath itself is left intact, and benign names render unchanged so the agent's read-file tool still resolves them.

Also tighten isSlashCommand to require parseCommand()'s token charset ([a-zA-Z0-9_:-]+ plus an optional @botname). A non-command-shaped input like /cafe or a zero-width-laden token previously skipped the group [sender] tag yet was not a runnable command, reaching the shared session as unattributed prose. It now keeps its attribution.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): guard activePrompt cleanup against replacement-turn clobber

The per-turn `finally` in ChannelBase deleted `activePrompts[sessionId]`
unconditionally. With the steer-mode bounded wait, this corrupts steer
protection: when turn A wedges (its `bridge.prompt()` never resolves), the
`CLEAR_CANCEL_TIMEOUT_MS` race times out (`steerWedged`), and turn B starts a
fresh chain and re-seeds `activePrompts` with its own entry. When A's prompt
finally settles and reaches its `finally`, the unconditional delete removes
B's entry — so a later turn C sees `activePrompts.get(sessionId) === undefined`
and silently loses steer protection (it forwards verbatim instead of
cancelling + re-prompting).

Fix: capture this turn's own `ActivePrompt` and compare-and-delete — only clear
the entry if it is still ours (`activePrompts.get(sessionId) === promptState`).

Sibling-map audit (same later-settling-wedged-turn clobber):
- collectBuffers: also gated on the same `stillCurrent` flag. A replaced wedged
  turn must not drain the buffer the live replacement turn now owns (reachable
  via mixed-mode single-scope, where a steer turn and collect follow-ups share
  one session). Behavior-preserving: in all serialized flows the turn is still
  current at its finally, so the drain runs exactly as before.
- sessionQueues / sessionGenerations / instructedSessions: not mutated in the
  per-turn finally (only in /clear), so no per-turn clobber. Queued turns are
  already guarded against /clear by the sessionGenerations counter.
- promptState.resolve(), bridge.off, streamer.stop, onPromptEnd: per-turn-owned
  cleanup, kept unconditional (a replaced wedged turn must still release any
  steer/clear waiter racing its done promise).

Adds a deterministic test (manual deferred, no timers/sleeps) reproducing A
wedged -> B replaces -> A settles late, asserting B's entry survives and a
following turn C still engages steer protection. Reverting the guard fails it.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): guard onPromptEnd on superseded turns and strip control chars from sender names

When a steer-replacement turn B re-seeds a session after the bounded
wind-down wait times out, a wedged predecessor turn A can settle late and
run its finally. onPromptEnd hides a session/chat-scoped working indicator
(e.g. the Telegram/WeChat typing indicator keyed by chatId), so A's
previously-unconditional onPromptEnd would stop B's indicator while B is
still working. Guard onPromptEnd with the same stillCurrent identity check
that already protects the per-session map cleanup, so a superseded turn can
no longer clear the successor's indicator. A's own teardown (textChunk
listener, streamer, promptState.resolve) stays unconditional.

Bring sanitizeSenderName to parity with its sibling sanitizeQuotedText by
stripping C0/DEL control chars: a crafted display name with \x07/\x1b
otherwise reaches the [name] prompt tag. Mirror the same strip in the qqbot
send.test.ts mock so a control-char regression in the real helper is caught.

Add a diagnostic stderr line when a steer abandons a wedged turn, matching
the existing queue-drop log.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): preserve path chars in attachment filePath; trim sender names

Addresses ci-bot review on the channel prompt-sanitization paths.

filePath over-sanitization (the main regression): a prior hardening pass
routed the rendered attachment path through sanitizeQuotedText, which strips
the `[`, `]`, and `"` characters. Those are valid, common filesystem path
characters (e.g. a Next.js `app/[slug]/page.tsx` route, a quoted segment, or a
space in a folder name), so stripping them advertised a `saved to:` path that
does not exist on disk and broke the agent's read-file tool. A path rendered
alone on its own line cannot use brackets/quotes/spaces to break out of that
line, so the only real injection risk is characters that break or reorder the
line. Add a path-safe neutralizer, sanitizePromptPath, that strips ONLY C0/DEL
controls (incl. CR/LF), the Unicode line/paragraph separators, and bidi
overrides (the PROMPT_UNSAFE_INVISIBLES set), preserving everything else
byte-intact and without capping length. The human-readable fileName label keeps
sanitizeQuotedText (bracket-stripping is fine for a quoted label).

steer generation-capture ordering: in steer mode the session generation was
snapshotted AFTER the bounded wind-down wait. A concurrent /clear (e.g. another
sender's clear-confirm on a shared session) that bumps the generation DURING
that wait was therefore invisible: the snapshot read the post-clear value, the
dequeue equality guard still held, and the turn ran against the just-cleared
session. Capture preSteerGeneration BEFORE the wait and use it for the guard so
a /clear during the wait is detected and the turn bails.

sender-name fallback: sanitizeSenderName now trims after the length cap and
returns an 'unknown' default, so a name made entirely of strippable chars
(brackets/newlines) no longer renders an anonymous bracket tag. Both call sites
embed the result as a bracket tag with no fallback of their own, so the default
lives in the shared helper; the qqbot test mock mirrors the new contract.

Adds load-bearing, mutation-checked tests for each change (deterministic,
fake-timer driven for the steer/clear race; no real-time waits).

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): stop streamer on steer-cancel and align slash-command trim

Two ci-bot review findings on the steer/slash-command paths in ChannelBase.

1. steer-cancel must stop the BlockStreamer, not just cancel. The steer
   handler flipped active.cancelled but never called stopStreaming(), unlike
   the sibling doClear path. cancelled alone only suppresses NEW chunks: text
   already buffered in a wedged turn's streamer is still flushed by the idle
   timer (~1500ms), which fires before the 3000ms steer wind-down bound, so it
   leaks into the chat after the replacement turn has begun. Mirror doClear and
   stop the streamer immediately after cancelling.

2. parseCommand now trims its input so it agrees with isSlashCommand (which
   already trims). Before, " /help" (leading space, common from IME/copy-paste)
   made isSlashCommand return true — suppressing the group [sender] tag — while
   parseCommand returned null, so the command reached the agent unattributed.
   Trimming closes that attribution gap; it changes nothing else (the
   no-whitespace path is unaffected, and /tmp/foo still has no handler).

Also document the forward-looking late-cancel concern at the steer cancel site:
cancelSession is keyed only by sessionId and the replacement prompt reuses it.
The shipping AcpBridge sends cancel+prompt over one in-order stdin stream with
cancel enqueued first, so the child always processes the cancel before the new
prompt — the replacement turn is safe today. A future network/daemon bridge
with cancel latency could reorder them; the proper fix is turn-scoped
cancellation (a Bridge-contract change), deferred to avoid an API break here.

Tests: a steered wedged turn with buffered streamer text invokes stopStreaming
so the idle-timer flush can't deliver stale text after the replacement turn
begins; " /help" in a group is handled as a command (no [sender] tag, no
forward) while /help and /git:commit still parse. Both are mutation-checked.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): block shell commands in shared sessions; dedupe cancel path

Phase 0 ships shared/group sessions (sessionScope 'thread' and 'single')
but has no per-sender trust model — the [sender] marker is explicitly not
a trust boundary. The pre-existing bang (`!`) handler in ChannelBase runs
arbitrary host shell commands, so in a shared session ANY participant
could run `!rm -rf /`. Gate it with the same isSharedSession predicate
used for the destructive-/clear confirm gate: refuse with a one-line
notice in shared sessions, keep direct execution in a 1:1 session (the
lone user is the operator). isSharedSession is promoted from a closure to
a private method so both gates share one source of truth.

Extract the duplicated cancel + bounded-wait sequence from doClear and the
steer branch into a private cancelAndAwaitActive() helper (the duplication
previously caused steer to miss stopStreaming()). steer keeps its pre-wait
generation snapshot and uses the boolean result; doClear ignores it.

Also: trim the over-verbose steer/clear comment blocks to the load-bearing
why; cap sanitizePromptPath at 1024 chars (defense-in-depth) like its
siblings; and make the qqbot send.test mock use the real sanitizeSenderName
via vi.importActual instead of an inline re-implementation that can drift.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): run onPromptEnd after /clear; only skip it for superseded turns

The per-turn `finally` guarded `onPromptEnd` behind `stillCurrent` alone. When
`/clear` cancels an in-flight turn with NO replacement it deletes that turn's
`activePrompts` entry, so if the cancelled prompt settles late `stillCurrent` is
false and `onPromptEnd` was skipped — leaking the platform cleanup several
adapters do there (Telegram clears its typing interval, DingTalk recalls the
working reaction, Weixin clears typing, Feishu finalizes card state). The
`stillCurrent`-only guard was only meant to stop a STEER-superseded turn from
clobbering its replacement's indicator, but it wrongly also caught the
no-replacement `/clear` case.

Distinguish the two: add `ActivePrompt.superseded`, set on the OLD turn only when
a steer replacement actually takes over the session slot. The `finally` now runs
`onPromptEnd` UNLESS the turn was superseded; the `activePrompts.delete` and the
collect-drain stay `stillCurrent`-gated (a superseded turn must not drain the
replacement's buffer). Regression + collect-drain tests added with mutation
checks.

Also addresses review nits: extract a shared command-token regex constant so
parseCommand and isSlashCommand can't drift; report `single` scope as "shared
channel-wide" in /who (it is shared across all DMs and groups, not just one
group); append a truncation ellipsis in sanitizeQuotedText so a cut
quote/filename is detectable; and include the message text in the
generation-bail drop log so an ignored message is diagnosable.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): require the slash-command token to immediately follow the slash

isSlashCommand used trimmed.slice(1).trimStart() before taking the first token, so a space after the slash (`/ foo`) still classified as a command. parseCommand's regex requires the token immediately after `/`, so it returned null for the same input. In a shared group session that divergence suppressed the [sender] attribution (isSlashCommand true) while running no command (parseCommand null), letting `/ foo` reach the agent unattributed — the exact failure the file's comment warns about.

Remove the .trimStart() so the token must immediately follow the slash; `/ foo` now splits to an empty first token and is treated as prose, agreeing with parseCommand. Normal commands (/help, /git:commit, /compress-fast, /cmd@bot) are unaffected. Adds an invariant test that isSlashCommand and parseCommand agree on `/ foo` (both false) and /help (both true), plus a behavioral test that `/ foo` keeps its [sender] tag.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): don't clobber a post-/clear turn's indicator; tidy refusal + diagnostics

Addresses wenshao's review on PR #5888:

- Refusal text: `!` shell commands are "disabled in shared sessions" (drop
  "group" — isSharedSession is also true for single-scope DMs, which are not
  groups).
- /clear diagnostic: capture cancelAndAwaitActive's result and log on timeout,
  mirroring the steer "abandoned a wedged turn" message, so a wedged /clear is
  observable instead of silently "worked".
- /clear indicator clobber: a /clear that evicts a wedged turn never set
  `superseded`, so the turn's late-settling finally still ran onPromptEnd and
  killed the indicator a turn started AFTER the /clear now owns. Add a
  `clearEvicted` flag: /clear runs the wedged turn's own onPromptEnd at eviction
  time (no replacement exists yet) and marks it clearEvicted; the late finally
  then skips onPromptEnd. onPromptEnd now fires exactly once for the evicted
  turn, at clear-time. The finally guard becomes
  `stillCurrent || (!superseded && !clearEvicted)`. ActivePrompt carries the
  originating chatId/messageId so the eviction can target the right indicator.
- Dropped-queued-turn log: sanitize the attacker-controlled message text
  (render newline visibly, strip C0/DEL incl. CR/ESC) before it reaches an
  operator's terminal, matching every other embed path.

Tests: complete the single-scope DM `!` refusal test (not-called + length +
not-forwarded); update the group/DM/1:1 refusal assertions to the new text;
re-point the "/clear settles late" test to clear-time cleanup; add a
replacement-after-/clear test asserting a late settle does not end a later
turn's indicator; strengthen the dropped-turn-log test with control-char input.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): fix root tsc errors in channel tests; gate /who; sanitize dingtalk nick log

Root `tsc --noEmit` failed on 9 type errors in two channel test files
that per-package builds and `--workspace` typechecks miss (both exclude
test files). Fix the test types only — no production type changes:
- qqbot/dingtalk: `QQChannel`/`DingtalkChannel` come from `await import()`
  as values, so annotate instances with `InstanceType<typeof X>`.
- dingtalk test config was missing the required `token` field of
  `ChannelConfig`.
- qqbot mock: bracket-access the index-signature `router` and type the
  assignment so `{}` is assignable to `Record<string, unknown>`.

/who: gate it to authorized senders in shared sessions, mirroring /clear
— /who leaks the workspace basename, so non-members shouldn't see it.

dingtalk: sanitize `senderNick` with the shared `sanitizeSenderName`
before writing it to stderr, so a crafted nick with CR/LF/control chars
can't fragment or inject log lines.

!-shell gate: refuse `!` shell commands in ALL groups (gate on
`envelope.isGroup`, not just shared sessions) — a user-scope group is not
a shared session yet is still multi-operator, so members could reach the
host shell. The refusal now runs BEFORE router.resolve, so a refused
command never creates a session. Single-scope DMs stay refused.

tests: the qqbot/dingtalk suites mock `@qwen-code/channel-base` and pull
the real `sanitizeSenderName` via the package export, which resolves to
base/dist and broke clean package-local runs. Alias the specifier to
base's source in each vitest config (mirrors cli) so the suites run
without a prior `tsc --build` of base.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): bump generation and release indicator on steer-abandon, mirroring /clear

When a steer abandons a wedged turn it re-seeds a fresh queue chain but, unlike doClear, did neither of the two protections doClear runs on a wedged-turn eviction. Both bugs surface when mixed dispatch modes collapse onto one session (single/user scope).

FIX 1 (generation bump): a followup queued behind the wedged turn stays on the now-orphaned chain. Without bumping sessionGenerations, when the wedged turn late-settles the followup PASSES the dequeue guard and runs the unguarded activePrompts.set, clobbering the live steer replacement -> two concurrent bridge.prompts on one session (duplicated responses + double tool execution). Bump the generation up-front (skipping if a /clear raced the wind-down wait) and advance the replacement's captured snapshot so it proceeds while the orphaned followup bails.

FIX 2 (messageId-scoped indicator leak): the supersede path never released the abandoned turn's OWN indicator. A CHAT-scoped indicator is re-seeded by the replacement, but a MESSAGEID-scoped one (per-message reaction/card keyed on the inbound messageId) is keyed on the abandoned turn's messageId and leaks until disconnect. Run the abandoned turn's onPromptEnd at steer-time and mark it superseded so its late finally still skips onPromptEnd (released once, no double-fire, replacement untouched).

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): steer waits for the running turn instead of racing a concurrent replacement

In steer dispatch, when a new message arrived while a prompt was running,
ChannelBase cancelled the running turn (best-effort), did a BOUNDED wait, and
on TIMEOUT (the old turn wedged, not finished) PROCEEDED to start a replacement
bridge.prompt() on the SAME sessionId while the old prompt was still active. As
wenshao flagged, this is bridge-unsafe:

- DaemonChannelBridge.prompt() REJECTS while the prior prompt is still marked
  active, so the replacement is silently dropped.
- Both bridges collect/emit chunks keyed by sessionId ONLY, so the abandoned
  turn's late chunks mix into the replacement's stream (duplicated/stale output).

Fix (wenshao option (a)): steer now best-effort cancels the running turn and
CHAINS the new turn onto the existing session queue tail, so it runs only AFTER
the old turn's finally has actually run (onChunk detached, activePrompts cleared,
indicator released). The cancel stays — it makes the old turn wind down sooner —
but steer never proceeds while a turn is still active, eliminating the unsafe
concurrency entirely. Steer now differs from followup only by that best-effort
cancel.

Removed the steer-only concurrency machinery that only existed to host a
concurrent replacement: the steer-path bounded wait (cancelAndAwaitActive call),
the steerWedged flag and fresh-chain re-seed, the preSteerGeneration capture, the
steer-time sessionGenerations bump, the steer-time onPromptEnd, and the superseded
flag (plus its finally guard term). /clear's OWN protections — its up-front
generation bump, eviction-time onPromptEnd, and the clearEvicted finally guard —
are SEPARATE and preserved; /clear genuinely evicts and still needs them.
cancelAndAwaitActive is retained, now used only by /clear.

BEHAVIORAL CHANGE: steer no longer force-interrupts a genuinely wedged turn; it
cancels it and waits for it to finish before the new turn runs. Turn-scoped
cancellation/routing (so a new turn can run without waiting for a wedged
predecessor) is wenshao option (b) — it needs an API change across every adapter
and is the deferred enhancement, out of scope here.

Folded in two further #5888 review items:

- [Critical] QQ slash-command audit-log injection (qqbot/src/QQChannel.ts): the
  audit process.stderr.write interpolated the RAW, attacker-controlled senderName
  (event.author.username) and cleanText BEFORE sanitization, so a crafted nick or
  message with CR/LF/ANSI escapes could forge or corrupt operator audit logs.
  Hoisted `safeName = sanitizeSenderName(senderName)` above the audit log and now
  log a neutralized command string (cap 80, render \n visibly, strip C0/DEL),
  mirroring ChannelBase's dropped-turn log and the DingTalk hardening already in
  this PR. The prompt-path sanitizeSenderName usage is unchanged.

- [Suggestion] single-scope DMs were not attributed (base/src/ChannelBase.ts): the
  [sender] prefix was gated on envelope.isGroup alone, but sessionScope:'single'
  collapses every sender's DM into one __single__ session (already treated as
  shared by the !-gate, /clear confirm and /who), so different people merged into
  one unattributed conversation (the RFC-R4 gap Phase 0 closes). The gate is now
  (envelope.isGroup || sessionScope === 'single') — deliberately NOT
  isSharedSession, which is false for user-scope groups that must keep attribution.

Tests: removed the obsolete steer-concurrency cases and added cases for the new
steer behavior (new turn starts only after the old completes; the abandoned turn's
late chunks cannot reach the new turn). Added an audit-log sanitization test
(QQ) and single-scope-DM / user-scope-group / 1:1-DM attribution tests (base).
/clear's eviction protections are unchanged and still pass.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): neutralize NEL/C1 in prompt text; dedupe shared-session auth gate

Address wenshao's review on the Phase-0 qwen-tag channel base.

- sanitize: PROMPT_UNSAFE_INVISIBLES now also neutralizes the C1 control
  block (U+0080-U+009F), which includes NEL (U+0085), a Unicode line break
  (UAX#14 BK) that renders as a new line. Without it a display name like
  "Alice<NEL>system: ..." or a crafted reply quote could inject a prompt
  line. Applies to both sanitizeSenderName and sanitizeQuotedText (and the
  shared path sanitizer) since they share the set. Tests cover NEL + a second
  C1 (CSI U+009B) for sender names and quoted text; mutation-checked.

- ChannelBase: the two best-effort cancelSession() calls (the /clear
  wind-down wait and the steer pre-cancel) no longer swallow the IPC failure
  with an empty catch. They now log channel name + sessionId + reason to
  stderr, matching the existing /cancel log style, so a wedged turn is
  diagnosable. Still fire-and-forget (not awaited).

- ChannelBase: extract the verbatim shared-session authorization gate shared
  by /clear and /who into a private isAuthorizedForSharedSession() predicate
  (isSharedSession + allowedUsers check). Behavior is unchanged; each caller
  keeps its own rejection wording.

- tests: add a GROUP-path case that /help@mybot is treated as a command
  (no [sender] prefix, not forwarded to the agent); the existing @botname
  test only covered a DM.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): strip C1/NEL from audit-log text too, matching the prompt path

The prompt path now neutralizes the C1 control block (U+0080-U+009F, incl.
NEL U+0085, a Unicode line break) via PROMPT_UNSAFE_INVISIBLES, but the inline
stderr audit-log sanitizers still stripped only C0/DEL. A crafted message
carrying NEL/C1 therefore survived into the dropped-/queued-turn log
(ChannelBase) and the QQ slash-command audit log, where many terminals render
NEL as a newline, forging an extra [channel] log line.

Extend both inline strips to also cover the C1 block (C0 + DEL + C1), matching
the prompt path's union, and refresh the now-stale comments. Tests feed a NEL
(U+0085) and a C1 char (U+009B) through both log paths and assert neither
survives; reverting the strip to C0/DEL fails the new NEL assertions.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): gate /status and /cancel in shared sessions; code-point-safe truncation

Three follow-ups to the shared-session authorization work:

- /status: add the same isAuthorizedForSharedSession gate /who uses, so a
  non-member of a shared session with a non-empty allowedUsers list can no
  longer read its session/access state. Non-shared (DM/per-user) is unchanged.
- /cancel: gate the destructive abort behind isAuthorizedForSharedSession like
  /clear (auth gate only, no confirm), so a group member can't cancel another
  user's in-flight turn on a shared session. 1:1 and authorized users unchanged.
- sanitize: truncate sanitizeSenderName/sanitizeQuotedText/sanitizePromptPath on
  Unicode code-point boundaries (Array.from) instead of UTF-16 code units, so a
  cap landing mid-surrogate-pair (e.g. an emoji) can't leave a lone surrogate
  that renders as the replacement character downstream. The ellipsis logic in
  sanitizeQuotedText still keeps the result within maxLen code points.

Adds tests for each (incl. mutation-checked gates and an emoji-at-cap case).

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): audit-log shared /clear and blocked ! shell; test steer stopStreaming

Address ci-bot review on PR #5888:

- [Observability] Emit a stderr audit line on a successful clear of a
  SHARED session (channel, sessionId, sanitized sender + stable senderId).
  A 1:1 DM clear is single-participant and is not logged.
- [Observability] Emit a stderr audit line when a group/shared member's
  `!` host-shell command is refused, so operators can detect blocked
  attempts. Sender name is sanitized; the command payload is not echoed.
- [Test gap] Add a steer test asserting the best-effort cancel calls
  active.stopStreaming (spying on the running turn's prompt), plus tests
  for both new audit lines and the DM no-log case.

The /cancel and /status shared-session auth gates flagged by ci-bot were
already present on this branch and are left unchanged.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): gate steer-cancel by authorization; close audit-log + attribution injection gaps

Address review on PR #5888 (qwen-tag-phase0):

- [Critical] The /cancel auth gate was bypassable via the default `steer`
  dispatch mode: any normal message cancels the running turn, so an
  unauthorized member of a shared session - blocked from /cancel - could
  abort another user's active turn just by sending a message. The steer
  branch now checks isAuthorizedForSharedSession FIRST and, when
  unauthorized, breaks to normal queuing (the message chains onto the
  session queue tail and runs AFTER the active turn instead of aborting it).

- [Suggestion] Audit-log sanitizers missed U+2028/U+2029 and the bidi
  overrides (log-line spoofing / trojan-source). Exported
  PROMPT_UNSAFE_INVISIBLES and added a shared sanitizeLogText(text, maxLen)
  helper that applies BOTH that set AND the C0/DEL strip (and caps length),
  used at both audit-log sites (ChannelBase dropped-turn log + QQ
  slash-command audit log) so the defense can't drift apart.

- [Suggestion] The [sender] attribution prefix was suppressed for any
  command-SHAPED text, so unrecognized "/x\n[SYSTEM]: ..." reached the agent
  unattributed. Added a synchronous isRecognizedCommand() (locally registered
  commands + the bridge.availableCommands snapshot) and now suppress the
  prefix only when the text is BOTH a command shape AND a recognized command;
  unrecognized command-like text keeps its [sender] tag.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): recognize command aliases per-session so attribution doesn't break them

isRecognizedCommand decides whether to suppress the group [sender]
attribution prefix, but matched only against availableCommands[].name.
The ACP parser also accepts command ALIASES (parseSlashCommand altNames),
so a valid alias like /summarize (alias of /compress) or /login (alias of
/auth) was classified as unrecognized, rewritten to "[Alice] /summarize",
and then run as PLAIN CHAT (the leading / is gone) instead of executing.
It also read the bridge's GLOBAL availableCommands snapshot, which in
DaemonChannelBridge can belong to another session.

- Carry aliases through the available-command contract: add an optional
  altNames?: string[] to AvailableCommand, emit them on the wire in
  _meta.altNames (ACP's extension point; a top-level altNames would be an
  excess-property error against the SDK type), and lift them in both
  bridges via readAvailableCommandAltNames. Omitted when absent, so
  alias-free entries stay byte-identical.
- Match name AND altNames, against THIS session's command list:
  isRecognizedCommand now takes sessionId and reads
  getAvailableCommands(sessionId) when the bridge exposes it
  (DaemonChannelBridge), falling back to the global getter (AcpBridge,
  single-agent, inherently session-correct). Stays synchronous: a real
  command sent before the snapshot loads keeps its tag (safe default).
- Security intent intact: genuinely-unrecognized command-shaped text
  (e.g. /x\n[SYSTEM]: ...) still keeps its [sender] tag.

Also fold in two #5888 review items in ChannelBase.ts:
- Steer auth gate: audit the silent steer->queue downgrade to stderr for
  an unauthorized member (operator-visible only; no per-message reply),
  matching the /cancel,/clear,/who,/status gates' observability.
- /clear eviction: a clear-time onPromptEnd that throws would abort the
  purge, leaving the evicted turn in activePrompts so its late finally
  (stillCurrent || !clearEvicted) re-runs onPromptEnd and clobbers a newer
  turn. Set clearEvicted first and catch+audit the throw so the purge
  always runs (turn becomes non-current) and the late finally skips.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): match agent commands case-sensitively; add steer wedge watchdog

isRecognizedCommand lowercased the token before comparing it to agent
command names/aliases, but the CLI's parseSlashCommand matches agent
commands CASE-SENSITIVELY (`cmd.name === part`, `cmd.altNames?.includes(part)`).
So a wrong-case token like `/SUMMARIZE` was "recognized" here — suppressing
the `[sender]` attribution tag — yet ran NO command in ACP, which then
forwarded the raw text unattributed, reopening the injection where a crafted
`/SUMMARIZE\n[SYSTEM]: …` second line reaches a shared group as an apparent
system directive. parseCommand now also returns the typed-case `raw` token;
isRecognizedCommand matches AGENT commands on `raw` (case-sensitive, mirroring
the CLI) while LOCAL commands keep their existing case-INSENSITIVE match
(registerCommand lowercases the stored name; handleInbound dispatches by the
lowercased token).

The steer chain-and-wait path lost the old "abandoned wedged turn" log, so a
hung predecessor bridge.prompt() could silently deadlock the session with no
observability. Add a diagnostic-only watchdog: the steer branch arms a timer
(unref'd) that, if the predecessor is still the active prompt after
CLEAR_CANCEL_TIMEOUT_MS, emits a stderr line pointing at /clear for recovery;
the chained `.then()` disarms it as its first statement once the predecessor's
tail resolves. No concurrency change — chain-and-wait is untouched.

Also simplify the late-finally onPromptEnd guard from
`stillCurrent || !clearEvicted` to `!clearEvicted`: clearEvicted is set ONLY
by /clear's eviction, which then unconditionally deletes activePrompts (its
try/catch around the clear-time onPromptEnd guarantees the purge even if it
throws) and never re-inserts the same promptState, so `clearEvicted` implies
`!stillCurrent` and the dropped term was unreachable.

Tests: wrong-case `/SUMMARIZE` keeps `[sender]` (with a mutation note); wrong-
case local `/HELP` still dispatches locally; fake-timer watchdog tests for the
wedged (logs) and settled (timer cleared, no log) paths.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): align agent command recognition with parseSlashCommand; guard finally onPromptEnd

Address maintainer review on the qwen-tag Phase 0 channel adapter.

- [Critical] Guard the normal-completion onPromptEnd in the per-turn finally.
  onPromptEnd runs platform-adapter cleanup (typing interval, working-reaction
  recall, card finalize) - network/IO that can throw. An uncaught throw skipped
  activePrompts.delete (session leak), promptState.resolve (active.done never
  settled, so a later /clear falsely logged "abandoned a wedged turn"), and the
  collect-buffer drain - and the rejection, swallowed by the queue tail's
  .catch(() => {}), silently dropped every later turn. Wrap it in try/catch with a
  stderr log, matching the eviction-path treatment.

- [Critical] End the channel/agent command-recognition divergence by matching the
  AGENT branch of isRecognizedCommand EXACTLY as the CLI's parseSlashCommand does:
  the FIRST whitespace token after the leading '/', case-SENSITIVELY, WITHOUT
  stripping an @suffix. parseCommand's '@'-stripped, lowercased token diverged from
  the agent (PARSE_COMMAND_RE drops '(?:@\S+)?'), so /compress@x or /Compress were
  "recognized" here (tag suppressed) yet ran no command there and reached the model
  unattributed. Matching the exact token leaves wrong-case / @suffix / injection-
  shaped tokens UNRECOGNIZED -> they keep their [sender] tag (attributed), exactly as
  the agent treats them. No prompt rewrite: a /compress@otherbot aimed at another bot
  must not run here. LOCAL commands keep case-insensitive dispatch.

- [Suggestion] Include the originating chatId/messageId (sanitized) in the "/clear
  abandoned a wedged turn" log so oncall can correlate the stuck turn.

- [Suggestion] Clarify the alreadyPrefixed JSDoc: it is also set by the QQ adapter on
  real self-prefixed inbounds, which sanitize the embedded name at the source - so the
  flag does not bypass sanitization (verified; no behavior change needed).

- [Suggestion] Replace the blind `as unknown as {...}` bridge cast on the recognition
  path with a typed AgentCommandsProvider interface (optional members), so a future
  rename/return-type change is type-checked instead of breaking at runtime.

- [Suggestion] Validate altNames' shape: isAvailableCommand now rejects a non-array
  altNames, and the recognition site guards the alias check with Array.isArray, so a
  malformed wire payload can't throw at the `.includes` call.

Tests: throwing-onPromptEnd cleanup + collect-drain + log; exact-token recognition
(verbatim /compress and /summarize alias; tag kept for /SUMMARIZE, /COMPRESS,
/compress@x, and /compress@x + [SYSTEM] line); wedged-turn log carries chat/message;
malformed altNames does not throw; isAvailableCommand drops a malformed-altNames entry.
Restored stderr spies in the two steer-watchdog tests' finally.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(channels): address qwen tag review followups

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-06-29 07:17:13 +00:00
jinyeandQwen-Coder aa8f9bb993 fix(standalone): Route serve shim through cli-entry (#5977)
* fix(standalone): route serve shim through cli-entry

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#5977)

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-06-29 07:00:39 +00:00
DragonandClaude 481ea7b1b1 docs(qc-helper): add daemon mode docs and fix system settings path (#5981)
Add qwen-serve.md and qwen-serve-deploy-local.md to the /qc-helper
documentation index so the skill can locate daemon-mode docs at runtime.
Also expand the Config File Locations quick reference to include the
Linux and Windows system settings paths, matching what settings.md
already documents.

Co-authored-by: Claude <[email protected]>
2026-06-29 06:32:45 +00:00
易良 98dec4fa71 fix(release): use relative postinstall patch dir (#5973) 2026-06-29 03:11:21 +00:00
68a9de4752 fix(core): only spawn memory recall when auto-memory is enabled (#5963)
Co-authored-by: Minerest <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-06-29 00:28:16 +00:00
d5a9027213 fix(cli): auto-select custom input on Enter in multi-select questions (#5791)
In ask_user_question multi-select mode, pressing Enter on the custom
input ("Type something...") did nothing — the Enter handler returned
early and handleCustomInputSubmit only toggled the checkbox without
submitting. Now Enter on custom input auto-selects the typed value
(if non-empty) and advances to the next question or submits. Empty
custom input is guarded to prevent accidental submission.

TextInput's onSubmit prop is the actual submission path when the
custom-input row is focused (the Enter handler in useKeypress is
short-circuited by the isCustomInputSelected guard). handleCustomInputSubmit
now calls handleMultiSelectSubmit() after toggling, but only when the
typed value is non-empty to avoid submitting pre-toggled options
unexpectedly.

Signed-off-by: Alex <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: 易良 <[email protected]>
2026-06-29 00:28:05 +00:00
ee375c6a41 feat(loop): inject a .qwen/loop.md task file at fire time via sentinels (#5890)
* feat(loop): inject a .qwen/loop.md task file at fire time via sentinels

A long-running /loop had no durable place to keep its task list — the
model re-stated the work every tick. This adds a .qwen/loop.md file the
loop re-reads and injects at fire time, driven by a sentinel prompt
(<<loop.md-dynamic>> for self-paced LoopWakeup, <<loop.md>> for
fixed-interval CronCreate).

At fire time the sentinel expands into the full task block (first
delivery, after the file changes, or after a compaction) or a short
reminder (unchanged), so the list is paid for once into the cached
prefix and later ticks stay cheap. Change-detection is content equality,
and the cache is committed only after the tick is delivered so an aborted
tick can't leave a dangling reminder. The reader is workspace-confined
(realpath boundary), 25 KB capped, and skips empty/missing/symlinked
candidates.

Interactive (Session) wiring covers self-paced and fixed-interval loops;
headless skips a bare sentinel (full headless expansion is a follow-up).
The loop.md-absent branch is a minimal no-op tick.

Closes #5889

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(cli): remove unused loop mode import

* fix(loop): rename loop task-file/tick-resolver modules to kebab-case

The repo's eslint.config.js enforces `check-file/filename-naming-convention`
with `{ '**/*.ts': 'KEBAB_CASE' }` over `packages/core/src/**/*.ts`, so the
camelCase module names (loopTaskFile.ts, loopTickResolver.ts and their tests)
failed the lint gate and blocked merge. Rename them to kebab-case via git mv
(history preserved) and update the relative import/export paths in the package
barrel and the renamed files. Exported symbol identifiers are unchanged, so
consumers importing from @qwen-code/qwen-code-core need no update.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): address re-review suggestions on loop.md injection

- loop-task-file: log a debug trail when a project loop.md is skipped for
  escaping the workspace via a symlink (control flow unchanged).
- loop-task-file.test: cover the non-whitelisted fs-error rethrow (EACCES)
  by mocking readFile so the assertion stays cross-platform stable.
- loop-tick-resolver: SHORT_ABSENT now names both the project
  (.qwen/loop.md) and home (~/.qwen/loop.md) candidates that are checked.
- loop-tick-resolver.test: assert tick.sourcePath on the unchanged branch.
- index: move the two loop barrel exports from Services into the Skills
  section to restore grouping/ordering (consumers import via the barrel).

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): address loop.md re-review — bounded read, cached realpath, single H1, reset cache on absence

- read at most LOOP_TASK_FILE_MAX_BYTES+1 via fs.open so a huge/malicious
  .qwen/loop.md is no longer fully read+decoded each tick (DoS hardening)
- cache the project-root realpath once per resolver instead of per tick
- emit a single H1 and avoid leaking the absolute path into the model prompt
- clear lastContent on the absent path so delete/recreate re-expands the block

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): address re-review — heading test, absent heading, source label

- Session.test.ts: fix the stale assertion to the current
  "# /loop tick — loop.md tasks from" heading (was the pre-rename
  "# /loop tick — tasks from") — the likely CI failure.
- loop-tick-resolver: route the absent case through tickHeading() so
  every tick variant (full block, short reminder, absent) shares one
  heading and the dynamic-pacing suffix; emitted text is unchanged.
- loop-task-file: return a semantic `source` ('project' | 'home') for a
  found loop.md; the resolver maps it to a label via an exhaustive
  Record, so a future candidate can't silently mislabel as "home loop.md".

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): keep loop.md confinement root internal; harden bounded-read test

Address re-review on the loop.md injection change:

- loop-task-file: drop `realProjectRoot` from the exported
  ReadLoopTaskFileOptions. The module is re-exported from
  @qwen-code/qwen-code-core, so a caller could pass a stale/broader root and
  widen the workspace-confinement boundary for project loop.md. The real
  project root is now derived from the trusted `projectRoot` inside
  readLoopTaskFile and cached per-root (kept off the public API), so callers
  can't supply a different confinement root. The resolver drops its private
  realpath cache accordingly.

- loop-task-file.test: make the bounded-read test load-bearing. It now wraps
  the file handle to observe read() calls and asserts no read — and their
  sum — exceeds the cap budget (LOOP_TASK_FILE_MAX_BYTES + 1), plus a
  short-file EOF case. It fails on a "read the whole file, then slice"
  regression that the previous content-only assertion let pass.

Co-Authored-By: Qwen-Coder <[email protected]>

* test(core): cover loop.md project-root realpath cache eviction on transient failure

Add a load-bearing test proving a transient fs.realpath(projectRoot)
failure (EACCES/ENOENT) is not pinned in the per-process cache: the entry
is evicted on rejection so the next tick re-resolves instead of replaying a
permanently-cached rejection. Without that eviction, a single transient
error would break loop.md resolution for that root forever.

The test goes red if the `.catch(() => realProjectRootCache.delete(...))`
eviction line is removed and green once restored; driven purely via the
realpath mock, with no timing waits.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): harden loop.md against symlink exfiltration, FIFO hang, and untrusted reads

Addresses the security review on PR #5890.

- loop-task-file: the project `.qwen/loop.md` is now lstat'd BEFORE the
  blocking open. A symlinked project file is refused outright — a
  repo-controlled `.qwen/loop.md -> ../.env` resolves inside the workspace,
  so the realpath confinement alone would pass and exfiltrate that file to
  the model. A FIFO/socket/device/dir is refused too, so a named pipe can no
  longer wedge the tick on a blocking `open` that waits for a writer. The
  ancestor-symlink confinement (`.qwen -> /outside`) is preserved. The home
  `~/.qwen/loop.md` now follows symlinks (a legitimate dotfiles setup) but
  stat's the resolved target and requires a regular file, and gained the
  debug-skip trace the project path already had.
- Session/resolver: gate the project loop.md on folder trust. An untrusted
  folder no longer reads the repo-controlled project file; the user-owned
  home file stays allowed. Threaded `allowProjectFile` (from
  `config.isTrustedFolder()`, mirroring getProjectHooks()) through
  LoopTickResolver into readLoopTaskFile.
- nonInteractiveCli: headless cron leak. Skipping a loop sentinel now also
  deletes a recurring SESSION (non-durable) loop.md job so it stops re-firing
  and `sessionSize` can fall to zero — otherwise the hold-open never resolves
  and the run hangs. Durable jobs are left untouched (they persist and don't
  count toward sessionSize); one-shots are already removed before fire.
- Session debug log: emit the non-absolute source label instead of the
  absolute `sourcePath`, so the resolved path isn't leaked to logs.

Tests: project in-workspace-symlink exfiltration guard, FIFO-before-open
(no hang, proven by open never being called on the project path), home
symlink-to-regular-file, untrusted-folder gating (resolver + Session),
absent-tick Session integration, and the headless recurring-session cleanup.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): bracket-access process.env in Session test setFakeHome

`noPropertyAccessFromIndexSignature` (root tsconfig) rejects dot access on
the `NodeJS.ProcessEnv` index signature, so the dot-notation
`process.env.HOME` / `process.env.USERPROFILE` reads and writes in the
`setFakeHome` helper failed `tsc --noEmit` with 4x TS4111, breaking CI
(packages/cli includes *.test.ts in its typecheck). Switch them to bracket
access to match the existing restore loop.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): fail-secure loop.md default + root confinement, with coverage

Address qwen-code-ci-bot review on the loop.md injection change:

- readLoopTaskFile's `allowProjectFile` now defaults to false. It is
  re-exported from the core barrel, so a caller that omits the option must
  not silently read an untrusted workspace's repo-controlled `.qwen/loop.md`.
  The real caller (Session via LoopTickResolver) already passes the
  trust-derived value explicitly, so trusted-folder behavior is unchanged.
- Fix a workspace-confinement false positive at a filesystem root: when
  realRoot is `/` (or `C:\`), `realRoot + path.sep` became `//` (`C:\\`),
  which no descendant startsWith, so running from the root wrongly refused
  every project loop.md. Derive the prefix without double-appending the
  separator and allow `real === realRoot`.
- Add coverage: the dynamic loop.md sentinel is cleaned up headlessly like
  the cron one (guards against a `=== LOOP_SENTINEL_CRON` regression); an
  aborted (undelivered) tick followed by an edit re-delivers the full new
  block (the #pendingContent vs #lastContent divergence); a project loop.md
  directly under a filesystem root is not falsely refused; and the new
  fail-secure default is locked in.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): harden loop.md reader/resolver per CI review

Follow-up hardening on the loop.md task-file pipeline:

- loop-task-file: add ELOOP/ENAMETOOLONG to the skip whitelist so a
  symlink-loop or over-long loop.md path is treated as a skippable
  candidate instead of crashing the tick (EACCES still surfaces).
- loop-task-file: confine the home ~/.qwen/loop.md symlink target to
  $HOME via realpath, blocking out-of-home escapes (e.g. -> /etc/passwd)
  while still allowing in-home dotfile symlinks. Shared prefix-confinement
  helper now used by both the project and home candidates.
- Session: echo the relative sourceLabel (not the absolute sourcePath) to
  the ACP client so the OS username / dir layout isn't leaked into the UI.
- loop-tick-resolver: drop the contradictory "established earlier" reminder
  on the first full delivery (the block is in the same message); keep the
  mode-specific pacing suffix on both full and short ticks.
- loop-tick-resolver: document the intentional cut > 0 boundary in
  cutToLastNewline (a position-0 newline keeps the body rather than
  emptying it).

Tests: ELOOP skip, home-symlink escape vs in-home target, home
non-regular-file skip, position-0 newline, first-delivery wording, and a
Session-level assertion that an auto-compaction resets the resolver cache
so the next unchanged tick re-expands. All new tests mutation-checked.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): address CI review on loop.md reader/resolver

- loop-task-file: at the byte cap, drop a still-incomplete trailing UTF-8
  sequence (walk to the last lead byte; cut before it when its declared
  width runs past the cap) so a mid-/malformed multi-byte char can't leave
  an orphan lead that decodes to a trailing U+FFFD. The byte-length
  re-clamp is now a pure safety net rather than the load-bearing step.
- loop-task-file/resolver: scope the boundary realpath cache to the
  LoopTickResolver instance (fresh per /cd rebuild, cleared by resetCache)
  so a long-lived process can't pin a stale boundary after a symlink
  re-point; direct barrel callers keep the process-lifetime fallback.
  Eviction-on-failure is preserved.
- nonInteractiveCli: log the headless cleanup of a recurring session
  loop.md cron, and guard the discarded delete() promise with .catch() so
  a future async delete can't become an unhandled rejection.
- tests: cover the incomplete-trailing-char cap, the ENAMETOOLONG skip
  branch, and resetCache invalidating the boundary realpath cache.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): re-evaluate folder trust per tick; guard durable loop.md in headless

Address review on the loop.md reader/resolver:

- Session resolver takes a folder-trust getter and re-reads it on every tick
  instead of capturing it once at construction. isTrustedFolder() is not
  process-stable in IDE sessions (a workspace-trust update can flip it), so a
  trusted->untrusted flip now stops reading the repo-controlled .qwen/loop.md
  immediately rather than serving it from a stale snapshot.
- Headless durable cron: a durable <<loop.md>> sentinel cannot be expanded in a
  headless run, so the scheduler skips such durable jobs before processJob
  stamps lastFiredAt (new setSkipDurableFire guard, honored by the tick loop and
  the catch-up/final delivery paths). Previously the tick was marked fired and
  persisted while the work was skipped downstream, silently consuming a tick the
  owning interactive session should run.
- Drop the absolute sourcePath from LoopTickResult; sourceLabel already serves
  the UI label and the presence check without leaking an absolute path.
- Size the loop.md read buffer to the file (stat.size + 1) instead of always
  allocating the 25 KB cap, keeping the bounded-read and truncated-flag
  semantics intact.
- Add one happy-path debug log on a successful loop.md read (source label, byte
  count, truncated flag) so oncall can confirm pickup.
- Add load-bearing tests for the 2-byte and 3-byte UTF-8 width branches.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): honor skipDurableFire in deliverPending missed branch

The headless durable-cron guard skipped fire+persist for catch-up and
final deliveries but not the missed branch: a missed one-shot <<loop.md>>
sentinel was unconditionally notified AND removed from disk, permanently
losing the task though no consumer ran the loop.md work. Partition the
missed batch on the same predicate — skipped one-shots are neither
notified nor deleted (left on disk for the owning interactive session),
while co-missed runnable one-shots still fire (batched) and are removed.

Tests:
- cronScheduler.test.ts: cover deliverPending missed/catch-up/final with a
  skip predicate — sentinel not fired/persisted, sibling fired+persisted;
  the missed-branch test mutation-locks the fix above.
- loop-task-file.test.ts: isWithin sibling-prefix confinement — root /ws/foo
  must refuse a realpath under sibling /ws/foobar; fails if isWithin loses
  the trailing path.sep (bare startsWith).

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): address loop.md injection review (durable guards, logs, QWEN_HOME)

Address 5 review suggestions on the loop.md sentinel pipeline:

- cronScheduler.deliverPending: align the catch-up and final skip guards
  with the tick loop by adding the `job.durable &&` predicate, so a
  non-durable job can never be skipped by skipDurableFire.
- cronScheduler: add a debugLogger.debug at each of the 4 durable-skip
  points (tick/missed/catch-up/final) so a deferred durable loop.md tick
  is diagnosable.
- Session: tag a loop.md sentinel-resolution failure distinctly from a
  model-call failure (sentinel mode + error code, no absolute path) before
  the shared cron catch surfaces it.
- Session.test: add a tick test that delivers the full block then a SHORT
  REMINDER on an unchanged second tick (full:false + sourceLabel).
- loop-task-file / Session: resolve the home/global loop.md from the
  QWEN_HOME-aware global dir (Storage.getGlobalQwenDir) instead of raw
  os.homedir(), keeping the home confinement from the earlier fix. Adds
  reader + Session coverage.

Co-Authored-By: Qwen-Coder <[email protected]>

* test(loop): cover all-filtered missed batch and working-dir resolver rebuild

Address PR review on the loop.md injection work with two load-bearing test
additions:

- cronScheduler: a 'missed' batch where every durable task is a loop.md
  sentinel filtered by skipDurableFire fires nothing and never calls
  removeMissedFromDisk (the runnable.length > 0 guard), so the owning
  interactive session still surfaces the preserved tasks.
- Session: changing getWorkingDir between ticks rebuilds the loop.md
  resolver for the new root, resolving the new root's loop.md and never
  re-serving the old root's content.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): report real home loop.md path in absent tick; cover resolve() error path

The absent loop.md reminder hardcoded `~/.qwen/loop.md (home)`, which is wrong once $QWEN_HOME relocates the global dir — the resolver checks `<homeQwenDir>/loop.md`. Build the absent body from the resolver's real homeQwenDir (OS-home tilde-abbreviated so the unrelocated case still reads `~/.qwen/loop.md`); keep the project label relative.

Also add a Session test that a `<<loop.md>>` sentinel whose resolve() throws (EACCES) propagates into the cron catch — surfacing as a cron error, never degrading to a default tick sent to the model — and logs the loop.md-tagged warn.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): sanitize loop.md resolve error and guard handle close

The cron catch forwards a sentinel-resolution error's .message verbatim to
the ACP client via emitAgentMessage. Re-throwing the raw Node fs error
leaked its absolute loop.md path (OS username + dir layout) to the client/
API. Re-throw a sanitized error carrying only the relative candidate labels
+ errno code; keep the full detail (absolute path included) in the local
debug warn.

Also guard handle.close() in readBoundedTaskFile's finally: a close failure
(e.g. EBADF) would otherwise replace an in-flight read/stat error (e.g. EIO)
and mask the real cause. Swallow the close error (debug-log only) so the
original propagates; the close is still attempted.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): align skip guards, resolve sentinel pendingRemoval limbo, QWEN_HOME-aware absent/error labels

Follow-ups on the loop.md-injection review:

- cronScheduler: add the `job.durable &&` guard to the missed-branch skip
  so all four skip sites (missed/catch-up/final/tick) read identically.
- cronScheduler: a skipped sentinel stayed on disk yet lingered in
  pendingRemoval, stranding it out of both the job map and disk
  reconciliation. Clear it from pendingRemoval (missed + final branches) so
  the next load re-installs it — the intended "defer to the owning session"
  path. Tests assert the skipped sentinel is not left in pendingRemoval.
- loop-tick-resolver: absentBody no longer claims the project candidate was
  checked when allowProjectFile is false (untrusted folder) — it names only
  the home path actually read. Tests updated.
- Session: the sanitized resolve-error reused a hardcoded ~/.qwen/loop.md;
  reuse the resolver's QWEN_HOME-aware homeLoopLabel() (now public) so a
  relocated global dir is reported accurately, still leak-safe. Test added.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): never leak an absolute $QWEN_HOME path in the home loop.md label

homeLoopLabel() built the model-facing home loop.md label via
tildeifyPath(join(homeQwenDir, 'loop.md')). homeQwenDir honors $QWEN_HOME,
which may point OUTSIDE $HOME (a supported relocation, common in
containers/CI). tildeifyPath only abbreviates $HOME-prefixed paths, so for a
$QWEN_HOME outside $HOME it was a no-op and the raw absolute path flowed
through absentBody() and the sanitized resolve-error into model/API text,
leaking the host's filesystem layout.

Label the three cases without ever emitting an absolute path:
- under $HOME -> tilde-abbreviated ~/.qwen/loop.md (unchanged);
- relocated via $QWEN_HOME -> the literal $QWEN_HOME/loop.md (env-var name,
  not the resolved dir), by swapping the resolved prefix;
- any other out-of-$HOME dir -> a generic placeholder.

The real absolute path stays in LOCAL debug logs only. Two existing tests
codified the leak (asserting the absolute path appeared); fixed and added a
privacy test (label + absent-tick modelText use the placeholder, never the
absolute path).

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): guard UTF-8 truncation boundary and scope resolve-error to checked candidates

When a loop.md task file is byte-capped on a UTF-8 boundary, the trailing
back-off only dropped a still-INCOMPLETE lead (`lead + width > end`). It missed
the symmetric case where a complete character is followed by stray orphan bytes
(`lead + width < end`), and it ran only once. Because `lead` is found by walking
back over continuation bytes, an ASCII byte between an orphan lead and the cap
(e.g. `c3 41 80 80 80 61`) stopped the walk early: the orphan `c3` and the stray
`80` continuations survived and decoded to trailing U+FFFD that the byte-length
re-clamp cannot remove. Replace the single check with a re-checking loop that
keeps a trailing unit only when its declared width reaches `end` exactly, and
re-checks the new tail since several malformed units can stack. Adds a
mutation-checked test using the orphan-lead repro.

Also make the sentinel resolve-error path trust-aware. The sanitized error
always named `.qwen/loop.md (project)`, even for an untrusted folder where the
project candidate is never read — the same false claim the absent reminder
already avoids. Introduce LoopTickResolver.absentLocations(projectChecked) as the
single source of truth for the checked-candidate "where" string (project named
only when actually read, QWEN_HOME-aware never-absolute home label) and use it
from both absentBody and the Session throw, so the two messages cannot drift.
Adds a Session test asserting an untrusted folder omits `(project)` while still
naming the home label.

Co-Authored-By: Qwen-Coder <[email protected]>

* test(loop): lock that one-shot sentinel jobs are not deleted by the headless skip

The skipHeadlessLoopSentinel deletion guard is `job.recurring && !job.durable`
— both conditions required. All existing tests use recurring: true, so a
simplification to `!job.durable` would wrongly evict a one-shot sentinel job
with nothing to catch it. Add a locking test: a non-recurring sentinel-prompt
job still returns the skip result but is never passed to scheduler.delete.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): guard empty homedir confinement root; interpolate dynamic sentinel

Two low-severity hardening fixes for the loop.md tick path.

- Session.ts: when QWEN_HOME is unset and os.homedir() returns '' (minimal
  containers with no HOME), homeConfineRoot collapsed to ''. It is passed as
  the home-candidate symlink confinement root, and isWithin('', target) is
  trivially true — so a home ~/.qwen/loop.md symlink could resolve anywhere,
  bypassing the boundary. Fall back to path.dirname(homeQwenDir) (the parent of
  the always-non-empty Storage.getGlobalQwenDir()) so the root is never empty.
  Adds a focused test that drives a dynamic tick with os.homedir()==='' and
  asserts the resolver receives a non-empty homeDir (mutation-checked).

- loop-tick-resolver.ts: PACING_SUFFIX.dynamic and ABSENT_TAIL.dynamic
  hardcoded the literal `<<loop.md-dynamic>>` in their user-facing instructions.
  Interpolate the exported LOOP_SENTINEL_DYNAMIC constant instead so a future
  rename can't silently drift the instruction. Adds one assertion referencing
  the constant on the absent-tick path (the full-block path was already covered).

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): reject hard-linked loop.md and keep dynamic loops alive on transient read errors

Hardens the .qwen/loop.md fire-time resolution surfaced by wenshao's review.

nlink hard-link exfiltration (Critical): the project and home loop.md
candidate checks verified isSymbolicLink()/isFile() + workspace/home
confinement but never inspected nlink. A hard link such as
`ln .env .qwen/loop.md` (or `ln ~/.ssh/id_ed25519 ~/.qwen/loop.md`) is an
indistinguishable regular file sharing the secret's inode; it passed every
check and isWithin (same inode, same fs), so the secret was read and injected
into the model prompt every tick. Both candidates now reject `nlink > 1`,
mirroring canonicalizeKeytermsFile in voice-keyterms.ts.

dynamic loop silent death (correctness): a transient, non-whitelisted fs error
(EACCES/EIO, or a Windows editor/AV briefly locking the file) made resolve()
throw, the cron catch re-throw, and the turn unwind before the model ran. For a
dynamic (self-paced) loop this is fatal and permanent: the firing wakeup was
already consumed and the loop only survives because the model re-arms
LoopWakeup at end-of-turn, so no turn means no re-arm. The catch now degrades a
dynamic tick to a model-facing no-op that mirrors the absent path (heading +
ABSENT_TAIL.dynamic re-arm + errno note) so the loop survives the hiccup; cron
keeps throwing (it re-fires on its own next interval). The real errno stays in
the LOCAL debug log, never leaking the absolute path to the client.

Also: capture folder-trust once per tick and thread it into both resolve() and
the sanitized error's absentLocations(), so a mid-tick trust flip can't make the
error name a different candidate set than was probed; and log the previously
silent whitespace-only skip so a present-but-empty loop.md is distinguishable
from an absent one in debug logs.

Co-Authored-By: Qwen-Coder <[email protected]>

* test(loop): hoist expects out of conditional branches to satisfy vitest/no-conditional-expect

The loop.md test additions placed expect() calls inside if-narrowing
branches (if (result.status === 'found') { ... }) and an
if (typeof text === 'string') guard inside a for-loop, which the
vitest/no-conditional-expect rule (an error in the recommended preset)
flags.

Hoist each assertion to the top level: assert result.status first, then a
plain throw-guard to narrow the discriminated union, then the assertions
unconditionally. In Session.test.ts, move the string type-filter into a
.filter() so the expect runs in a plain for-of (loops are not conditionals).
Pure lint-compliance refactor: assertions and coverage are unchanged.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): normalize home label trailing slash; distinguish transient-error echo

homeLoopLabel() built the $QWEN_HOME label by slicing homeLoopPath at
homeQwenDir.length, but Storage.getGlobalQwenDir() does not strip a trailing
slash, so QWEN_HOME=/x/.qwen/ over-counted the separator and produced the
garbled $QWEN_HOMEloop.md. Slice past path.dirname(homeLoopPath) instead, which
is always trailing-slash-free; the no-trailing-slash case is unchanged.

The loop-tick echo said "loop.md not present" whenever sourceLabel was absent,
which also fired for buildTransientErrorTick's dynamic-mode survival tick (a file
that exists but failed to read this tick). Add a transientError flag on
LoopTickResult, set only by buildTransientErrorTick, and branch the echo to
"loop.md temporarily unavailable" for that case; genuinely-absent stays "not
present". No errno/path leaks into the echo.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): only degrade transient fs errors in dynamic mode; log transient flag

In dynamic (self-paced) loop mode, the loop.md sentinel-resolution catch
degraded EVERY resolve() error to a no-op re-arm tick. A non-fs error (a
TypeError / assertion → code 'unknown') therefore entered an infinite
silent no-op cycle: the loop never died and the real bug never surfaced.
Gate the degradation on a known-transient fs code set (TRANSIENT_FS_CODES:
EACCES/EIO/EBUSY/EPERM/ENOENT); any other (unexpected) error falls through
to the existing sanitized throw so it surfaces instead of looping forever.

Also distinguish a transient-error tick from a genuinely-absent loop.md in
the debug log: both produce full:false + sourceLabel:undefined, so the line
printed delivery=absent for both. Add transient=${transientError ?? false}
so an oncall engineer can tell 'file missing' from 'file unreadable'.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): treat EISDIR/ENOTDIR as transient in dynamic mode

readLoopTaskFile's lstat→open sequence can throw EISDIR/ENOTDIR if the
loop.md path is swapped to a directory (or non-directory) between the
pre-open lstat and fs.open (a narrow TOCTOU race). Add both codes to
TRANSIENT_FS_CODES as defense-in-depth so a dynamic (self-paced) loop
degrades to a no-op re-arm tick and survives, rather than re-throwing and
silently terminating, should readLoopTaskFile's internal skip ever narrow.

Drop ENOENT from the set: readLoopTaskFile resolves a missing file to its
own missing→no-op path, so ENOENT can never reach the Session resolve()
catch where TRANSIENT_FS_CODES is checked — listing it was dead and
misleading ("absent" is not a transient read failure).

Add dynamic-mode tests asserting an EISDIR and an ENOTDIR resolve error
each degrade to a no-op re-arm tick (loop survives), mirroring the EACCES
case.

Co-Authored-By: Qwen-Coder <[email protected]>

* test(loop): lock the setSkipDurableFire sentinel-predicate wiring in runNonInteractive

Both halves of the headless durable-loop.md guard are tested alone —
detectLoopSentinel via the skipHeadlessLoopSentinel tests, and the
setSkipDurableFire filter in cronScheduler tests — but nothing pinned that
runNonInteractive actually installs a predicate connecting them. Add a
locking test that enables cron, injects a real CronScheduler, spies on
setSkipDurableFire, and asserts the captured predicate classifies both
loop.md sentinels (true) and a regular cron prompt (false).

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(loop): keep separator for root QWEN_HOME; distinct heading for transient-error tick

Two maintainer-suggested refinements to the loop.md tick resolver:

- homeLoopLabel() dropped the leading separator when $QWEN_HOME is the
  filesystem root: path.join('/', 'loop.md') = '/loop.md', whose
  path.dirname is '/' (length 1), so slicing past it yielded the garbled
  '$QWEN_HOMEloop.md'. The home candidate is always <homeQwenDir>/loop.md,
  so build the label as '$QWEN_HOME' + path.sep + 'loop.md' directly --
  byte-identical for the under-$HOME, non-root, and trailing-slash cases,
  fixed for root.

- The transient-error tick (file exists but unreadable this tick, e.g. EIO)
  reused the ABSENT heading ('loop.md absent'), contradicting its own body
  ('could not be read this tick'). Give it a distinct 'loop.md unavailable'
  heading via a new tickHeading({ unavailable }) variant, keeping the dynamic
  re-arm tail and the sanitized (errno) note unchanged.

Tests: add a root-$QWEN_HOME label case and assert the transient tick's
heading conveys 'unavailable' (never 'absent'/'not present'); update the
Session transient-tick heading assertions to match. The genuinely-absent
tick still reads 'loop.md absent' / 'not present'.

Co-Authored-By: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-06-28 23:05:06 +00:00
30c35781b3 ci(autofix): loosen issue candidate filters so the agent finds work (#5860)
* ci(autofix): loosen issue candidate filters so the agent finds work

The scheduled issue phase had been finding 0 candidates on every run. Tier-1
requires the rarely-applied status/ready-for-agent label, and tier-2's
"unattended = no human comment at all" rule fought its ">2 days old" age
gate: on a busy repo every aged bug already has a community reply, so the
funnel collapsed (128 aged bugs -> 0 survivors) purely on the comment rule.

- Unattended now keys off maintainer engagement (OWNER/MEMBER/COLLABORATOR
  comments, excluding our own bots), not any human comment. Community
  "+1"/"me too" no longer disqualifies an issue.
- Tier-1 and tier-2 are always both gathered (tier-1 prioritized, then
  de-duped by number) so a single weak ready-for-agent issue can't starve
  the run.
- Tier-2 scans a bounded age window via MIN_ISSUE_AGE_DAYS..MAX_ISSUE_AGE_DAYS
  (1..15) to focus on fresh, settled bugs instead of an unbounded set.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(ci): preserve autofix issue tier priority

* fix(ci): keep ready autofix issues in tier one

* fix(ci): reserve autofix tier two slots

* fix(ci): tolerate tier two autofix scan failures

* fix(ci): tolerate tier one autofix scan failures

* fix(ci): harden autofix candidate merging

* fix(ci): harden autofix issue scanning

* fix(ci): harden autofix diagnostics

* fix(ci): skip unsafe autofix comment fallbacks

* fix(ci): drop unused autofix comments payload

* test(ci): strengthen autofix comment refresh guard

* test(ci): satisfy autofix workflow lint

* test(ci): tighten autofix tier two assertion

* test(ci): cover autofix tier merge guards

* test(ci): cover autofix unattended filter

* fix(ci): summarize autofix comment refresh drops

* fix(ci): bound autofix tier-2 comment-refresh API calls

Cap the tier-2 issues fed into the comment-refresh loop to 15 (a margin
above the ~10 ever selected, since filter_unattended_candidates drops
attended issues afterward) and request full pages with per_page=100, to
avoid GitHub secondary rate limits. Add workflow tests asserting the
forced-issue skip/in-progress exclusion and the tier-2 ready-for-agent
label exclusion stay in place.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(ci): refresh all scanned tier-2 autofix issues, not just first 15

The previous change capped comment-refresh to the first 15 tier-2 issues
before filter_unattended_candidates runs. The scan still fetches 30, so
valid unattended bugs at positions 16-30 were never refreshed nor
filterable: if the first 15 were all maintainer-attended/dropped, the run
could end with zero candidates again — the failure this PR set out to fix.

Drop the .[0:15] refresh cap and refresh the full scanned set (already
bounded by --limit 30 on the scan). per_page=100 stays as the
pages-per-issue rate-limit mitigation, and the drop summary total/counters
reflect the full refreshed set again. Top-N selection after filtering is
unchanged.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(ci): order tier priority before recency in autofix assess prompt

The assess prompt gave two conflicting selection rules: the tier note says not to pick a tier-2 issue over a comparably actionable tier-1 one, while the recency tiebreaker says to prefer the most recently reported. Since tier-1 has no age bound and tier-2 is the recent (1-15 day) tier, these point in opposite directions for comparable confidence. Make recency apply only after the tier preference and only within the same tier, removing the contradiction. Prompt text only; no shell/jq change.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(ci): guard forced-issue jq and derive autofix comment scratch path

Address two review suggestions on the autofix candidate scan step.

- Wrap the forced-issue candidate jq in the same error guard the other
  jq calls use (tier-1/tier-2 stamp, tier merge): on malformed
  forced-issue JSON it now emits a ::warning:: and falls back to an empty
  candidate list instead of silently producing an empty candidates.json
  with no diagnostic.
- Make refresh_issue_comments reuse-safe by deriving its NDJSON scratch
  path from the output_file parameter (${output_file%.json}.ndjson)
  instead of hardcoding tier2-with-comments.ndjson, so a second call or a
  different tier can't clobber a shared scratch file. Behavior for the
  current single call is unchanged.

Co-Authored-By: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-06-28 23:00:13 +00:00
jinye 988a5021e8 docs(telemetry): comprehensive documentation update to match current implementation (#5960)
* docs(daemon): update developer docs for recent daemon PRs

- Add Last-Event-ID client reconnect guide (10-event-bus, 13-sdk-daemon-client)
- Add cross-connection vote routing section (04-permission-mediation)
- Add new capability tags: daemon_status, workspace_permissions, workspace_trust,
  workspace_github_setup, workspace_voice, workspace_voice_transcription,
  voice_transcribe (11-capabilities-versioning)
- Add new event types: trust_change_requested, github_setup_completed,
  extensions_changed, mid_turn_message_injected (09-event-schema)
- Fix _meta.serverTimestamp source description (09-event-schema, 10-event-bus)
- Fix async function* syntax in SDK example (13-sdk-daemon-client)
- Sync event/capability counts across all docs (43->47 events, 67->75 tags)

* docs(daemon): add workspace remember design doc (PR #5884)

Design document for the sessionless workspace remember API proposed in
PR #5884. Covers API endpoints, task lifecycle, implementation details,
events, error handling, and SDK integration.

Status: Proposed (not yet merged).

* docs(telemetry): comprehensive update to match current implementation

Added 34 undocumented events, 17 metrics, 11 daemon metrics, 2 spans. Fixed diff_stat attribute schema (was documented as JSON string, actually individual attributes). Added Performance Monitoring reserved section. Standardized attribute annotations with type and optionality markers.

* fix(telemetry): extract EVENT_TOOL_OUTPUT_TRUNCATED constant and add qwen-code prefix

Extracted hardcoded event name to constant for consistency with other telemetry events. Added standard qwen-code. namespace prefix. Updated test assertion to match the new prefixed event name.

* qwen: address PR review feedback (#5960)

* qwen: address PR review feedback (#5960)

* qwen: address PR review feedback (#5960)

* qwen: address PR review feedback (#5960)

* qwen: address PR review feedback (#5960)

* qwen: address PR review feedback (#5960)

* docs: address PR review feedback (#5960)

* docs: address PR review feedback (#5960)
2026-06-28 21:01:35 +00:00
5581424b6b feat(browser-ext): revive Chrome extension via daemon-direct architecture (#5777)
* feat(chrome-qwen-bridge): 🔥 init chrome qwen code bridge

* chore(chrome-qwen-bridge): connect

* chore(chrome-qwen-bridge): connect & them

* chore(chrome-qwen-bridge): connect & them

* chore(chrome-qwen-bridge): wip  use chat ui

* chore(chrome-qwen-bridge): wip  use chat ui

* wip

* refactor(chrome-extension): rename chrome-qwen-bridge package to chrome-extension

* feat(chrome-extension): enhance network monitoring with webRequest API

Replace the existing network monitoring implementation with a more comprehensive solution that combines both webRequest and debugger APIs for broader coverage. The new implementation:

- Uses chrome.webRequest.onBeforeRequest to capture all outgoing requests
- Uses chrome.webRequest.onCompleted to capture completed responses
- Uses chrome.webRequest.onErrorOccurred to capture failed requests
- Retains debugger API integration for detailed network information
- Implements memory management with maximum 1000 logs per tab
- Adds proper initialization and cleanup for each tab
- Ensures graceful handling of debugger attachment failures
- Provides more reliable network activity capture across all tabs

This enhancement significantly improves the reliability and coverage of network monitoring functionality in the Chrome extension.

* refactor(chrome-extension): reorganize directory structure for better maintainability

This commit reorganizes the entire Chrome extension package structure for improved maintainability and clarity:

- Move all source files to `src/` directory (background, content, sidepanel)
- Move build configurations to `config/` directory
- Move documentation to `docs/` directory with proper categorization
- Move all script files to `scripts/` directory
- Move native-host specific files to appropriate subdirectories (`src`, `scripts`, `config`)
- Update package.json scripts to reflect new file locations
- Add comprehensive documentation files (debugging, development, architecture, API reference)
- Maintain all functionality while improving project organization

The reorganization separates source code from build output, centralizes documentation, and creates a clear separation of concerns making the project more maintainable and easier for developers to navigate.

* feat(chrome-extension): enhance native host communication and network logging

- Add troubleshooting documentation for native host setup issues
- Improve native host logging to home directory with fallback to tmp
- Enhance network logging in service worker with response body capture
- Update scripts to properly reference host.js from correct path
- Increase timeout for MCP session creation and long prompts from 3 to 5 minutes
- Add getConsoleLogs functionality to sidepanel for content script capture
- Improve browser-mcp-server network logs aggregation by request ID
- Update icon assets and improve manifest configuration

refactor(chrome-extension): consolidate host.js entry point and improve path resolution

- Create unified host.js entry point that delegates to src/host.js
- Improve path resolution for host scripts in installer and runner scripts
- Add proper path existence checks for browser-mcp-server.js
- Support running from different directory structures

style(chrome-extension): improve TypeScript type safety and error handling

- Add proper type definitions for message handling in side panel
- Add null checks and error handling for message parsing
- Improve React component callback implementations

* refactor(chrome-extension): redesign build workflow

* fix(chrome-extension): resolve ESLint errors in native host, service worker, and content script

- Fix 'Unexpected lexical declaration in case block' by wrapping switch cases in blocks
- Fix 'Unexpected constant truthiness on the left-hand side of a || expression' by using conditional patterns
- Fix unused variable errors by properly using catch error parameters or adding logging
- Fix 'document' and 'window' not defined errors in service worker with proper global declarations
- Add eslint-disable comments where appropriate for globals used in specific contexts

* feat(chrome-extension): enhance native host with browser MCP tools and event streaming

- Add new browser MCP tools: browser_click, browser_click_text, browser_run_js, browser_fill_form_auto
- Implement SSE (Server-Sent Events) for improved event streaming instead of long-polling
- Add daemon script for running the bridge host in background
- Enhance documentation with MCP notes and updated README
- Add multiple executable binaries to package.json: chrome-browser-mcp, qwen-bridge-host
- Improve error handling and event processing in the native host
- Add debouncing mechanism for stream end events in service worker
- Update timeout for MCP discovery to accommodate slower startup

Co-authored-by: Claude Opus 4.5 <[email protected]>

* fix(chrome-extension): use trusted cwd for MCP tool discovery

The root cause of MCP tools not being recognized by Qwen CLI was that
the cwd (current working directory) was defaulting to '/' (root directory).

In Qwen CLI's MCP discovery logic, there's a security check:
  if (!cliConfig.isTrustedFolder()) {
    return;  // Skip MCP tool discovery
  }

The root directory '/' is not a trusted folder, so MCP tools were
silently not being discovered at all.

Changes:
- host.js: Default to $HOME instead of process.cwd() for start_qwen
- service-worker.js: Remove '/' fallback, let host.js handle default

This ensures browser MCP tools (browser_read_page, browser_click, etc.)
are properly discovered and available to the model.

* fix(core): validate MCP entry script existence before connection

Add pre-flight check to verify that stdio-based MCP server entry scripts
exist on disk before attempting connection. This prevents silent failures
and provides clear error messages for misconfigured MCP servers.

* fix(chrome-extension): enhance native host path resolution and port config

- Add QWEN_BROWSER_MCP_SERVER_PATH env override for custom installations
- Expand candidate search paths for browser-mcp-server.js discovery
- Add detailed logging when MCP server script is not found
- Support BRIDGE_PORT env variable for HTTP API server configuration

* fix(chrome-extension): improve browser MCP server reliability and debugging

- Add comprehensive debug logging for bridge health checks and host spawn
- Support BROWSER_MCP_NO_SPAWN env to disable automatic host.js spawning
- Handle bridge unavailability gracefully with clear error messages
- Add raw JSON mode fallback for clients without Content-Length framing
- Capture and log host.js stdout/stderr instead of inheriting stdio
- Add pre-flight bridge check at startup for better diagnostics
- Handle each bridge call failure with proper error responses

* chore(chrome-extension): add debug wrapper script for MCP server

Add cbmcp-wrapper.sh to help diagnose MCP server invocation issues.
The wrapper logs invocation details and stderr to /tmp/cbmcp.log,
making it easier to debug when Qwen CLI spawns the MCP server.

* docs(chrome-extension): add MCP/Bridge troubleshooting guide

Document common failure scenarios when MCP bridge shows as Disconnected:
- EPERM errors when spawning host.js cannot bind to port
- Content-Length framing issues during MCP handshake
- Step-by-step troubleshooting with flow diagrams
- Manual bridge setup with BROWSER_MCP_NO_SPAWN workaround

* build(chrome-extension): 浏览器插件 mcp 构建优化

* docs(chrome-extension): update docs

* fix(chrome-extension): 解决CDP响应体获取和权限请求处理问题

* feat(mcp-chrome-integration): add MCP Chrome browser extension integration

Add complete Chrome extension with native messaging host for MCP integration:
- Chrome extension with sidepanel UI, service worker, and content script
- Native server with agent engines (Claude/Codex), session management, and tool bridge
- Shared packages for types, tools, and node specifications
- Documentation and build scripts

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* chore(mcp-chrome-integration): refactor

* feat(chrome-extension): 切换到HTTP后端代理并更新构建配置

* refactor(mcp-chrome-integration): build

* wip

* wip chrome extension

* chore: ignore .worktrees

* docs: plan sidepanel component removal

* refactor(sidepanel): remove local components

* feat(chrome-extension): add native messaging ACP client and protocol support

- Add ACP client for native messaging communication
- Add file handler for local file access via native host
- Add protocol definitions for ACP communication
- Archive old documentation files
- Update integration status and protocol documentation

* fix(chrome-extension): use GenericToolCall from @qwen-code/webui

- Replace non-existent local ToolCallCard import with GenericToolCall
- Import from @qwen-code/webui package instead of local path
- Fix component props to match GenericToolCall interface
- Remove unused ToolCallData import

* refactor(mcp-chrome-integration): split large files and fix ESLint errors

- Split tools.ts (1554 lines) into 8 schema files by functionality
- Split native-messaging.ts (1533 lines) into 6 modules
- Split doctor.ts (1099 lines) into 8 modules
- Split content-script.ts (1055 lines) into 5 modules
- Add Qwen Team license headers to all new files
- Remove unused tool names (SEARCH_TABS_CONTENT, SEND_COMMAND_TO_INJECT_SCRIPT, USERSCRIPT, RECORD_REPLAY)
- Fix ESLint errors: no-require-imports, no-explicit-any, no-unused-vars, prefer-const
- Add archive/ directory to eslint ignore patterns

* 调试 MCP 工具成功

* refactor: revert unnecessary formatting changes and clean up MCP chrome-integration

- Revert Node version requirement from >=22 to >=20
- Revert code formatting changes (import statements and indentation)
- Archive obsolete chrome-extension implementation
- Clean up outdated documentation and scripts
- Reorganize MCP chrome-integration docs

* docs(mcp-chrome): 新增核心文档和更新 README

- 新增 02-features-and-architecture.md (27个工具完整参考)
- 新增 03-design-and-implementation.md (与 hangwin/mcp-chrome 对比)
- 新增 04-test-cases.md (35个测试用例)
- 更新 01-installation-guide.md (Extension ID 固定方案)
- 重写 README.md (对齐新文档结构)

* refactor(chrome-extension): 简化 sidepanel 并移除未使用代码

- 删除未使用的 Onboarding 组件
- 删除冗余样式文件 (App.css, timeline.css)
- 删除未使用的工具函数 (diffStats, diffUtils, sessionGrouping, tempFileManager, webviewUtils)
- 新增 MCP 工具状态横幅显示
- 隐藏不需要的 UI 按钮 (slash command, attach, edit mode)
- 移除未使用的变量 (clearToolCalls)
- 更新 manifest.json 和 native-messaging-host

* chore: regenerate lockfile for mcp-chrome-integration workspace

npm install reconciles the lock with the merged package.json: adds the
new packages/mcp-chrome-integration/app/* workspace subtree (371 deps).
No existing main dependencies were removed.

* chore(chrome-integration): drop dead logger + unused pino deps

native-server/src/util/logger.ts was 100% commented-out dead code (even
a hardcoded /Users/hang/... path) with zero importers; pino/pino-pretty
were declared but never imported (logging is console.error to stderr,
correct for a native-messaging host). Remove the file and both deps.

* feat(chrome-integration): WIP serve-backed agent client (Phase 1 backbone)

Replace the hand-rolled ACP client by driving `qwen serve` (main's
maintained HTTP daemon) through the SDK's DaemonClient. New
serve-agent/client.ts spawns `qwen serve --no-web` where the host used to
spawn `qwen --acp`, then uses DaemonClient (REST + SSE) for session
create / prompt / cancel / permission / streaming. Same public surface as
AcpClient so the native host can swap in place.

Compiles + typechecks against the real SDK API. NOT yet wired into
native-messaging-host.ts — wiring is gated on two items that need runtime
validation in a real Chrome + qwen env:
  - OAuth: host's onAuthenticateUpdate (in-extension authUri) maps to
    serve's server-side device-flow events — needs design.
  - permission requestId becomes string (was number) — host's
    permissionRequests map + handler need to follow.

acp/ kept in place until the serve path is validated. Packaging follow-up:
@qwen-code/sdk (→ core) as a dep of the standalone host is fine in the
monorepo but needs a publish-time story (bundle or resolve from the
co-installed qwen).

* feat(browser-ext): daemon-direct connection foundation (Phase 1, #5626)

First brick of the daemon-direct architecture from #5626: the extension
talks straight to a local `qwen serve` HTTP daemon instead of a native
messaging host.

- daemon/config.ts: resolve { baseUrl, token } — default loopback
  http://127.0.0.1:4170 (auth-free), overridable via chrome.storage.local.
- daemon/discovery.ts: GET /health probe so the side panel can show a
  "start qwen serve" hint instead of a broken chat when no daemon is up.

Both typecheck clean. (Pre-existing tsc errors live only in the orphaned
legacy sidepanel hooks — useWebViewMessages/useToolCalls etc. — which the
DaemonSessionProvider migration removes next.)

* docs(browser-ext): daemon-direct architecture spec (Phase 1+2, #5626)

Concrete implementation spec: Phase 1 (side panel as daemon client, no
daemon changes) and Phase 2 (browser tools as a client-hosted MCP server
over the daemon WS). Phase 2 reuses the existing SdkControlClientTransport /
SdkControlServerTransport pattern but moves the wire from the SDK subprocess
control plane onto qwen serve's WebSocket — a new public daemon-contract
surface, gated behind a capability flag (the open question in #5626).
Includes the daemon-lifecycle options for #5626 Q3.

* feat(browser-ext): Phase 1 — side panel as a daemon-direct client (#5626)

Side panel chat now talks straight to a local `qwen serve` HTTP daemon via
@qwen-code/webui's DaemonSessionProvider, replacing the native-messaging
relay (background/ + content/ untouched; nativeMessaging perm kept for now).

- SidePanelRoot.tsx: health gate — checkDaemonHealth(getDaemonConfig());
  shows a "run qwen serve" hint + Retry when unreachable, else mounts
  DaemonSessionProvider around App.
- App.tsx: rewritten daemon-driven — transcript/streaming/permissions/
  lifecycle from the webui daemon hooks (useTranscriptBlocks, useStreamingState,
  usePromptStatus, usePendingPermissions, useConnection, useActions); reuses
  the existing webui presentational components + ChromePlatformProvider.
- sidepanel/daemon/{transcriptItems,permission}.ts: adapters from daemon
  transcript/permission shapes to the existing UI components.
- Deleted the dead legacy native-messaging chat hooks/types.

Verified: sidepanel/daemon tsc clean; `npm run build` (esbuild) passes,
emits the side-panel bundle with the daemon wiring. (Pre-existing tsc errors
remain only in background/ + content/, out of scope until Phase 2.)

Daemon contract accepted live against the worktree `qwen serve`: /health,
/capabilities (advertises session_create/prompt/events/workspace_mcp),
POST /session runs end-to-end to the model-auth gate.

* feat(browser-ext): Phase 2 — browser tools over the daemon WS (#5626)

Reverse tool channel: a WS client (the extension) hosts an MCP server (its
browser tools) that the daemon's agent can call, carrying mcp_message
JSON-RPC frames over the daemon WS — reusing the SdkControlClientTransport /
SDK-MCP-server control-plane pattern. Gated behind capability flag
`client_mcp_over_ws` (opt-in; the public-contract piece flagged in #5626).

Daemon (core + cli/serve):
- core/tools/client-mcp-registrar.ts: ClientMcpRegistrar — id-correlation,
  pending/timeout, notifications fire-and-forget, exposes the
  sendSdkMcpMessage(server,msg) callback McpClientManager consumes.
- cli/serve/acp-http/client-mcp-ws.ts: ClientMcpWsConnection — handles
  mcp_register/mcp_message/mcp_unregister frames; pushes mcp_message down the
  WS; disposes on close. Hookup to the live agent McpClientManager is a
  ClientMcpServerProvider injection point (returns structured `not_wired`
  until the child↔parent reverse-IPC lands — see below).
- capability `client_mcp_over_ws` threaded through serve options/capabilities.

Extension (chrome-extension/background):
- browser-tools-server.ts: minimal hand-rolled MCP JSON-RPC
  (initialize/tools/list/tools/call) reusing the existing tool-catalog +
  router + executors (MVP 6 read-first tools); WS client to the daemon /acp
  with mcp_register + reconnect. Wired into the service worker behind a
  health probe; native messaging left intact.

Self-accepted (no LLM needed): 10/10 tests pass — a headless ws client
registers + answers the MCP handshake over mcp_message and the daemon
lists+CALLS the client-hosted chrome_read_page tool end-to-end over a real
socket. Builds: core + cli + extension esbuild all green; tsc clean.

NOT yet wired (out of scope here; needs acp-bridge + acpAgent reverse IPC):
the parent-process WS ↔ ACP-child McpClientManager hookup — the daemon's WS
lives in the parent serve process but sendSdkMcpMessage binds in the ACP
child. Single injection at the mountAcpHttp call site once that IPC exists.

* feat(serve): wire client-MCP-over-WS to the ACP child agent (#5626)

Closes the Phase 2 gap: a client-hosted (extension) MCP server's tool calls
now reach the agent's McpClientManager in the ACP CHILD, routed back to the
parent's ClientMcpRegistrar and out over the daemon WS. Opt-in via
QWEN_SERVE_CLIENT_MCP_OVER_WS=1 (the contract is still settling — dormant by
default; this is the public-daemon-contract piece flagged in #5626).

Contract additions:
- ACP ext-method `qwen/control/client_mcp/message` (child→parent, called UP):
  {server,payload} → {payload}; notifications resolve with a synthetic ack.
- runtime-MCP config flag `__clientMcpOverWs`: parent stamps it on the SDK-type
  add config; child KEEPS type:'sdk' (instead of stripping) so it binds an
  SdkControlClientTransport instead of the SDK subprocess control plane.
- BridgeOptions.clientMcpSender seam + ServeAppDeps.clientMcpSenderRegistry.

Round-trip: mcp_register(WS) → serve registers the connection's
ClientMcpRegistrar.sendSdkMcpMessage in a process ClientMcpSenderRegistry +
bridge.addRuntimeMcpServer(type:'sdk',__clientMcpOverWs) → child adds the
SDK server + runs initialize/tools/list, each frame child→parent via
client_mcp/message → BridgeClient looks up the sender → registrar pushes
mcp_message down the WS → extension answers → child discovers the tools.

Self-accepted LIVE (no LLM): integration-tests/cli/qwen-serve-client-mcp.test.ts
spawns a REAL qwen serve + REAL qwen --acp child under a mock OpenAI server,
a headless ws client registers + answers the MCP handshake, and the child
discovers the client-hosted chrome_read_page tool over the genuine
child→parent→WS channel (GET /workspace/mcp/chrome-tools/tools lists it).
Verified passing locally (24.6s). +5 bridge round-trip tests; 324 acp-bridge,
the 10 prior Phase-2, acpAgent 133, server+acp-http 675 all pass; builds clean.

Not exercised here: a real LLM turn driving tools/call (needs creds), and a
real Chrome extension as the WS client (headless ws stands in).

* fix(serve): session-scope runtime MCP servers so client tool calls resolve (#5626)

The reverse tool channel registered the client-hosted MCP server only on the
bootstrap/workspace Config, so discovery worked but a prompt — which runs
against an independent per-session Config from newSessionConfig→loadCliConfig
— couldn't resolve the tool ("not found in registry"), and the reverse WS
channel was never reached. Spec (docs/05) intends per-session scope.

Fix (minimal, additive, guarded; normal settings-based MCP servers unaffected):
- core/config.ts: add Config.getRuntimeMcpServers() (shallow copy of the
  private runtimeMcpServers map).
- acpAgent.ts newSessionConfig: copy the bootstrap Config's runtime MCP
  servers into a newly-created session Config before initialize() so its
  discovery binds that session's sendSdkMcpMessage (register-before-session).
- acpAgent.ts workspaceMcpRuntimeAdd/Remove: fan the add/remove out to every
  active session's McpClientManager (register-after-session), best-effort.

Test: the fake model now emits the fully-qualified registered name
mcp__chrome-tools__chrome_read_page (what a real model is handed); the
reverse channel still forwards the bare tool name to the client server.

Verified LIVE (no LLM, no creds): integration test now drives the FULL loop —
model→agent→session-registry resolution→reverse client_mcp/message over the
daemon WS→headless ws client returns CallToolResult→agent consumes it (tool
completed)→turn_complete. 2/2 integration tests pass (confirmed locally).
Regression: config 248, acpAgent 133, mcp-client-manager 101, client-mcp-ws 5
pass; builds clean. (server.test.ts has 2 pre-existing Web-Shell flakes,
identical on the unmodified baseline.)

Still needs a real Chrome extension (vs the headless ws stand-in) + a real
model turn for true browser behavior; the protocol round-trip is proven.

* chore(browser-ext): remove the dead Native Messaging stack (#5626)

Daemon-direct made Native Messaging obsolete — the extension talks to
`qwen serve` directly (chat over HTTP+SSE, browser tools over the daemon WS),
so the entire native-host stack is dead weight. Deletes ~15.5k lines:

- packages/mcp-chrome-integration/app/native-server/ — the whole native host
  (MCP servers, ACP client, Fastify server, doctor/register/report/postinstall,
  the superseded serve-agent backbone).
- extension background transport: native-messaging.ts, native-connection.ts,
  native-message-handler.ts, native-messaging-types.ts, ui-request-router.ts
  (+ its test). The still-used executor types (BrowserToolArgs / RawNetworkRequest
  / WebSocketSession / NetworkCaptureState) move to background/browser-tool-types.ts.
- service-worker rewired to daemon-direct only (drops the NativeMessaging
  init + the onMessage→routeUiRequest relay; keeps the browser-tools server start).
- esbuild.background.config.js drops the deleted native-messaging entry point.
- manifest.json drops the `nativeMessaging` permission.
- package.json scripts drop every native-server/native-host reference; dev-watch
  no longer spawns the native host.
- obsolete native-messaging docs (01-04) + scripts (diagnose/install/update) removed;
  README rewritten for daemon-direct.

Extension esbuild build green; tsc errors dropped (84 -> 69, all pre-existing
node:test/content typings). Daemon-side (cli/serve/core) untouched.

* chore(browser-ext): drop orphaned content-fetch-patch.ts (#5626)

* fix(serve): let browser extensions open the daemon WS reverse channel

The daemon-direct Chrome extension (#5626) connects to qwen serve's /acp
WebSocket to register its browser tools as a client-hosted MCP server.
Three gaps blocked the real-browser path. A node WS client in the
integration tests carries no browser Origin and completes the ACP
handshake, so none of these surfaced until a real Chrome connected:

- The WS CSRF check hard-coded loopback origins, so the extension's
  chrome-extension://<id> Origin was rejected with 403. Wire the
  existing --allow-origin allowlist into the WS upgrade check
  (acp-http/index.ts, server.ts) with the same match semantics as the
  REST allowOriginCors.
- parseAllowOriginPatterns rejected chrome-extension:// because its
  URL.origin is the opaque "null". Rebuild the canonical origin from
  scheme+host for opaque-origin schemes (auth.ts), with tests.
- The extension skipped the ACP initialize handshake and sent
  mcp_register directly, tripping the daemon's 30s initialize timeout.
  Send ACP initialize first and register only after the ack
  (browser-tools-server.ts).

Also allow console.* in the extension package (no stdio in the MV3
runtime) via the eslint no-console allowlist.

Verified end-to-end against a real Chrome: the daemon agent calls
mcp__chrome-tools__chrome_read_page and reads the live active tab.

* docs(browser-ext): Plan C (CDP tunnel) feasibility + implementation design

Assess routing chrome-devtools-mcp's ready-made DevTools toolset through the
extension's chrome.debugger to drive the user's real browser, instead of
re-implementing each tool in the extension (Plan A). Records, with source
verification against [email protected] + [email protected]:

- the createCDPSession wall (why zero-change reuse fails — Target.attachToTarget
  is "Not allowed" for chrome.debugger; cdp-mcp throws in McpContext.from);
- the patch-package fork shape (pin 1.4.0 + a ~2-site patch, not vendor/submodule);
- the daemon /cdp browser-level CDP emulation + sessionId routing design;
- minimal browser-level command set, reusable prior art (playwright-mcp
  --extension), phased steps, risks, and the Plan A fallback.

Refs #5626.

* feat(serve): add CDP browser-level emulator for Plan C tunnel (#5626)

First component of the Plan C "CDP tunnel": a synthesis layer that fakes the
browser-level CDP topology so an external puppeteer client (chrome-devtools-mcp)
can connect over a future /cdp endpoint while page-domain commands are forwarded
to the one real tab via the extension's chrome.debugger.

Implements the exact contract a Phase 0 spike proved necessary: a tab->page
two-level target tree + recursive Target.setAutoAttach (browser attaches the tab
session; the tab session attaches the page session), with page-session commands
routed to forwardToTab and tab events re-tagged with the page session id. The
spike connected real puppeteer to a pure synthesis layer and ran
page.evaluate(() => 1 + 1) === 2. 7 unit tests cover the handshake + routing.

Refs #5626.

* feat(serve): add CDP tunnel reverse-link, /cdp glue, and bridge registry (#5626)

Plan C Phase 1 daemon core. The reverse-link forwards page-domain CDP
commands to the extension over cdp_command/cdp_result frames (id-correlated,
timeout) and re-tags cdp_event onto the page session; cdp-ws wires a per-
puppeteer-connection emulator to the reverse-link bound to the single active
extension bridge in a process-scoped registry. The emulator gains setTabInfo
so the synthetic targetInfo reflects the real tab after cdp_attach.

* feat(serve): wire /cdp upgrade branch and cdpTunnelOverWs flag (#5626)

Adds the /cdp WebSocket upgrade branch to acp-http (reusing the loopback /
host-allowlist / auth / CSRF checks) and routes inbound cdp_* frames on the
extension's /acp socket to the bound reverse-link. The extension connection
registers as the active CDP bridge eagerly at ACP initialize so a /cdp
puppeteer client can bind immediately (avoids the attach chicken-and-egg).
Feature flag cdpTunnelOverWs is wired exactly like clientMcpOverWs
(env QWEN_SERVE_CDP_TUNNEL_OVER_WS=1, capability cdp_tunnel_over_ws),
DEFAULT OFF — existing behaviour is unchanged when off.

* feat(browser-ext): add CDP bridge over the reverse /acp socket (#5626)

The extension answers cdp_attach by attaching chrome.debugger to the active
tab and cdp_command via chrome.debugger.sendCommand, replying cdp_result;
chrome.debugger.onEvent -> cdp_event, onDetach -> cdp_detach. Reuses the
existing browser-tools-server /acp socket (routes cdp_* frames; tears the
bridge down on socket close) and mutually excludes with the
chrome_network_debugger_* tools (one debugger per tab).

* build(deps): pin chrome-devtools-mcp 1.4.0 + puppeteer-core 25.2.0, patch McpContext (#5626)

Pins the two CDP-tunnel client deps (exact, to keep the version-specific
patch and puppeteer's hardcoded ExtensionTransport topology stable) and adds
patches/chrome-devtools-mcp+1.4.0.patch. The patch wraps McpContext.#init's
devtoolsUniverseManager.init / serviceWorkerConsoleCollector.init in try/catch
so the createCDPSession wall (Target.attachToTarget -> -32000 over
chrome.debugger) no longer crashes the server on startup; only performance_* /
service-worker console degrade. Applied via the existing postinstall
patch-package hook (same form as patches/ink+7.0.3.patch).

* test(serve): add Plan C /cdp end-to-end acceptance harness (#5626)

Node script that starts the real daemon with the flag on, connects a mock
extension over /acp (ACP initialize + mcp_register, answering cdp_command with
page-domain CDP), then puppeteer.connect to /cdp and asserts
page.evaluate(() => 1 + 1) === 2 through the real daemon + emulator +
reverse-link. Allowlists the harness dir in eslint's node-script globals.

* fix(serve): gate CDP page commands behind attach completion (#5626)

Real-Chrome testing surfaced an ordering race the mock acceptance missed: the
extension's chrome.debugger.attach is async (it pops the debugger banner), but
forwardToTab forwarded page-domain commands immediately, so a fast puppeteer
Network.enable reached the extension before attachedTabId was set and failed
"CDP tunnel not attached to a tab". The mock extension acked attach
synchronously, which hid the race.

Add an attach gate in CdpReverseLink: forwardToTab awaits the in-flight
cdp_attach to settle (success or failure) before forwarding. Verified
end-to-end against REAL Chrome — puppeteer read the live active tab (a GitHub
PR page) through the tunnel: pages=1, real url/title/body returned.

Refs #5626.

* refactor(chrome-extension): delete Plan A side panel, content scripts, and reverse tool channel

Tears out the superseded Plan A surface so the extension can become a pure
CDP-tunnel pipe (chat moves to the daemon web UI, browser tooling runs as
chrome-devtools-mcp over the /cdp tunnel):

- src/sidepanel/ (side-panel chat UI)
- src/content/ (content scripts; the CDP tunnel drives DOM/Input via
  chrome.debugger, no injection needed)
- background reverse tool channel: browser-tools-server, browser-network-tools,
  network-capture-utils, browser-tool-executors, tool-catalog, tool-router,
  mcp-tool-result, browser-tool-types, and their tests
- public/sidepanel/sidepanel.html static asset

Refs #5626

* refactor(chrome-extension): rewrite service worker as minimal daemon CDP client

The service worker is now the entire extension logic: probe the daemon /health,
open the /acp WebSocket, send the ACP initialize handshake (the daemon closes
the socket on a 30s init timeout otherwise and binds this connection as the CDP
bridge at that point), then route cdp_* frames into the CDP bridge with capped
backoff reconnect. No more reverse MCP tool server (chrome-tools is gone).

cdp-bridge: drop the browser-network-tools import and the network-capture
mutual-exclusion branch in handleAttach (the network tools are deleted, nothing
to exclude); remove the now-unused isCdpTunnelAttached export.

Refs #5626

* build(chrome-extension): trim manifest, build config, and deps to the CDP pipe

manifest: drop content_scripts, side_panel, and the sidePanel/webRequest/cookies/
scripting/webNavigation permissions; keep only debugger/tabs/activeTab/storage
plus background, key, host_permissions, icons, and action.

build: background esbuild now has a single service-worker entry point (content
script gone); delete the UI esbuild/postcss/tailwind configs and drop the UI
build step + build:ui scripts; sync-extension no longer special-cases the gone
sidepanel assets; dev-watch no longer spawns the UI watcher.

deps: remove the side-panel-only deps (@qwen-code/webui, react, react-dom,
markdown-it, and the @types + the postcss/tailwind/autoprefixer CSS toolchain).

Refs #5626

* chore(chrome-extension): drop dead externally_connectable hook (#5626)

* test(serve): add real-Chrome /cdp local verification script (#5626)

* test(serve): add cdp-mcp-over-tunnel layer-C smoke check

* fix(extension): keep the CDP tunnel alive with chrome.alarms

MV3 service workers idle out after ~30s, so the tunnel silently dropped
whenever no puppeteer client was driving it and the user had to keep the
Service Worker DevTools open to hold the worker awake. Register a 30s
chrome.alarms keepalive: the recurring onAlarm dispatch holds the idle
timer off, and each wake of a terminated worker re-runs the top level to
reconnect.

* feat(serve): auto-register chrome-devtools-mcp over the CDP tunnel

Plan C (#5626) last mile: when `qwen serve` runs with the CDP-tunnel flag,
the agent should be able to drive the user's real browser. Rather than
hand-writing browser tools, auto-register the (patched) chrome-devtools-mcp
as a session MCP server pointed at this daemon's /cdp endpoint, so its 29
ready-made DevTools tools flow through the tunnel.

- run-qwen-serve: forward QWEN_SERVE_CDP_TUNNEL_OVER_WS + _PORT into the
  spawned ACP child via childEnvOverrides (same path as the MCP budget env).
- acpAgent: buildCdpTunnelMcpServer() injects the server into the
  top-precedence sessionMcpServers tier when the flag + port are present and
  the package resolves; trust left unset so tools default to 'ask' (no silent
  auto-approval of browser control); best-effort skip otherwise.

No settings.json edit and no hand-written tools required.

* fix(serve): gate CDP bridge registration to the extension (#5626)

Auto-registering every /acp initialize as the CDP bridge was last-writer-
wins. Once an ACP agent connects over the same /acp endpoint (web UI, Zed),
it would capture the bridge and receive cdp_* frames it can't answer,
stealing the tunnel from the extension. Gate registration on
clientInfo.name === 'qwen-cdp-bridge'; the extension (and the acceptance
mock) now identify themselves that way, while agent clients are left alone.

* feat(extension): open the web UI when the toolbar icon is clicked

The extension has no UI of its own (pure CDP-tunnel pipe; chat lives in the
daemon web UI), so clicking the toolbar icon did nothing after the side panel
was removed. Wire action.onClicked to open the daemon baseUrl in a new tab so
the icon is a useful entry point instead of a dead click.

* feat: host the web UI in a Chrome side panel (#5626)

The extension is a pure CDP-tunnel pipe with no UI of its own, so after the
side panel chat was removed the toolbar icon did nothing. Bring the side panel
back as a thin host that iframes the daemon web UI (chat + tools), so the
sidebar is the everyday entry point and reuses the web UI's pages/components
instead of shipping a second UI in the extension.

- extension: side_panel + sidePanel permission; sidepanel.html/js frames the
  daemon baseUrl; toolbar icon opens the panel (openPanelOnActionClick).
- daemon: the Web Shell sent frame-ancestors 'none' + X-Frame-Options: DENY,
  which blocked the iframe. Allow framing only for chrome-extension origins
  explicitly passed via --allow-origin; everything else still gets DENY.

* fix: prefer chrome-devtools over computer-use under the CDP tunnel + keep MV3 worker alive during attach (#5626)

Two issues surfaced driving the real agent:

1. The agent picked the OS-level computer-use tool (cua-driver) for browser
   tasks instead of the injected chrome-devtools-mcp — heavyweight screenshot/
   click loop that pegged a CPU and stalled turns. Disable computerUse when the
   CDP tunnel flag is on so browser automation goes through the tunnel.

2. The extension's MV3 service worker idled out *between* CDP commands (the
   agent pauses to think), detaching chrome.debugger and hanging the next
   command. Add a sub-30s keepalive while attached; the 30s alarm only covered
   idle reconnects, not in-flight attachments.

* chore(extension): stop tracking .extension-key.pem (signing private key)

The extension signing private key was committed and pushed — anyone with repo
access could impersonate the extension. Stop tracking it and gitignore *.pem.
Load-unpacked debugging needs only the public "key" in manifest.json, so this
doesn't affect dev on any machine; the .pem is kept locally for packaging.

* refactor(chrome-extension): flatten to packages/chrome-extension

Drop the mcp-chrome-integration wrapper + dead native-server (daemon-direct
no longer uses native messaging). The extension is now a top-level workspace
at packages/chrome-extension. Updated root workspaces, eslint globs, doc-path
comments, and the two node scripts' global directives. cli + extension builds
and eslint verified green.

* test(serve): assert cdp_tunnel_over_ws in the capability registry

The cdp_tunnel_over_ws capability was added to SERVE_CAPABILITY_REGISTRY +
CONDITIONAL_SERVE_FEATURES but the test's EXPECTED_REGISTERED_FEATURES and the
conditional drift-insurance branch weren't updated, failing 3 registry tests.
Add it to the expected list (after client_mcp_over_ws, matching registry order)
and add its assertion branch (predicate accepts/rejects the cdpTunnelOverWsEnabled
toggle).

* fix(serve): address review comments on CDP tunnel + client-MCP wiring (#5777)

- guard post-async ws.send() with readyState OPEN (daemon crash on extension
  disconnect, sendClientMcpAck + cdp endpoint sends)
- make client-mcp-sender-registry delete() ownership-aware (cross-connection
  server-name collision)
- type the __clientMcpOverWs runtime config flag carrier
- document the CDP tunnel trust model (loopback + bridge-gated dumb pipe)

* chore(cdp-tunnel): set copyright year to 2026 on files created this year

The Plan C / #5626 files were authored in 2026 but carried a 2025 header.
service-worker.ts (created last year under #1432) keeps 2025.

* feat(chrome-ext): add side-panel onboarding gate + fix packaging

The side panel framed the daemon Web Shell unconditionally and showed a
static "Connecting…" line. When no daemon was reachable, or the daemon
wasn't started with --allow-origin (so frame-ancestors blocks the iframe),
the user was stuck on "Connecting…" with no guidance, and discovery.ts's
health check was never wired in.

Wire a health/capabilities gate into the panel:
- probe GET /health, then GET /capabilities
- down                         → "Start qwen serve" + the exact command
- up but no `allow_origin` feat → "Allow this extension" + the command
- ready                        → frame the Web Shell

The command is built from chrome.runtime.id at runtime, so it always names
this extension's real origin (dev-unpacked or published) — no need to know
or hardcode the id, and no publish-first chicken-and-egg. The pure decision
helpers live in onboarding-logic.js.

Also:
- fix `npm run package` so manifest.json sits at the zip root (the old
  `zip ... extension/` nested it under extension/, which the Chrome Web
  Store rejects)
- gitignore that packaging zip; add a package README

Part of #5626. PR #5777.

* docs(cdp-tunnel): trim over-long Plan C comments (ponytail)

Comment-only: compress multi-paragraph design rationale to intent, keep the
non-obvious why (trust model, bridge gate, attach ordering) + ponytail
ceilings. ~-91 lines, no code touched, build + tests green.

* fix(serve): address second-round review comments on the CDP tunnel (#5777)

- close the bound /cdp puppeteer socket on extension disconnect (was hanging
  ~170s on CDP timeout) via an onExtensionGone hook
- reject a 2nd concurrent /cdp client instead of silently clobbering routeInbound
- narrow extension host_permissions from <all_urls> to localhost (chrome.debugger
  needs no host perm; only the /health fetch needs localhost)
- log safeWsSend drops under serve debug mode so a dead tunnel is diagnosable

* feat(chrome-ext): polish side-panel welcome into a terminal console

Replace the bare welcome with a console that matches the product (a CLI
daemon): the command is the hero, typed at a `$` prompt with a blinking
cursor inside a titled terminal card. Warm-charcoal / electric-lime,
light + dark aware (prefers-color-scheme), staggered load-in, a pulsing
"listening" status, and a reduced-motion guard. No web fonts / no inline
JS (the extension CSP allows neither).

No behavior change: same /health + /capabilities gate and the
chrome.runtime.id-derived command. Mechanics tidied alongside the markup:
visibility toggles a .hidden class (CSS owns the flex layout), the copy
button updates a label span, and the command is prefilled synchronously
so first paint isn't an empty prompt.

PR #5777.

* feat(chrome-ext): click-to-copy command + centered copy button

Make the onboarding command easier to grab: the whole command row is now
click-to-copy (keyboard-reachable, Enter/Space), and a centered "Copy
command" button sits at the foot of the terminal card. Both flash a
check-mark "Copied" confirmation; the small top-bar button is gone.

No gate-logic change. PR #5777.

* test(serve): cover CDP-tunnel + client-MCP regression guards

Add the four focused unit tests flagged in review for load-bearing
reverse-channel paths that had no coverage:

- ClientMcpSenderRegistry: ownership-scoped delete — a disconnecting
  connection must not remove an entry a peer re-registered under the
  same name.
- CdpTunnelRegistry: register/supersede/unregister lifecycle, inbound
  routing delegation, onExtensionGone-on-disconnect, and the
  stale-unregister guard that must not evict a newer active bridge.
- CdpReverseLink: a forwarded command rejects when its per-command
  timer expires (not just on bulk dispose).
- safeWsSend: drops (no send, no throw) on a CLOSED/CLOSING socket.

safeWsSend is extracted from acp-http/index.ts into its own module so
it's unit-testable in isolation; behavior unchanged.

PR #5777.

* fix(serve): address bot code-review findings on the CDP tunnel (#5777)

- cdp-bridge: tear down listeners before re-attach (was double-registering →
  duplicate cdp_event frames corrupting puppeteer)
- emulator: return a CDP error for an unknown session instead of fake success
- registry: notify the superseded bridge so the old /cdp closes (single-puppeteer)
- deps: move chrome-devtools-mcp + puppeteer-core to optionalDependencies (~26MB)
- tests: entry-script validation, deliverClientMcpMessage error branches,
  registry supersede

* fix(chrome-ext): revert side panel to welcome when the daemon stops

Once the panel framed the Web Shell it stopped probing, so if the daemon
later went away the iframe was left showing Chrome's localhost
connection-refused page with no way back. Keep probing after framing and,
after a short tolerance (2 misses, ~5s, so a transient blip doesn't nuke a
live chat), clear the iframe src and show the welcome screen again.

PR #5777.

* fix(chrome-ext,serve): address review comments on the CDP tunnel

- service-worker: redact the bearer token from the connect log, and guard
  connect() against a still-CONNECTING socket so a rapid reconnect can't
  orphan an in-flight handshake.
- acpAgent: don't clobber a user-configured `chrome-devtools` MCP server
  with the tunnel auto-wire.
- cdp-reverse-link: log dropped/unexpected inbound frames via an optional
  diagnostic sink instead of swallowing them silently.
- name the cross-package `qwen-cdp-bridge` client-name constant on both
  sides instead of repeating the bare string.

PR #5777.

* refactor(chrome-ext): inline onboarding helpers, drop dead pollTimer

onboarding-logic.js was a 65-line file (mostly JSDoc) for three trivial
helpers and a constant, split out "for testability" that was never used.
Fold them into sidepanel.js (decideState collapses into probeState's
return; resolveBaseUrl/allowOriginCommand become a one-liner each) and
delete the file + its import.

Also drop `pollTimer`: the welcome-fallback change made it write-only (the
only clearInterval was removed), which trips no-unused-vars.

PR #5777.

* fix(serve,chrome-ext): more CDP-tunnel review fixes

- sidepanel: pass the bearer token through the Web Shell URL fragment so a
  token-gated daemon doesn't 401 every framed request.
- server: throw if deps.bridge is injected without deps.clientMcpSenderRegistry
  (the bridge is already wired to its own sender; a fresh one would be orphaned).
- cdp-browser-emulator: surface unhandled browser-level CDP commands via an
  optional log sink (keep the empty-result ack, with a TODO).
- cdp-bridge: only treat "already attached" as ours when attachedTabId === tabId
  (a foreign DevTools owner now errors), and detach the previous tab on switch
  so Chrome drops its debug banner.
- build.js: add packages/chrome-extension to the build order so root build
  exercises the extension bundle.

PR #5777.

* fix(serve,chrome-ext): harden CDP-tunnel + client-MCP reverse channel

- client-mcp-ws: cap registered servers per connection (max 10) and re-check
  `disposed` after the provider round-trip so a WS close mid-register can't
  leave a zombie server; add registrar serverCount().
- acp-http: rate-limit client-MCP frames (mcp_register/unregister at the
  mutation tier, mcp_message at read) and cap concurrent fire-and-forget
  register/unregister dispatch (max 8) to stop DoS amplification.
- cdp tunnel: on /cdp puppeteer disconnect send a `cdp_release` frame so the
  extension detaches chrome.debugger instead of leaving the tab's debug banner
  up until /acp dies.
- acpAgent: skip chrome-devtools auto-registration (with a diagnostic) when the
  /cdp tunnel requires bearer auth — the ACP child can't authenticate to it.

PR #5777.

* fix(chrome-ext,serve): address second-round CDP-tunnel review

- service-worker: only the *active* socket's close tears down the bridge — a
  stale daemon-forced close must not detach the new connection's debugger.
- daemon config + sidepanel: fail closed on a non-loopback baseUrl so a tampered
  chrome.storage value can't exfiltrate the bearer token off-host (background
  fetch/WS bypass host_permissions).
- sidepanel: reentrancy guard on tick() so overlapping slow probes don't burn
  the framed-miss tolerance and flash the welcome screen mid-chat.
- cdp-bridge: reentrancy guard on handleAttach so overlapping cdp_attach frames
  can't interleave teardown and corrupt attachedTabId.
- add cdp-ws.test.ts: regression cover for no-bridge reject, second-client
  reject, onExtensionGone fail-fast, cdp_release on dispose, and the
  superseded-bridge cleanup guard.

PR #5777.

* fix(serve,webui): address third-round CDP-tunnel review

- safe-ws-send: wrap the debug-mode writeStderrLine in try/catch so a broken
  stderr (EPIPE on a piped/closed log) can't break the "never throw on a dead
  socket" contract production callers rely on.
- ChromeToolCall: index-access rawInput['name'] to satisfy
  noPropertyAccessFromIndexSignature.

PR #5777.

* fix(serve,chrome-ext): address #5777 review round 4

- acp-http: send mcp_error back on an unexpected client-MCP handler rejection
  (register/unregister callers otherwise hang); log when the inflight cap rejects.
- cdp-ws: log the happy-path cdp_release dispatch for oncall tracing.
- run-qwen-serve/acpAgent: don't pass a bogus "0" CDP-tunnel port for ephemeral
  --port 0, and emit a stderr diagnostic when the tunnel is disabled for a
  missing/invalid port instead of failing silently.
- cdp-bridge: handle a cdp_release that races an in-flight handleAttach so a late
  attach can't leave a debugger attachment with no live /cdp client.
- service-worker: close the WS on an ACP initialize error so the daemon doesn't
  keep holding a non-functional CDP bridge.

* fix(serve,chrome-ext): address pr-review findings (#5777)

- client-mcp-sender-registry: gate the child-side runtime-server teardown on
  ownership too (Config.removeRuntimeMcpServer is not owner-scoped), so a
  disconnecting connection can't kill a server a later connection re-registered
  under the same name. (P2)
- service-worker: carry the bearer token via the `qwen-bearer.*` WS subprotocol
  (matching the web-shell + daemon decoder) instead of a `?token=` query the
  daemon never reads, so a token-gated daemon no longer 401-reconnect-loops. (P3)
- run-qwen-serve: advertise client_mcp_over_ws / cdp_tunnel_over_ws in the
  bootstrap /capabilities too, matching the runtime path. (P3)

* chore(webui): remove unused ChromeToolCall component (#5777)

ChromeToolCall was added in a debugging commit but is never wired into the
tool-call routing (getToolCallComponent) — there's no `chrome` tool kind and
the only references were barrel re-exports. Dead code; remove it.

* fix(serve,chrome-ext): address #5777 review round 5 (diagnostics)

- service-worker: log the WS close code/reason so failure modes aren't
  indistinguishable (e.g. the daemon's 1011 "no extension connected").
- acpAgent: containment-check the resolved chrome-devtools-mcp bin path so a
  malformed `bin` field can't escape the package dir.
- cdp-tunnel-registry: log when a new extension bridge supersedes a stale one.

* fix(chrome-ext,serve): address #5777 review round 6

- cdp-bridge: ack the attach (as an error) before tearing down on a
  release-during-attach, so the daemon's reverse link doesn't hang ~170s
  waiting for a cdp_attached that never arrives.
- acp-http: rename isFireAndForget -> dispatchOffQueue + clarify the comment;
  register/unregister are dispatched off-queue but still expect a response ack,
  so the name no longer reads as "no response."

* fix(serve,cli): address #5777 review round 7

- config: warn (stderr) when QWEN_SERVE_CDP_TUNNEL_OVER_WS overrides an explicit
  tools.computerUse.enabled=true, so the effective config isn't a silent surprise.
- client-mcp-ws: document the intentional idempotency of handleUnregister.
- cdp-reverse-link: add tests for the attach gate — forwardToTab parks behind an
  in-flight attach, the cdp_attach timer rejects, and the gate opens on timeout
  so commands don't hang.

* fix(serve,core): address #5777 review round 8

- mcp-client: only treat the first stdio arg as a local entry script when
  it is clearly a filesystem path. A bare includes('/') also matched scoped
  npm package names (npx @scope/pkg), wrongly resolving them under the
  workspace and throwing before the runner ran. Adds a regression test.
- client-mcp-sender-registry: reject shadowedSettings so a browser-hosted
  WS client cannot shadow a user-configured MCP server name; roll back the
  child-side add.
- cdp-ws: close the puppeteer socket when /cdp attach fails so dispose()
  clears cdpBound/routeInbound, instead of a stuck tunnel until restart.

* fix(serve,chrome-ext): address #5777 review follow-ups

* fix(serve): satisfy CDP inbound frame guard typing

* fix(serve): address CDP tunnel review feedback

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-06-28 15:57:31 +00:00
a1bcda6c1b feat(desktop): voice dictation in the desktop app (#5856)
* feat(desktop): voice dictation in the desktop app

Bring the `/voice` dictation feature to the desktop app (#5796), matching the
Web Shell (#5755). The renderer captures the microphone and streams raw 16 kHz
mono PCM to a loopback `/voice/stream` WebSocket in the Electron main process;
the server transcribes by reusing the CLI voice pipeline (batch + realtime) with
the qwen credentials, so provider keys never reach the renderer.

- server-core/voice: a standalone loopback WS server (separate from the RPC
  server, shares its token via the `?token=` query) + ported batch/realtime
  transcription pipeline + SSRF guard.
- Credentials resolve from the qwen CLI config: OAuth (`~/.qwen/oauth_creds.json`)
  → API key (`~/.qwen/settings.json` DashScope provider) → env.
- Renderer: a mic in the composer toolbar that swaps to a recording bar
  (waveform + timer) while dictating; transcript is inserted for review.
- Voice on/off and model are configurable in Settings → Input and the composer
  model dropdown, persisted via a `voiceEnabled` / `voiceModel` setting.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(desktop): harden voice dictation review gaps

* test(desktop): update voice ipc channel inventory

* fix(desktop): hide voice stream internals from errors

* test(desktop): cover voice websocket paths

* test(desktop): cover voice transcription paths

* fix(desktop): detect voice capture frame drops

* fix(desktop): address voice review blockers

* fix(desktop): address voice security blockers

* fix(desktop): address voice review blockers

* fix(desktop): address voice review blockers

* fix(desktop): address voice shutdown review

* fix(desktop): address voice review blockers

* fix(desktop): fix server-core voice typecheck and harden config resolution

Fix three type errors that left server-core's typecheck red:
- type the readQwenJson test mocks generically (`<T,>() => ... as T | undefined`)
  to match the real `<T>(file) => Promise<T | undefined>` signature
- replace `HeadersInit` (undefined under lib ESNext/no DOM) with
  `RequestInit['headers']`
- widen the fetch stub cast to `as unknown as typeof fetch` (the stub lacks
  `preconnect`)

Also apply small hardening to resolve-voice-config:
- normalizeBaseUrl strips userinfo so a user:pass@host base URL can't leak
  credentials into outbound requests or errors
- OPENAI_API_KEY without OPENAI_BASE_URL now throws a specific message instead
  of a generic no-credentials error
- readQwenJsonFromDisk honors QWEN_HOME for the config base dir

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(desktop): address voice review follow-ups (logging, dedupe, truncation)

- voice-ws-handler: tag connection logs with a short per-connection id so
  concurrent sessions are distinguishable; log unrecognized text frames; add
  bufferedBytes/pcmChunks context to the finalize "no session/audio" warnings.
- voice-frame-sender: fire onTooManyDroppedFrames once as the threshold is
  first crossed instead of on every later dropped frame.
- qwen-asr-realtime-session: rename `failed` -> `settled` for parity with
  voice-stream-session (the flag is set on both fail and the success path).
- voice-stream-session / qwen-asr-realtime-session: drop the redundant outer
  slice in formatServerErrorMessage so sanitize's truncation `...` indicator
  survives (credential redaction unchanged).
- voice-server: drop the dead `if (!isEnabled())` wrapper that the early
  return above already makes unconditional.
- voice-stream-session test: cover the result-generated sentence_end commit
  branch and the lastPartial reset (multi-sentence path).

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(desktop): normalize QWEN_HOME and scope voice pending-op cap to control frames

Voice config resolution read the raw QWEN_HOME env value, so QWEN_HOME=~/x or
a relative value pointed desktop voice at a different directory than the rest
of Qwen (which normalizes via core's Storage.getGlobalQwenDir) and missed the
OAuth/settings credentials. Mirror core's normalization in a local helper:
expand a leading ~, resolve a relative path to absolute, and fall back to
~/.qwen when QWEN_HOME is unset/empty.

Also scope the per-connection pending-operation cap to control frames. PCM
frames buffered behind a slow upstream connect (up to the 8s connect timeout)
no longer inflate the count cap and falsely trip "Too many pending voice
messages." — buffered audio is already bounded by queuedBytes. The drain gate
(pendingOperations) still counts every message, so close/slot-release
semantics are unchanged.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(desktop): reject credentialed voice base URLs; scope batch byte cap to batch

Address qwen-code-ci-bot review on the desktop voice ASR handler.

- resolve-voice-config: normalizeBaseUrl now throws on embedded userinfo
  instead of stripping it. For `https://[email protected]/...` the URL
  parser already resolves `evil.com` as the host, so clearing userinfo
  afterward would silently proceed with an attacker-controlled host. A
  legitimate voice base URL never carries credentials.
- voice-ws-handler: MAX_AUDIO_BYTES (~5.5-min / 10 MB) is the batch WAV/file
  cap. Streaming forwards frames immediately and is bounded by
  MAX_CONNECTION_MS (the 6-min hard timer) + queuedBytes, so counting
  already-forwarded frames toward the batch cap cut legit streams off ~30 s
  early. Enforce the file cap for batch sessions only; the batch 10 MB cap
  is unchanged.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(desktop): redact voice stream errors; drop unused qwen:// origin; re-fire voice URL retry

Address voice review on 8b19cffcf:

- Streaming session errors reached the renderer raw: the onError callback and
  the streaming open/finish paths forwarded errMessage(error) straight to
  fail(), bypassing the credential redaction the batch path applies. An
  upstream ws.on('error') can surface auth URLs / Bearer tokens, so route
  streaming error text through the same sanitizeResponseDetails redactor
  (passing the apiKey) before it leaves the server. Batch errors are already
  sanitized inside transcribeQwenAsrBatch and stay untouched.

- Remove the qwen:// scheme from the voice origin allowlist. No custom app
  scheme is registered anywhere (only craftagents deep-links and the
  thumbnail:// privileged scheme), so accepting it only let an unregistered
  same-machine scheme pass origin validation — tightened as defense-in-depth.

- Fix the voice URL cold-start retry: setWsUrl(null) is a no-op when the value
  stays null, so the [wsUrl] effect never re-fired and voice stayed unavailable
  on slow cold starts (OAuth refresh / slow disk > 1.5s). Add a bounded retry
  counter to the effect deps so it re-polls until the URL resolves.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(desktop): address voice PR #5856 review feedback

Cleanups:
- voice-stream-session: set settled in the close-handler else branch so a late
  error/close cannot re-enter and double-fire onError.
- qwen-asr-realtime-session: extract the duplicated "connection closed
  unexpectedly" message into a single module constant.
- shared/storage: inline the no-op loadStoredConfigForVoiceSettings wrapper.

Tests:
- voice-stream-retry: cover openVoiceStreamWithRetry (first-try success,
  retry-then-success, non-retryable rethrow, give-up) using fake timers.
- voice-server: extract pure classifyVoiceUpgrade for the 4 upgrade guards;
  unit-test the exact statuses and add an integration test asserting each
  guard rejects without upgrading.
- net-guard: add a DNS-lookup-failure test for assertVoiceBaseUrlNetworkAllowed.

Fix:
- useVoiceCapture: treat a graceful 1000 close as normal completion instead of
  surfacing it as a connection error.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(desktop): order voice upgrade token check before disk read; settle realtime close

Address two voice PR #5856 review notes:

- classifyVoiceUpgrade: check the voice token before the disk-reading
  isEnabled guard, so an upgrade with a wrong/missing token is rejected
  without a loadStoredConfig() disk read (a pre-auth wasted-work surface).
  New guard order: path -> token -> disabled -> origin.
- qwen-asr-realtime-session: set settled=true in the ws 'close' handler's
  terminal branches, mirroring voice-stream-session, so a late error/close
  event can't re-enter via fail() and double-fire reject/onError.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(desktop): harden voice backpressure visibility + IPC sender guard (PR #5856)

Criticals (wenshao):
- voice-stream-session: count dropped audio frames/bytes under upstream
  (DashScope) backpressure and log the cumulative total on entry and on
  recovery. Previously frames were dropped silently, leaving invisible
  transcript gaps with no log/counter.
- electron main: validate the sender of `__get-voice-stream-url`. The URL
  embeds the loopback auth token, so only the app's own top-level renderer
  frame (file:// when packaged, the Vite dev origin in dev) may read it;
  injected/cross-origin sub-frames and stray webviews get null.

Suggestions (wenshao):
- InputSettingsPage: revert the optimistic voice enabled/model state when the
  IPC write rejects, so the UI never lies about the persisted value.
- useVoiceDictation: tick the elapsed timer at 1s instead of 100ms — the bar
  floors elapsed to whole seconds, so finer ticks were ~10x redundant renders.
- voiceModels: add `description` as the single source of truth and consume it
  in both the settings and composer pickers (drops duplicated computation).
- voice-server: gracefully WS-close clients before force-terminating on
  shutdown, so an in-flight transcript can flush instead of being dropped by a
  TCP reset; bounded by a short grace period and an absolute deadline.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(desktop): await + rollback voice model change on IPC failure

handleVoiceModelChange in FreeFormInput fired the setVoiceModel IPC and
discarded the promise (void). If that IPC rejected (handler throws,
settings file locked), the renderer kept the optimistically-selected
model while the persisted value stayed old. The voice server reads the
persisted model per recording (resolveDesktopVoiceConfig), so the
dropdown and the actual ASR model silently diverged.

Await the IPC and roll back the optimistic setVoiceModel on failure,
matching the established pattern in InputSettingsPage. Failures are
reported through the existing logInputError helper.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(desktop): report voice backpressure totals at session end; log graceful shutdown

Address ci-bot review on the desktop voice PR.

- voice-stream-session: surface the cumulative dropped-frame total exactly once
  when a session ends. droppedFrames/droppedBytes were warned only on
  backpressure enter/recover transitions, so a session that ended while still
  dropping (or after a recovery) lost the running total. A one-shot
  reportDroppedTotals() now fires from every terminal path (fail, task-finished,
  unexpected close), logging only when frames were actually dropped. Adds tests
  that a dropping session logs the total once and a clean session logs nothing
  (mutation-checked: disabling the report fails the test).

- voice-server: closeVoiceServerResources was silent during graceful shutdown,
  unlike the disabled-close path. Thread the existing Logger in and add concise
  logs matching the existing style: shutdown start with active client count,
  force-terminate of stragglers after the grace period, and shutdown complete.
  terminateVoiceClients now returns the count it terminated so the
  force-terminate log is precise. Adds a test asserting all three lines.

The reviewer's mock.module ordering concern for the backpressure test was
assessed and found non-fragile: the test registers the platform mock and then
dynamically imports the module under test (await import after mock.module), so
bun re-instantiates voice-stream-session.ts with the mock active regardless of
load order. Verified across isolation, the full voice suite, and orderings that
pre-load the module with the real logger first; all green. No refactor made.

Co-Authored-By: Qwen-Coder <[email protected]>

* test(desktop): cover voice dropped-totals fail/close paths; clarify recovery-log comment

reportDroppedTotals() runs from three terminal paths but only the
task-finished path was tested. Add deterministic tests for the two
untested paths and lock the idempotency guard:

- fail() path: an upstream socket error drives fail() (which sets
  `settled` before reporting); assert the totals line logs exactly once.
- ws.on('close') path: a mid-session close (which reports before setting
  `settled`, the inverse order of fail()); assert it logs exactly once.
- idempotency: a close followed by a late task-finished invokes
  reportDroppedTotals() twice — task-finished is not short-circuited by
  `settled` — so only the droppedTotalsReported guard keeps the line to a
  single entry. Mutation-verified: removing the guard double-logs;
  disabling the fail()-path report drops the fail() test's log.

Also reword the backpressure-recovery comment: droppedFrames/droppedBytes
are cumulative session counters (never reset between episodes), so the
recovery log reports the running total, not a per-episode count.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(desktop): log disabled-path straggler terminations; drop redundant default args

On the voice stream server, the disabled-grace close path force-terminated
straggling clients with no diagnostic, unlike the shutdown path which logs
the force-terminated count. Add a parity warn via a small exported
terminateDisabledVoiceClients() helper (a clean unit-test seam, matching the
existing exported close/terminate helpers) and cover it with a deterministic
test.

Also stop hard-coding CLOSE_TIMEOUT_MS / SHUTDOWN_GRACE_MS at the
closeVoiceServerResources() call site: they were only the function's own
defaults, forwarded just to reach the log param, and would silently drift if
the defaults changed. Pass undefined so the defaults apply.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(desktop): count realtime ASR dropped frames; cover frame-trust gate; sanitize status

- voice(server-core): realtime ASR session counts dropped frames/bytes under
  socket backpressure and logs the cumulative total once at session end
  (parity with voice-stream-session reportDroppedTotals; idempotent across the
  fail/session.finished/close terminal paths). Logger is injectable via deps so
  the drop totals are unit-tested deterministically.
- voice(electron): surface voice-server URL retry exhaustion instead of silently
  disabling dictation — one-shot console.warn after the retry budget plus a new
  initFailed flag on the hook return. Retry timing/behavior unchanged.
- security(electron): extract isTrustedRendererFrameUrl (the sole guard gating
  the voice token) into main/voice/frame-trust.ts and unit-test file://, dev
  origin, cross-origin, undefined, malformed, and the VITE_DEV_SERVER_URL
  fallback. Behavior preserved; index.ts imports the helper.
- voice(server-core): sanitize response.statusText with sanitizeResponseDetails
  like the body, so a hostile ASR proxy can't leak secrets via the status line.

Co-Authored-By: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-06-28 15:47:51 +00:00
易良 f876f4a250 ci: allow longer PR review timeout retries (#5961) 2026-06-28 15:41:04 +00:00
de706203c0 feat(core,cli,sdk): resume an interrupted turn without a synthetic "continue" message (#5030)
* feat(core): add continueInterruptedTurn to resume unfinished turns from history

Detect how a session's last turn ended from persisted chat history alone
(no in-memory request refs, so it works across process restarts) and
continue it without injecting any synthetic user message:

- interrupted_prompt (orphaned trailing user entry): re-submit the same
  content via SendMessageType.Retry — strip + re-push, same logical turn.
- interrupted_turn (dangling functionCall): close each pair with a
  synthesized error functionResponse submitted as a ToolResult, the same
  continuation signal a real tool completion would have sent.
- none: idempotent no-op (null stream).

Core layer for the SDK/ACP "continue previous turn" capability discussed
in #4679; protocol exposure (_qwen/session/continue) comes separately.

* feat(cli,sdk): expose continue-interrupted-turn through ACP and SDK control protocol

ACP surface:
- New vendor ext method `qwen/session/continue` (acpAgent) backed by
  Session.continueTurn(): classifies the last turn from history and
  re-drives the model loop — Retry semantics for an orphaned trailing
  user entry, synthesized error tool results for dangling tool calls.
  No new user message enters the transcript; the prompt id reuses the
  interrupted turn's number. 409 when a turn is already in flight.
- The model-call ↔ tool-call loop is extracted verbatim from
  #executePrompt into #runModelTurnLoop (+ #finishTurn epilogue) and
  shared by both paths.
- initialize advertises the capability via agentCapabilities._meta
  (qwen.sessionContinue) per ACP extensibility guidance.

SDK surface (stream-json control protocol):
- New control request subtype `continue_last_turn` routed to
  SystemController and delegated to the nonInteractive Session, which
  schedules a continuation turn ahead of queued user messages.
- runNonInteractive gains `continueInterrupted` mode: detection +
  Retry/ToolResult first-turn override; reminder injection is skipped
  so tool_result parts stay at the head of the user message.
- Query.continueLastTurn() sends the request; reply reports
  { accepted, interruption } and resumed output flows as regular
  stream messages.

Part of the #4679 continue-previous-turn work; core detection landed
in the previous commit.

* fix(core): replay the orphaned user entry verbatim, reminders included

The Retry send path does not re-inject per-turn system reminders, so
filtering them out of the captured entry made the resumed request differ
from the original (the date reminder vanished). A continuation is the
same turn, not a fresh one — capture the trailing user entry byte-for-
byte so the resumed wire request is identical to the interrupted one.
Verified end-to-end against a request-capturing mock server.

* fix(cli): re-trigger work loop for pending continue turns; cover continueInterrupted

- ensureProcessingStarted's finally-block re-trigger condition omitted
  pendingContinueTurns, so a continue request arriving as the work loop
  wound down could be stranded until the next user message. Add it to
  the guard alongside the other queues.
- Add runNonInteractive({continueInterrupted}) unit tests: Retry path,
  ToolResult (interrupted_turn) path, and the clean-turn no-op.

* fix(core,cli): restore orphaned entries when a continued send is skipped

When resuming an interrupted turn, the orphaned trailing user entries are
stripped from history before the content is re-sent. If that send is then
skipped (e.g. session token limit exceeded), the stripped content was lost
from history entirely. Have strip return the removed entries and restore
them on any non-cancelled skip across the core retry, non-interactive, and
ACP continue paths.

On the ACP path the turn loop already re-adds functionResponse parts of an
unsent message via #preserveUnsentMessageHistory, so a tool_result orphan
would be written twice (once by preserve, once by restore). Let the caller
own restoring the initial message and skip the loop's functionResponse-only
preservation for that one message when it isn't a cancellation.

* fix(cli,sdk): address continuation review feedback

* fix(cli): address continuation review feedback

* fix(cli): preserve continuation constraints and drain state

* Merge latest main into feature branch

* fix(core,cli): harden continuation recovery

* fix(cli): close continuation review gaps

* chore(cli): restore acp bridge path mapping

* fix(cli): reject continuation during background turns

* refactor(cli): simplify continuation handling

* fix(cli): harden interrupted turn continuation

* refactor(cli): share continuation helper logic

* fix: harden interrupted-turn continuation

* chore: remove unrelated continuation diff churn

* refactor(cli): drop ACP session-continue surface from PR #5030

Issue #4679 only needs the SDK/stream-json continuation path. The SDK's
Query.continueLastTurn() rides the stdio control protocol handled by the
nonInteractive session, not ACP, and the qwen/session/continue vendor
method had no SDK caller. Revert the ACP continuation surface
(Session.continueTurn, #executeContinue, the qwen/session/continue routing
and capability advertisement, and their tests) to the merge-base so the PR
ships only the path the issue requires.

* refactor(core): rename turnInterruption to kebab-case turn-interruption

check-file/filename-naming-convention requires kebab-case for new files
(AGENTS.md), so turnInterruption.ts failed CI lint. Rename the module and
its test and update the index re-export.

* fix(core,cli): delegate interrupted-prompt continuation to the Retry path

The continuation path duplicated the orphan strip/restore that the Retry
send in client.ts already performs, and its restore guard flipped on
pre-push stream events, so an abort before the history push could
permanently drop the orphaned user entry. Delegate to the Retry
strip/restore, whose guard flips only after the content is committed.

Removing the local strip drops the now-dead maxEntries parameter from
stripOrphanedUserEntriesFromHistory, and detection now reads the full
history (getHistory) so its re-submitted parts match the unbounded strip.
Also fire onResultEmitted only after a successful emit, so a throwing
emitResult cannot lose both the result and the error.

* test(cli): cover insertAfterFunctionResponses ordering

Add direct unit coverage for the splice helper used by the continuation
reminder insertion: mixed parts, all-functionResponse input, empty
additions, and empty parts.

* feat(cli,acp,sdk): expose continue-last-turn to daemon + ACP and fix continuation review findings

Surface the interrupted-turn continuation through two more entry points, both
routing into a new Session.continueLastTurn() that reuses the core
detectTurnInterruption classification and the existing send/tool loop:

- daemon: new control method qwen/control/session/continue
  (status -> bridgeTypes -> bridge -> POST /session/:id/continue -> acpAgent
  handler), returning { accepted, interruption }.
- ACP: a `qwen.daemon.continueLastTurn` prompt _meta flag, mirroring the
  existing daemon retry meta. interrupted_prompt re-submits the stripped
  orphaned run; interrupted_turn closes dangling tool calls with synthesized
  error functionResponses. System reminders are now inserted after any leading
  functionResponse parts so a tool-result continuation keeps tool_result
  blocks first (normal prompts unchanged).

Also resolve the open continuation review findings:

- core/client: history-length snapshot guard so a Retry send that fails before
  the first event never duplicates history (replaces the retrySendStarted flag
  that missed pre-event failures).
- cli/nonInteractive/session: emit a terminal error result when an accepted
  continuation is abandoned by shutdown; emit a continue_turn_failed diagnostic
  when a continuation fails after a result was already flushed.
- sdk/query: idempotent finishTransportRead with optional error, also invoked
  on the message-router error path, so a transport crash rejects pending
  control (continueLastTurn) and MCP requests instead of hanging.

Adds regression tests across core/cli/sdk for each fix and the new surfaces.

* test(sdk): cover finishTransportRead MCP-response rejection on teardown

Addresses the review ask to validate that finishTransportRead rejects pending
MCP responses when the transport read loop ends. White-box: injects a pending
entry rather than standing up a full SDK MCP server handshake, since the
rejection drain is map-internal regardless of how the entry was registered.

* fix(acp): harden continueLastTurn — restore stripped orphan on send failure, fix ordering and error surfacing

Review follow-ups on the ACP/daemon continuation path:

- interrupted_prompt: preserve the full stripped orphan message back into
  history when a continuation send fails non-cancelled. Previously only
  functionResponse parts were kept, so a stripped text orphan was lost — the
  user's unanswered turn vanished from history.
- worktree restore notice: insert after any leading functionResponse parts so a
  tool-result continuation keeps tool_result blocks first (Anthropic ordering),
  matching the system-reminder insertion.
- continueLastTurn fire-and-forget: emit a client diagnostic on failure instead
  of log-only, so a failed continuation is visible rather than silently stalled.
- log at warn when the in-prompt re-detection finds no interrupted turn.

Adds a Session regression test asserting the orphan is preserved on a
non-cancelled continuation send failure.

* perf(acp,core): classify continuation from a bounded tail; O(1) retry length read

Review follow-ups (all non-blocking):

- continueLastTurn classifies from a shallow bounded tail
  (getHistoryTailShallow) instead of structuredClone-ing the entire history.
  The authoritative re-detection inside the fired prompt() still reads full
  history for the strip, matching the nonInteractive two-tier pattern.
- the Retry history-length guard reads getHistoryLength() (O(1)) instead of
  getHistory().length, which structuredClones the whole history just to read
  .length (the truncateHistory path already warns about this).

Adds a Session test for the pendingPrompt re-entrancy guard (continueLastTurn
rejects while a prompt is in flight).

* fix(acp-bridge): strip daemon continue meta key from external prompts

An external POST /session/:id/prompt could set
_meta['qwen.daemon.continueLastTurn'] and trigger a continuation through the
prompt FIFO, bypassing continueLastTurn()'s admission guards. sendPrompt now
strips it alongside the retry meta key; the legitimate path is the agent's
internal Session.prompt() and does not go through sendPrompt.

* fix(cli): resolve continuation review comments

- Session.ts: reuse the shared insertAfterFunctionResponses helper for both
  the reminder and worktree-notice phases (removes the duplicated findIndex
  pattern) and guard against duplicate reminders on an interrupted_prompt
  continuation, mirroring nonInteractiveCli.ts. Adds an ordering test.
- systemController.ts: point the missing-callback error at the wiring
  (onContinueLastTurn on ControlContext) instead of 'not available'.
- nonInteractive/session.ts: tag the continue logs with sessionId.

* fix(core): gate retry restore on a push counter, not history length

The Retry strip/restore guard compared history length to a post-strip
baseline to decide whether the re-submitted user content landed. Auto-
compression inside GeminiChat.sendMessageStream runs before the push and
shrinks history independently of it, so the length could fall below the
baseline even after a successful push, restoring the stripped entries and
duplicating the user's prompt (reachable on success once compaction removes
>=2 entries, common near the token limit).

GeminiChat now exposes a monotonic userContentPushCount (incremented when the
user content is pushed, decremented only if that push is rolled back on a
setup error). client.ts snapshots it after the strip and restores only when it
did not advance — invariant under compression.

* fix(sdk): log pending requests when the transport finalizes

finishTransportRead rejected pending control/MCP requests with no anchor log,
so a CLI crash mid-continuation left only scattered rejections. Emit one
correlatable line with the pending counts and error when work was in flight.

* fix(serve): track daemon continuation through the prompt-admission path

POST /session/:id/continue ran the continuation as an untracked internal
agent prompt (continueLastTurn fired Session.prompt() fire-and-forget via the
sessionContinue control method). The bridge never set pendingPromptCount /
promptActive / activePromptOriginatorClientId for it, so the daemon reported
the session idle during a continuation, a racing POST /prompt was admitted with
no FIFO wait and aborted the in-flight continuation, and the continuation's
events went out unattributed.

Now continueLastTurn is the accept/reject pre-check only; when it accepts,
bridge.continueSession drives the actual turn through sendPrompt with a trusted
continue flag (re-arming the continue meta key that sendPrompt strips from
untrusted callers), so the continuation gets full admission tracking, FIFO
serialization, originator attribution, and turn-complete broadcasting. The
/continue route forwards X-Qwen-Client-Id and continueSession validates it
up-front, matching POST /session/:id/prompt. The {accepted, interruption}
response contract is preserved.

Addresses the continuation-lifecycle review finding.

* test(serve): cover continuation admission tracking end-to-end

- bridge: assert a continuation reports the session BUSY (hasActivePrompt +
  pendingPromptCount) and that a racing prompt queues behind it via the FIFO
  instead of aborting it — the two behaviors the lifecycle fix targets.
- serve: assert POST /session/:id/continue forwards X-Qwen-Client-Id to
  continueSession so the continuation turn is attributed like a prompt.

* test(serve): add real-daemon smoke for POST /session/:id/continue

Exercises the full route → bridge → agent continuation path against a spawned
daemon: a fresh session has no interrupted turn, so /continue returns
{accepted:false, interruption:'none'} (model-free, like the prompt-admission
checks). Also fixes the now-stale DAEMON_CONTINUE_META_KEY comment to reflect
that continueSession (not continueLastTurn) re-arms the meta via sendPrompt.

* refactor(acp-bridge): drop dead AbortController in continueSession; harden reject test

- continueSession passes undefined instead of a never-aborted AbortController to
  sendPrompt (continuations cancel via the cancelSession route, not this handle).
- The 'rejected continueSession does not dispatch' test waits long enough for a
  regression fire-and-forget to reach the agent before asserting none ran,
  instead of a single microtask flush.

* fix(cli): restore orphaned turn when a continuation send throws

The interrupted_prompt continuation strips the orphaned user run from history
before re-sending it. #preserveUnsentMessageHistory only restores on a graceful
null-stream result, not when the send throws (e.g. a hard-rescue compression
error before the user-content push) — so the orphan was permanently lost
(transcript gap; --resume loads history missing the user's turn).

Capture the stripped entries + a GeminiChat push-count snapshot, and restore
them in the send-loop catch when the count did not advance (mirrors the core
Retry restore in client.ts) — so a later tool-loop failure, where the orphan
already landed, does not double-restore. Also emit conversation_finished on the
no-op continuation early-return, which sits before the send-loop try/finally.

* fix(serve): surface continuation admission failures + mirror prompt replay contract

continueSession previously swallowed post-accept admission failures (queue-full,
pre-aborted, session reaped) yet returned accepted:true, leaving SDK callers
waiting for output that never arrives. Now it dispatches sendPrompt
synchronously so an admission throw propagates (the caller gets an error, not a
false accept); only post-admission turn failures are logged (now via
teeServeDebugLine, so they reach the SSE diagnostic stream).

It also mirrors POST /session/:id/prompt's replay/correlation contract: capture
lastEventId + accept a route-generated promptId and return both on accept, so a
client attaching the SSE stream can replay missed events and correlate
turn_complete/turn_error. Adds tests for the combined retry+continue meta strip,
nonexistent-session and async-failure error paths, and the new contract fields.

* chore(core): log retry-restore decision for diagnostics

restoreStrippedRetryEntries mutates history in a finally with no trace; a single
debugLogger.info on the restore branch makes a future push-counter regression
(silent duplicate/loss) diagnosable.

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-06-28 13:24:06 +00:00
jinyeandQwen-Coder 60cf2556df refactor(cli): Remove serve bridge re-export shims (#5955)
* codex: address PR review feedback (#5937)

Co-authored-by: Qwen-Coder <[email protected]>

* refactor(cli): remove serve bridge re-export shims

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#5955)

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-06-28 13:20:44 +00:00
易良 3ce46949c2 fix(core): halt repeated shell inspection variants (#5944)
* fix(core): halt repeated shell inspection variants

* test(core): cover shell-stagnation streak reset, fix guard-count doc

Address review on #5944: the checkAlwaysOnSafeties JSDoc still said it
enforces two guards after the shell inspection-command stagnation guard
was added, so correct it to three.

Add a regression test proving a non-inspection tool call resets the
shell-stagnation streak to zero, which is the guard's main false-positive
defense and was previously untested.

* fix(core): exclude write-bearing git chains from stagnation bucket

isGitInspectionCommand matched if any segment of a shell chain was a git
status/diff/ls-files, so a productive chain like
`git add . && git status && git commit` was classified as read-only
inspection. Eight such commands would falsely trip the always-on
shell-stagnation guard.

Require every segment of the chain to be a read-only git inspection
before bucketing. Mixed chains that also write fail open (non-inspection),
the safe direction for an always-on halt. The #4695 loop is still caught:
its compound case is status && diff, both read-only.

* fix(core): halt repeated shell inspection variants

* fix(core): treat path-specific git diffs as progress

* test(core): cover shell stagnation guard boundaries

* test(core): address shell stagnation review feedback

* docs(core): sync loop guard comments
2026-06-28 13:08:19 +00:00
Shaojin Wen 680f523853 ci(review): increase PR review timeout from 90 to 120 minutes (#5959)
Large PRs with extensive diffs across multiple packages can exceed
the 85-minute effective review window (90 - 5 buffer). The recent
run #28318973107 timed out on PR #5777 which touched acp-bridge,
serve, config, and chrome-extension packages.

Bump both the job timeout-minutes and the validation cap to 120 so
the review agent has enough headroom for complex multi-package PRs.
2026-06-28 12:53:48 +00:00
Zqcand俊良 8daeb5b1f9 feat(core): add configurable auto-compact threshold and Stop hook context usage (#4025) (#5868)
* feat(core): add configurable auto-compact threshold and Stop hook context usage (#4025)

Add two features requested in issue #4025:

1. Configurable auto-compact threshold via settings.json
   - Add context.autoCompactThreshold setting (0-1, default 0.7)
   - Extend computeThresholds(window, pct?) to accept optional pct parameter
   - Wire all 4 call sites (chatCompressionService, geminiChat, contextCommand, useContextualTips)
   - Large windows (>110K) dominated by absolute branch, custom threshold mainly affects small windows

2. Stop hook stdin payload includes context usage data
   - Add ContextUsageData interface and buildContextUsage helper
   - Extend StopInput with context_usage, context_limit, input_tokens fields
   - Wire 3 callers (Session.ts, client.ts, config.ts)
   - Enables hook scripts to observe context usage and suggest compact strategies

* fix(test): add getAutoCompactThreshold mock to geminiChat.test.ts, add NaN guard to buildContextUsage

* fix(review): address round 4 findings — schema constraints, Partial<ContextUsageData>, buildContextUsage validation

* docs: add context.autoCompactThreshold and Stop hook context usage fields documentation

* fix(review): add contextWindowSize fallback, pct clamp, doc accuracy, threshold propagation test

* fix: correct warn value in contextCommand test comment

* test(chatCompressionService): fix misleading pct=1 test name and assertion

* fix(chatCompressionService): prevent negative warn threshold for low pct values

* docs(chatCompressionService): update JSDoc warn formula to include max(0, ...) floor

* test(chatCompressionService): add pct clamping tests and fix NaN handling

Add tests for out-of-range pct values (-0.5, 1.5, NaN) to verify
computeThresholds clamping behavior. Fix implementation to use
Number.isFinite() check so NaN falls back to DEFAULT_PCT instead
of propagating through Math.max(0, NaN) which yields NaN.

* test(config): add MCP Stop dispatch validation tests

Add tests for buildContextUsage runtime validation in MCP Stop dispatch path:
- Valid numeric inputs produce correct ContextUsageData
- Missing/undefined fields return undefined
- String values rejected by Number.isFinite validation
- Negative values return undefined

Also add Number.isFinite check for contextWindowSize in buildContextUsage
to properly validate MCP input types at runtime.

* fix(chatCompressionService): fix TypeScript type narrowing for pct parameter

Use explicit undefined check before Number.isFinite to properly narrow
the number | undefined type in the ternary expression.

---------

Co-authored-by: 俊良 <[email protected]>
2026-06-28 10:17:57 +00:00
jinye 7f71a8512d docs(daemon): refresh daemon docs for recent PRs (wave 2) (#5954)
* docs(daemon): update developer docs for recent daemon PRs

- Add Last-Event-ID client reconnect guide (10-event-bus, 13-sdk-daemon-client)
- Add cross-connection vote routing section (04-permission-mediation)
- Add new capability tags: daemon_status, workspace_permissions, workspace_trust,
  workspace_github_setup, workspace_voice, workspace_voice_transcription,
  voice_transcribe (11-capabilities-versioning)
- Add new event types: trust_change_requested, github_setup_completed,
  extensions_changed, mid_turn_message_injected (09-event-schema)
- Fix _meta.serverTimestamp source description (09-event-schema, 10-event-bus)
- Fix async function* syntax in SDK example (13-sdk-daemon-client)
- Sync event/capability counts across all docs (43->47 events, 67->75 tags)

* docs(daemon): add workspace remember design doc (PR #5884)

Design document for the sessionless workspace remember API proposed in
PR #5884. Covers API endpoints, task lifecycle, implementation details,
events, error handling, and SDK integration.

Status: Proposed (not yet merged).
2026-06-28 08:36:30 +00:00
qwen-code-ci-botandgithub-actions[bot] 8b65a555a0 chore(release): v0.19.3 (#5952)
* chore(release): v0.19.3

* docs(changelog): sync for v0.19.3

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-28 06:59:17 +00:00
VectorPeakandqwen-code-dev-bot 877e8ab777 fix(desktop): normalize source slug validation errors (#5911)
* fix(desktop): normalize source slug validation errors

* fix(desktop): address source slug review feedback

---------

Co-authored-by: qwen-code-dev-bot <[email protected]>
2026-06-28 04:53:41 +00:00
Yufeng He 277673794a fix(core): isolate Anthropic SDK abort listener leak with per-request child controllers (#5946)
The Anthropic SDK's fetchWithTimeout (core.mjs) registers an 'abort' listener on the caller-provided signal and never removes it, the same leak #4810 isolated for the OpenAI generator. Both generateContent and generateContentStream passed request.config?.abortSignal, typically a long-lived session/turn signal, straight to client.messages.create (and the streaming path also to the empty-stream fallback probe), so every request left a dead listener behind and they piled up over a session.

Wrap the caller's signal in a per-request child (createChildAbortController) on both paths, hand the child to the SDK and the fallback probe, and abort it once the request completes or the stream is drained or abandoned. The leaked listener then lands on a short-lived child instead of the caller's signal, and the child's own parent listener is removed on cleanup. This mirrors the OpenAI pipeline fix (execute and executeStream).

Adds regression tests that drive five requests over one caller signal (streaming and non-streaming) and assert no abort listeners remain on it, plus a test that a caller abort still propagates to the per-request child.
2026-06-28 03:07:11 +00:00
ytahdnandytahdn 8f1118a179 feat(web-shell): add 'voice' to ComposerToolbarAction for external visibility control (#5947)
Co-authored-by: ytahdn <[email protected]>
2026-06-28 03:06:29 +00:00
ytahdnandytahdn c458830db4 feat(web-shell): add mobile responsive view for TodoPanel (#5948)
- Desktop (>=600px): show full text 'Step 3 / 8' or '第 3 / 8 步'
- Mobile (<600px): show compact text '3 / 8' with progress ring only
- Add todo.stepFraction i18n key for both EN and ZH
- Reduce summary padding on mobile for better space usage

Co-authored-by: ytahdn <[email protected]>
2026-06-28 02:56:31 +00:00
carffuca 0dc631b631 feat(web-shell): add error boundaries so a render crash can't white-screen the embed (#5943)
web-shell ships as an embeddable React component, so an uncaught render
error anywhere in the tree (Markdown / KaTeX / Mermaid / a tool panel)
unmounts the whole React root and blanks the host page. Add two layers of
isolation plus a generic boundary:

- ErrorBoundary (components/ErrorBoundary.tsx): generic class boundary with
  reset-on-key recovery, so a streamed/edited/retried subtree recovers on its
  own while a stably-broken one stays on the fallback without looping.
- Message level: each MessageItem body is wrapped, so one bad message degrades
  to an inline notice instead of taking down the transcript. The notice is
  role-aware (right-aligned for user messages, left for assistant) so it still
  reads as the right turn and doesn't blur turn boundaries.
- Top level: the WebShell lib entry and the standalone main.tsx wrap <App> in a
  self-contained RootErrorFallback (no provider/theme/i18n dependency, inherits
  host text color) offering a retry, so an App-level crash shows a recoverable
  surface instead of a blank page.

Adds message.renderError strings (en + zh-CN) and unit tests covering the
boundary mechanism and the root fallback + retry path.
2026-06-28 01:12:42 +00:00
Yufeng He a6ea33f203 fix(serve): reject non-positive sessionRecapAwayThresholdMinutes values (#5945)
* fix(serve): reject non-positive sessionRecapAwayThresholdMinutes values

Fixes #5680. The away threshold is a duration in minutes, so 0 or a negative value is meaningless and is effectively ignored at runtime, leaving the persisted value out of sync with what takes effect. Bound it to minimum 1 in the settings schema (the ACP schema already uses min: 1), mirroring #5906 which added minimum: 0 to cleanupPeriodDays. Add workspace-settings API tests covering reject (0/-1) and accept (1/5).

Signed-off-by: Yufeng He <[email protected]>

* chore(schema): regenerate settings.schema.json for sessionRecapAwayThresholdMinutes minimum

---------

Signed-off-by: Yufeng He <[email protected]>
2026-06-28 01:11:45 +00:00
lcheng c7020907c1 fix(core): preserve the selected model when re-applying a provider install plan (#5835)
* fix(providers): preserve user-selected model on re-install/re-auth (#5819)

* test(cli): add getValue stub to settings mocks broken by install-plan fix

* test(cli): add getValue stub to settings mocks broken by install-plan fix

* fix(providers): address review feedback on preserve-model PR

- fix stale model.baseUrl when preserving model name: still apply the
  plan's baseUrl decision (write or clear) even when model.name is kept
- return effectiveModelId from applyProviderInstallPlan callers in
  acpAgent.ts and run-qwen-serve.ts by reading the adapter's post-apply
  model.name value instead of plan.modelSelection?.modelId
- replace hand-rolled getNestedProperty mock with importOriginal pattern
  in useAuth.test.ts and useProviderUpdates.test.ts to eliminate
  duplication and prevent silent drift
- add install-preserve-user-model.test.ts to the diff so reviewers can
  run automated confirmation without dropping an external file

* fix(providers): address review feedback on preserve-model PR

- fix stale model.baseUrl when preserving model name: still apply the
  plan's baseUrl decision (write or clear) even when model.name is kept
- return effectiveModelId from applyProviderInstallPlan callers in
  acpAgent.ts and run-qwen-serve.ts by reading the adapter's post-apply
  model.name value instead of plan.modelSelection?.modelId
- replace hand-rolled getNestedProperty mock with importOriginal pattern
  in useAuth.test.ts and useProviderUpdates.test.ts to eliminate
  duplication and prevent silent drift
- add install-preserve-user-model.test.ts to the diff so reviewers can
  run automated confirmation without dropping an external file

* style: fix indentation in run-qwen-serve.ts installAuthProvider

* fix(providers): use isSameModelIdentity for model retention check and fix acpAgent test mock

* fix: remove duplicate baseUrl in acpAgent providers/connect response

* fix(test): preserve settings reference in acpAgent createLoadedSettingsAdapter mock

* test: add coverage for planOffersCurrentModel and effectiveModelId

* fix(test): fix TypeScript cast in acpAgent preserved model test

* fix(serve): read effective model/baseUrl from adapter in installAuthProvider

* style: fix prettier formatting
2026-06-28 00:44:11 +00:00
tt-a1i 714513df20 feat(channels): register Telegram bot command menu (#5919) 2026-06-27 22:53:08 +00:00
34678cfbd9 Upgrade GitHub Actions to latest versions (#3683)
* Upgrade GitHub Actions to latest versions

Signed-off-by: Salman Muin Kayser Chishti <[email protected]>

* ci: restore ratchet annotations on upgraded actions

The action version bumps had replaced '# ratchet:owner/repo@<ver>'
annotations with bare '# vX.Y.Z' comments, which silently breaks
ratchet's ability to track and update these SHA pins. Restore the
annotations on every changed line (updated to the new major), matching
each line's pre-existing convention on main.

---------

Signed-off-by: Salman Muin Kayser Chishti <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
2026-06-27 17:03:32 +00:00
jinyeandqwen-code-dev-bot fa8b2ad87a feat(acp): support /cd command in ACP sessions (#5903)
* feat(acp): support /cd command in ACP sessions

Implements the server-side /cd command for ACP multi-session architecture:

- Infrastructure: ext-method constant, bridge types, CdWhilePromptActiveError
- Core: skipProcessChdir option in Config.relocateWorkingDirectory()
- ACP child handler: trust check, fs validation, sandbox guard
- Bridge: promptQueue-serialized changeSessionCwd with event publishing
- HTTP route: POST /session/:id/cd with error-to-status mapping
  - directory_not_found → 400
  - directory_not_trusted → 403
  - restrictive_sandbox → 403
  - session_busy (CdWhilePromptActiveError) → 409

SDK client wrappers and capability registration are deferred to a follow-up
PR once the server-side implementation is validated end-to-end.

Closes #5677

* qwen: address PR review feedback (#5903)

- [Critical] Thread skipProcessChdir through prepareSessionArtifactMigration
  to prevent corrupting shared daemon process.cwd() on rollback in ACP mode
- [Suggestion] Raise cd timeout to Math.max(initTimeoutMs, 30_000) for
  large-project fs ops and memory refresh
- [Suggestion] Move errorKind-to-HTTP mapping (restrictive_sandbox,
  directory_not_found, directory_not_trusted) into sendBridgeErrorImpl;
  cd route catch block now matches /branch and /fork pattern

* fix(acp): address review feedback on /cd implementation (#5903)

Critical fixes:
- Do not overwrite entry.workspaceCwd — session stays listed under the
  daemon's bound workspace; logical cwd lives in agent Config only
- Queue tail tied to raw extMethod settlement, not timeout — subsequent
  prompts cannot run against unknown cwd state after caller timeout
- Add realpath equality check for skipProcessChdir path to guard against
  TOCTOU symlink swaps between trust check and state update
- Skip artifact migration for ACP /cd (skipArtifactMigration option) —
  storage stays at bound workspace so branch/load/lifecycle remain consistent

Suggestions addressed:
- Add response shape validation for extMethod result
- Rename CdWhilePromptActiveError HTTP code from 'session_busy' to
  'cd_while_prompt_active' (avoids collision with SessionBusyError)
- Add null-byte path validation in ACP sessionCd handler
- Add operator logging on successful cwd change (warnings included)

* chore: revert unrelated cua-driver formatting, add to .prettierignore

Reverts 23 cua-driver files that picked up Prettier formatting noise
during merge conflict resolution. Adds packages/cua-driver/ to
.prettierignore since it's a vendored package whose content should not
be auto-formatted by local hooks.

---------

Co-authored-by: qwen-code-dev-bot <[email protected]>
2026-06-27 14:47:40 +00:00
pomelo 8b5cb01a33 docs: add provider preset governance policy to CONTRIBUTING.md (#5631)
Define Tier 1 qualification criteria (affiliation disclosure, operational
maturity, organic user demand, data/security transparency, maintenance
commitment) and the custom-provider default path for providers that don't
qualify. Also document the technical checklist for approved preset PRs.

This formalizes the governance stance applied to recent third-party gateway
PRs (#5060 TrustedRouter, #5625 PleumRouter).
2026-06-27 14:44:21 +00:00
jinye a93ec674e9 perf(cli): enable compile cache and defer getCliVersion for serve (#5938)
Enable Node.js compile cache (module.enableCompileCache) in the serve
fast-path so V8 bytecode is cached across daemon restarts. Start
getCliVersion() as a non-blocking promise and resolve it in parallel
with the runtime module loads inside buildRuntime(), removing it from
the pre-listen critical path.
2026-06-27 14:19:29 +00:00
tt-a1i 865581b559 feat(cli): show scheduled task count in footer (#5921) 2026-06-27 14:15:45 +00:00
2583b5c7dd feat(cli): add /model --vision for a fallback vision model (#5778)
* feat(cli): add /model --vision for a fallback vision model

Add `/model --vision <id>` and an interactive vision-model picker to
configure an image-capable model the vision bridge borrows when a
text-only main model receives an image, mirroring the `/model --fast`
pattern (flat `visionModel` setting + runtime `setVisionModel`). An
explicit vision model takes precedence over same-provider auto-select and
may cross providers; an unreachable pin falls back to auto-select rather
than firing the bridge at a model it can't reach.

Also fixes two vision-bridge issues surfaced while verifying #5126:

- The gate read stale modalities after a runtime model switch, so an
  @image sent right after switching to a multimodal model could still
  trigger the bridge. Refresh model-derived modalities on the qwen-oauth
  hot-update paths (setModel default-model + handleModelChange) so the
  gate reflects the current model.
- The transcription was appended after a now-empty "Content from <file>:"
  header, leading the primary model to re-read the image file and ignore
  the transcript. Stand the transcription in the image's slot and tell the
  model the image cannot be read by a tool.

Closes #5597

* fix(cli): add vision model description translations

* feat(cli): warn on non-image-capable /model --vision pin; cover modality refresh

Self-review follow-ups on the /model --vision work:

- Warn (but still honor) when /model --vision or the picker pins a model that
  isn't image-capable, reusing the vision bridge's isImageCapable so the
  explicit and auto-select paths agree on what counts as vision-capable.
  isImageCapable is now exported for the CLI to share.
- Exclude fastOnly models from --vision validation so it matches the
  completion filter.
- Add a Config-level test asserting handleModelChange refreshes modalities on
  the qwen-oauth hot path (the vision-bridge gate fix that lacked direct
  coverage).

* fix(cli): translate the non-image-capable vision model warning

Add zh/zh-TW/en entries for the /model --vision picker warning shown when a pinned model isn't image-capable, matching the existing vision-string translations.

* fix(vision-bridge): stop showing the image transcription twice

The vision bridge printed the full transcription in its notice AND fed it to
the primary model, which then re-stated it — so the user read the same image
description twice (once in the notice box, once in the answer).

- The notice now shows only the egress-disclosure header; the transcription is
  fed to the primary model and surfaced in its answer, so it is no longer
  duplicated. Removes the now-unused transcript truncation / control-char
  stripping helpers.
- The bridge model is instructed to transcribe/describe, not answer the user
  request (the user's intent is carried as a focus hint, not a question), so its
  output is context for the primary model rather than a second competing answer.

* fix(cli): register vision model i18n strings and cover /model --vision

- Register 'Vision Model' / 'Select Vision Model' in en/zh/zh-TW so the
  vision picker and confirmation aren't English-only for non-English users
  (mirrors the existing Voice Model entries).
- formatNonVisionModelWarning reuses the dialog's already-registered t()
  key instead of a plain English literal, keeping both paths i18n-consistent.
- Add the missing bare '/model --vision' tests (interactive dialog +
  non-interactive current-model report), mirroring the --voice coverage.

* refactor(vision-bridge): remove dead VisionBridgeResult.transcript field

The inline transcript display was dropped in 14de33333 (it duplicated the
description already fed to the primary model), leaving `transcript` set on
the result but never read in any production path. Remove the field and its
now-redundant test — the untrusted-wrapper assertion it carried is already
covered by 'converts images to an untrusted text block on success'.

* test(cli): cover vision-model branches; dedupe fast/vision helpers

Refactor (behavior-neutral):
- modelCommand.ts: fold identical formatUnavailableFast/VisionModelMessage
  bodies into formatUnavailableAuxModelMessage(label, hint).
- ModelDialog.tsx: extract the fast/vision selection-key parsing into
  encodeAuxModelSelector(); voice keeps using the model id directly.

Tests:
- config: malformed pin (catch branch) falls back instead of throwing;
  authType-qualified visionModel binds to the matching provider only;
  setVisionModel('')/undefined clears back to auto-select.
- modelCommand: authType-scoped vision rejection message.
- ModelDialog: non-image-capable pin emits the warning in history.

* test(cli): vision-mode coverage + register --vision completion i18n key

- i18n: register the `--vision` completion description key in en/zh/zh-TW
  (was wrapped in t() with no entry, so non-English showed English).
- useModelCommand: test vision mode opens + suppresses fast, resets on close.
- modelCommand: test --vision rejects fastOnly/voiceOnly models.

Also corrects the stale "30-minute" wait-timer comment in
qwen-code-pr-review.yml (actual env timer is 10m; point to repo settings).

* fix(core): exclude primary model from explicit vision bridge selection

resolveVisionModelSelection lacked the m.id !== primary guard that the
auto-select path (selectVisionBridgeModel) already has, so pinning the
text-only primary via /model --vision would fire the bridge at a model
that cannot read images. Mirror the auto-select guard.

* chore(core): log vision bridge transcription for traceability

The transcription is no longer surfaced to the user or carried on the
result; log it at debug so a wrong primary-model answer can be traced to
a misread image vs a downstream hallucination.

* test: cover vision model helpers and vision-model dialog dispatch

Add direct unit tests for encodeAuxModelSelector (3 key shapes) and
isImageCapable (isVision/modalities/name-fallback), and a dispatch test
for the /model --vision dialog case mirroring voice-model.

* fix(cli): render vision bridge notice as a dim tip, drop doubled marker

The success/cancelled vision-bridge notice was added as MessageType.INFO
(primary-color `●` gutter) while its text also led with `🔎`, producing a
doubled `● 🔎` marker. Route success/cancelled notices to a new dim
`vision_notice` variant (secondary color, single `🔎` gutter glyph) and
strip the baked-in glyphs from the notice text so the renderer owns the
prefix. Failures keep the prominent red `✕` ERROR variant (also
de-duplicated).

* test(cli): assert VISION_NOTICE type for mid-turn vision bridge notice

* test(cli): cover vision_notice classification and rendering

* fix(vision): harden vision-bridge model selection + UX

Address review feedback on #5778:
- config: provider-aware isCurrentPrimaryModel() so a cross-provider namesake
  (e.g. anthropic:shared-model vs an openai shared-model primary) stays an
  eligible vision pin; resolveVisionModelSelection now also filters fast/voice-
  only models (a settings.json pin bypasses the slash command) and warns on
  every silent-drop path so a stale pin is debuggable.
- modelCommand/ModelDialog: reject pinning the current primary as the vision
  model at set time (ignored at runtime) instead of persisting a dead pin.
- useModelCommand: vision mode suppresses voice so the dialog can't end up in
  two specialized modes at once.
- HistoryItemDisplay: vision_notice gets 0 top margin like its notice peers.
- vision-bridge-service: log transcription metadata only (model + length),
  never the raw text — screenshots carry tokens/PII and debug logs can be
  shared in support bundles.

PR #5778.

* fix(vision): fail-closed bridge routing, ambiguity guards, i18n, settings, tests

Address the remaining #5778 review feedback:
- baseLlmClient/sideQuery/vision-bridge: add an opt-in `failClosed` to the
  per-model resolve path; the vision bridge sets it so a missing cross-provider
  credential fails the conversion instead of silently routing image payloads at
  the text-only main generator (which BaseLlmClient otherwise falls back to).
- ModelDialog: reject a vision pin that maps to multiple same-provider endpoints
  (the persisted authType:modelId can't disambiguate base URLs), mirroring the
  voice-mode duplicate guard.
- modelCommand: wrap the non-interactive vision status message in t() with
  en/zh/zh-TW locale entries.
- SettingsDialog: wire the Vision Model row into the sub-dialog dispatch so Enter
  / Right-Arrow open the picker (like fastModel).
- image-part-utils: document the multi-image drop / "don't read_file" contract.
- tests: fail-closed resolve paths, primary-guard fallback, vision initialIndex,
  authType-qualified + malformed vision selectors, failure-path image removal.

PR #5778.

---------

Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
2026-06-27 12:51:11 +00:00
ytahdnandytahdn 22389247d5 feat(web-shell): add optional session sidebar with navigation (#5931)
Co-authored-by: ytahdn <[email protected]>
2026-06-27 12:39:46 +00:00
673454839e feat(memory): add a git-shared team memory tier (#5886)
* feat(memory): add a git-shared team memory tier

Add an opt-in third auto-memory tier — TEAM — stored in the repository at
`.qwen/team-memory/` and shared with collaborators through git. The private
USER and PROJECT tiers are unchanged.

- Enabled via the `memory.enableTeamMemory` setting (default off) or the
  `QWEN_CODE_MEMORY_TEAM` env override; inactive otherwise.
- The model is taught a three-tier prompt and when to route saves to the
  shared tier (project-wide conventions, team-wide references).
- Writes to the team directory are scanned for secrets (a curated
  gitleaks-style ruleset) and hard-blocked, since the directory is committed
  to the repo and visible to everyone with repo access.
- Team writes default to 'ask' permission — not auto-allowed like the private
  tiers — so they are proposed for confirmation in the default approval mode
  and surface in the git diff for review.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(vscode): update settings schema for team memory

* fix(memory): harden team memory review gaps

* fix(memory): address team-memory PR review feedback

- secret-scanner: AWS key suffix is base62 ([A-Z0-9]), not base32
  ([A-Z2-7]) — old class missed IDs with digits 0/1/8/9; add a
  positive test for such a key.
- paths: document that isAnyAutoMemPath deliberately excludes team
  memory (security-load-bearing — team writes must stay 'ask').
- extractionAgentPlanner: name the team-memory dir in the scoped deny
  message so a denied team write is debuggable.
- prompt: spell the tier count via a number-word lookup so a future
  4th tier never silently reads "two".
- team-memory-secret-guard: debug-log matched rule IDs only (never the
  secret content) when a write is blocked.
- edit: when a blocked team write's secret already exists in the
  on-disk file, tell the user to delete the committed secret.
- docs: caveat that a directory-form gitignore (.qwen/) makes the
  !-reinclude a no-op; use the file-glob form.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(memory): harden team-memory secret guard on blocked writes and notebooks

- write-file: blocked team-memory secret write now returns an `error`
  field (INVALID_TOOL_PARAMS) so the framework treats it as a failure
  instead of a silent success, mirroring edit.ts.
- notebook-edit: scan the serialized .ipynb that hits disk through the
  team-memory secret guard before writing, blocking with the same
  `error` field; behavior unchanged for non-team-memory paths.
- store: log non-ENOENT errors in readTeamAutoMemoryIndex before
  re-throwing, so EACCES/EIO/ELOOP no longer vanish via config.ts's
  best-effort `.catch(() => null)`.
- config: emit a debug diagnostic when team memory is enabled but
  suppressed by the untrusted-workspace trust gate.

Co-Authored-By: Qwen-Coder <[email protected]>

* feat(memory): auto-generate the team memory index

Generate the team `MEMORY.md` index by scanning the saved memory files
instead of having the model hand-maintain it. The index is derived and
ordered by path, so the committed file is deterministic across machines.
This removes the git merge-conflict surface a hand-edited shared index would
have, keeps the index consistent with the files, and frees the model from the
two-step save for the team tier.

Co-authored-by: Qwen-Coder <[email protected]>

* feat(memory): opt-in git sync for team memory

Add an opt-in, best-effort git sync of team memory at session start so a
daemon can share team memory through the remote without manual git. Off by
default; enabled with `QWEN_CODE_MEMORY_TEAM_SYNC=1`.

It commits the team directory (only that path), pulls fast-forward-only, then
pushes. `--ff-only` is deliberate: it never creates a merge commit or a
conflict — a diverged branch is skipped rather than touching the working tree.
All git is best-effort and never throws, so a failure cannot break a session.
Commits can be attributed to the acting user via an optional author, for
cooperative per-user attribution on a shared daemon.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(core): close team-memory secret-guard regex + test gaps

- secret-scanner: add `_` to the two T3BlbkFJ-marker char classes so legacy
  base64url OpenAI keys with underscores in the body no longer bypass the
  scanner; extend the OpenAI positive samples with an underscore key body.
- edit: cover the pre-existing-secret branch — seed the on-disk file with a
  full detectable token, assert the distinct "already exists" message and that
  the blocked edit leaves the file untouched.
- team-paths: cover the first-ever write before .qwen/team-memory exists,
  exercising realpathNearestExisting walking up to an existing ancestor.

Co-Authored-By: Qwen-Coder <[email protected]>

* feat(core): add Google OAuth client secret and SendGrid secret-scanner rules

Extend the team-memory secret scanner with two more high-confidence,
distinctive-prefix credential patterns (both gitleaks rules):

- gcp-oauth-client-secret: GOCSPX- prefix + 24 base64url chars,
  placed next to the existing GCP AIza API-key rule.
- sendgrid-api-token: SG. + 22-char id + . + 43-char secret.

Both have a near-zero false-positive bar, matching the curated set's
existing criteria. Add a load-bearing positive sample per rule.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(memory): warn when team memory is enabled but not git-shareable

When the team tier is active, emit a one-time startup warning if its
directory is git-ignored (e.g. a directory-form `.qwen/` ignore that a
`!`-reinclude cannot escape) or there is no git root — so the tier never
silently shares nothing. Gated on the active (trusted + enabled) tier;
probes a representative file path with `git check-ignore`, and is
latched so refreshHierarchicalMemory re-runs do not re-emit it.

Closes the last open review thread (wenshao + qwen-code-ci-bot both
flagged the silent-inert case); the docs caveat already shipped.

Co-Authored-By: Qwen-Coder <[email protected]>

* feat(memory): dedup team index by description; fix empty-frontmatter parse

The team index now collapses entries that share a (normalized) description
into one line — listing the other files via "(also: …)" — so two people
saving the same shared fact don't surface as duplicates. Nothing is dropped;
every file path is preserved and the grouping is deterministic (input is
path-sorted).

Also fix a pre-existing frontmatter bug: parseFrontmatterValue used `\s*`,
which crosses newlines, so an empty value (`description:`) captured the next
frontmatter line. Bound it to horizontal whitespace.

Co-Authored-By: Qwen-Coder <[email protected]>

* feat(memory): add memory.enableTeamMemorySync setting

Team-memory git sync was env-only (QWEN_CODE_MEMORY_TEAM_SYNC). Add a
first-class `memory.enableTeamMemorySync` setting mirroring the
enableTeamMemory plumbing (settingsSchema, cli/core config, acpAgent
type/schema/default/keys/normalizer, desktop mirror), with the env var kept
as a 0/1 override. Off by default. Docs updated.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(memory): audit hardening — resilient scan, deterministic team index

From a multi-round adversarial audit of the team-memory tier:

- scan: a single unreadable memory file (EACCES, or a TOCTOU delete during
  `git pull`) no longer rejects the whole scan and wipe every memory from the
  index — per-file failures are caught and skipped (debug-logged).
- indexer: order the team index by code unit, not `localeCompare()`, so the
  committed MEMORY.md is byte-identical across machines/locales and the
  ff-only sync can't wedge on index churn.
- scan: escape the frontmatter key before building its RegExp (latent hardening).
- config: surface a skipped/failed team-memory sync at debug level instead of
  silently swallowing it.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(memory): deterministic team index cap + surface diverged sync

Round-2 audit follow-ups:

- scan: the >200-file cap selected its subset by mtime + locale basename, which
  differs per machine/locale — so a large team's committed index churned and
  wedged the ff-only sync. Team scans now cap by code-unit path (deterministic);
  private tiers keep mtime-recency (never shared, so it's fine).
- sync: a diverged branch made `pull --ff-only` (and the push) fail with no
  `skippedReason` — a silent no-op for a user who opted into sync. Now reported
  as `pull-failed` / `push-failed` and logged. Docs note pull/push are
  whole-branch, not path-scoped.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(memory): regenerate settings schema for enableTeamMemorySync

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(memory): harden team-memory git sync (reconcile-first, scoped push, safe failure)

The opt-in team-memory git sync could publish unrelated local commits, wedge a
two-writer branch, leave team files staged on commit failure, and hang session
start on a credential prompt. Harden all four maintainer-flagged criticals:

- Reconcile first: run `git pull --ff-only` BEFORE committing so the local team
  commit lands on top of upstream instead of diverging; a diverged branch is
  skipped cleanly as `pull-failed` rather than committing then wedging `--ff-only`.
- Scoped push: push only when THIS sync created the commit and the branch was
  not already ahead of `@{u}` (pre-sync), via an explicit single-branch refspec
  (`HEAD:<merge-ref>`) — never an unqualified `git push`. New `local-ahead` skip.
- Safe failure: unstage the team path when the commit fails (hook/GPG/identity)
  so a user's next manual `git commit` cannot sweep team files in.
- Non-interactive git: force `GIT_TERMINAL_PROMPT=0` + batch-mode SSH, SIGKILL on
  timeout, and a shorter timeout so sync cannot block session start on a prompt.

config refresh: rebuild the team index BEFORE syncing (so a fresh MEMORY.md is
committed/pushed, with a re-build after a pull that brought new files) and key
the shareability latch by projectRoot so a newly entered repo is re-checked.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(memory): harden team-memory index/scan/path security

Address the security/correctness criticals from PR #5886 review. Team
memory is committed to the repo and loaded into every collaborator's
system prompt, so its write/scan/classification paths are trust
boundaries.

- indexer: pass noFollow on the team MEMORY.md write and reject a
  symlinked team root, so a committed `.qwen/team-memory` (or index)
  symlink can't redirect the generated index outside the repo.
- indexer: sanitize attacker-controlled frontmatter title/description
  (strip control/zero-width/bidi chars, collapse newlines, defang
  code/link markdown, cap length) before embedding into MEMORY.md, to
  prevent prompt injection into the shared context.
- indexer: skip the rewrite when the generated index is byte-identical,
  avoiding mtime churn and no-op commit cycles.
- scan: normalize CRLF before parsing so a Windows checkout's team files
  don't silently drop out of the shared index.
- paths: resolve a dangling final-component symlink via lstat/readlink in
  realpathNearestExisting, so a `decoy -> team-memory/leak.md` (missing
  target) is classified as team and the secret scanner can't be bypassed.
- secret-scanner: bound both quantifiers in the openai-api-key T3BlbkFJ
  rule ({20,512}) to remove O(n^2) ReDoS backtracking; real keys still
  match.
- team-memory-git-status: also probe a representative topic path so a
  repo that re-includes the index but ignores topic files is flagged as
  non-shareable.
- notebook-edit: run the team-memory secret scan in validateToolParamValues
  too, for parity with edit/write-file.

Adds load-bearing tests for each fix.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(memory): sanitize team index paths and verify whole-path containment

Refines the earlier team-memory security hardening with two fixes the
first pass left open:

- sanitizeIndexPath now defangs the attacker-controlled relativePath
  rendered into the committed MEMORY.md — at BOTH the `](path)` link
  target and the "(also: ...)" dedup suffix. A git filename may legally
  contain newlines and markdown delimiters, so a raw path could inject a
  second physical line (e.g. "- SYSTEM:") and reopen the prompt-injection
  structure the patch was closing for title/description.
- rebuildTeamAutoMemoryIndex now realpath-resolves the whole team root
  and requires it to equal repoRoot/.qwen/team-memory. The prior lstat
  only inspected the leaf, so a symlinked PARENT component (e.g.
  `.qwen -> /tmp/out`) let scan/write escape the repo undetected while
  the guard believed it had rejected symlinks.

Also adds a regression test pinning the security-load-bearing invariant
that isAnyAutoMemPath excludes team paths (so team writes stay ask-gated).

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(memory): keep team index link targets addressable, not just safe

The earlier injection fix rewrote legal filename chars (`(` `)` `[` `]` and
backticks) to `_` and used that destroyed value as the ACTUAL MEMORY.md link
target. A real file `feedback/a(b).md` was emitted as `](feedback/a_b_.md)`,
which does not exist (or, worse, collides with a different real file) — the
link was injection-safe but no longer addressable.

Replace `sanitizeIndexPath` with `encodeIndexPathTarget`, a reversible
percent-encoder: every char outside an addressable allowlist (alphanumerics
and `/ . - _ ~`) is percent-encoded, so breakout chars become inert ASCII
(newline->%0A, `(`->%28, `)`->%29, space->%20, backtick->%60, ...). The target
is a single line with no `](`/`)` breakout, yet `decodeURIComponent` recovers
the exact original path so the link resolves to the real file. `/` is kept
literal so the path still works as a relative reference. Applied at both index
sites (the main `](path)` link and the `(also: ...)` suffix). The display label
(title) stays non-reversibly sanitized as before — it is not addressable.

Update the path tests to assert the emitted target percent-decodes back to the
real relative path, and add a `feedback/a(b).md` case. The newline+`]`/`)`
injection assertions remain (mutation-verified: they fail if the encoding is
dropped).

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(memory): gate team-memory sync on the index safety check

rebuildTeamAutoMemoryIndex throws when the team root is a symlink that
could redirect the committed index outside the repo, but the surrounding
.catch swallowed the error and syncTeamMemory ran independently — so a
refused symlink/escape still got git add/commit/push'd on the out-of-repo
dir, defeating the indexer's refusal. Capture whether the rebuild
succeeded and only sync when it did, so a failed safety check skips sync
entirely.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(memory): harden team-memory git sync and cover execute-time secret scan

Several robustness fixes to the team-memory git sync, plus a missing test:

- Respect a user's GIT_SSH_COMMAND: append our non-interactive batch guards
  (-oBatchMode=yes -oConnectTimeout=5) onto it instead of clobbering it, so a
  custom identity (-i), proxy jump (-J), or non-standard port is preserved.
- Skip cleanly on a detached HEAD (new `detached-head` skippedReason) instead
  of creating an orphaned commit that can never be pushed. A branch with no
  upstream still commits locally as before.
- Use SIGTERM (not SIGKILL) as the git kill signal so a timeout mid-commit/-pull
  lets git release index.lock and clean up; the non-interactive ssh guards
  already bound the network-hang case.
- docs(memory): correct the sync description to match the implementation
  (rebuild index, ff-pull BEFORE commit, push only the scoped sync commit,
  skip on divergence/no-upstream).
- test(notebook-edit): cover the execute-time team-memory secret scan (the
  full-notebook backstop), distinct from the existing validate-time test.

Co-Authored-By: Qwen-Coder <[email protected]>

* fix(memory): split security vs operational team-sync failures; per-op git kill signal

Refine the recent team-memory sync changes per review:

- indexer: throw a typed TeamMemoryRootSecurityError for the symlink/escape
  rejection so the sync gate can tell a SECURITY refusal apart from an
  OPERATIONAL IO failure. config now blocks sync ONLY on the security class;
  an operational rebuild failure (EACCES/ENOSPC/EPERM) is logged but
  self-corrects on the next rebuild and no longer permanently gates legitimate
  team sync. The security invariant (never add/commit/push an escaping root)
  is unchanged.
- team-memory-sync: pass a per-op kill signal to tryGit — SIGKILL for the
  hang-prone network/ref ops (pull/push/rev-parse/symbolic-ref; no index.lock
  to corrupt), SIGTERM for mutating ops (add/commit) so git can release locks
  and clean up. Node's execFile timeout does not escalate to SIGKILL on its
  own, so a child trapping SIGTERM could hang past the timeout.
- team-memory-sync: thread the resolved branch into resolvePushTarget so it no
  longer re-spawns `git symbolic-ref` after the detached-HEAD check.
- docs: fix a broken sentence in the team-memory git-sync section.
- tests: cover both rebuild failure classes (security -> sync skipped;
  operational -> sync still runs) and a positive gate (rebuild ok + sync
  enabled -> sync runs); assert the typed error on the indexer symlink tests.

Co-Authored-By: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-06-27 12:29:48 +00:00
jinyeandQwen-Coder 94ea0884bb refactor(cli): split serve server assembly (#5937)
Co-authored-by: Qwen-Coder <[email protected]>
2026-06-27 12:18:19 +00:00
易良 f33dd61f8a fix(core): stop repeated truncated write_file/edit retries from looping (#5934)
* fix(core): stop repeated truncated edit retries

* fix(core): default output tokens to the model limit instead of the 8K cap

The 8K CAPPED_DEFAULT_MAX_TOKENS made normal large responses (esp. file
writes) truncate, forcing a truncate->escalate round-trip and, worst case,
a retry loop. Default to the model's declared output limit instead; the
existing escalation + multi-turn recovery stay as the truncation backstop.

The 8K cap was a slot-reservation optimization. Claude Code keeps the same
cap but gates it behind a feature flag that defaults OFF for third-party
providers; qwen-code's providers are all third-party / OpenAI-compatible /
self-hosted, so matching that default-off behavior is the safe choice. The
capacity tradeoff stays opt-in via QWEN_CODE_MAX_OUTPUT_TOKENS.

Refs #5756

* fix(core): use a truncation-specific stop directive for repeated truncated writes

* docs: update max tokens configuration wording
2026-06-27 12:17:12 +00:00
ytahdnandytahdn a1f7846cd1 feat(web-shell): add manual toggle for enhanced markdown tables (#5917)
* feat(web-shell): add manual toggle for enhanced markdown tables

- Add toggleable table component with SVG icon buttons
- Default to plain table, click icon to switch to enhanced table
- Show "Copied!" feedback with checkmark for 2s after copy operations
- Add padding-right to last column to prevent icon overlap
- Update tests to verify toggle and copy feedback behavior

* fix(web-shell): update table toggle tests

* fix(web-shell): address table toggle review

* fix(web-shell): make table copy feedback strict-mode safe

* fix(web-shell): ignore stale table copy feedback

* test(web-shell): cover stale table selection copy

---------

Co-authored-by: ytahdn <[email protected]>
2026-06-27 12:10:08 +00:00
tt-a1i 91ac3c200c feat(core): warn before foreground shell timeout (#5918)
* feat(core): warn before foreground shell timeout

* fix(core): guard shell timeout promotion hint
2026-06-27 12:06:44 +00:00