Commit Graph
44 Commits
Author SHA1 Message Date
jinyeandQwen-Coder 32d22f950c feat(managed-agent): support durable structured user questions (#16433)
Connect Hosted question actions, structured answers, durable continuation, and the WebShell question form. Cover canonical response replay and pending-question recovery across Harness and Java context restarts.

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-06 09:23:44 +00:00
callmeYe 17ad9cdb04 feat(skills): add file-backed archive installation kernel (#16421) 2026-11-06 11:30:04 +08:00
Shaojin Wenandverify-round e2d3243863 fix(web-shell): express the PR watch events dependency in the channel editor (#16236)
* fix(web-shell): express the PR watch events dependency in the channel editor

The GitHub adapter rejects a non-empty prWatchEvents unless
deliverToBoundSessions is enabled, but the editor never expressed that
dependency: operators could tick watch events, press Save, and receive a
raw English daemon refusal that read as "the selection cannot be saved".

Group the bound-delivery switch and the watch-event checkboxes into one
framed panel, disable the checkboxes with an inline hint until the switch
is on, and mirror the runtime rule as a client-side validation backstop
for hand-written stored configs that carry events without delivery.

* fix(web-shell): keep stored watch selections clearable while delivery is off

Review follow-ups on the bound-session panel:

- A stored selection stays uncheckable-while-gated no more: with delivery
  off, selected events (including unknown stored values) can be cleared
  directly, while new selections remain disabled, so turning delivery off
  never strands the operator between a dependency error and disabled boxes.
- Turning the delivery switch on clears the stale requiresBoundDelivery
  error instead of leaving the instruction under the now-on switch.
- The disabled hint moves into FieldShell's existing hint slot, restoring
  the "description is the first paragraph" invariant; the hint gets an id,
  aria-describedby on the checkbox group and a persistent polite live
  region so screen readers hear why the boxes are gated.
- The render gate now carries the same github scope as the validation
  mirror, and checked-but-disabled rows keep the selected border.
- Tests: shutdown path, stale-error retirement, aria association, and a
  stored delivery-on mount; visuals scenario seeds the bound-session
  fields and captures the gated and enabled states.

* fix(web-shell): scope dependency-error retirement to the dependency message

The switch-on clear deleted the whole prWatchEvents error slot, wiping the
higher-precedence invalidOption message while an unknown stored value was
still ticked; and unticking the last selected event — the other remedy —
left the dependency alert over an empty, valid selection. Retire only the
dependency message, from both remedies, and pin the live region's
persistence (empty region mounted before the hint, same node across the
transition) plus the hint-less rendering of the pre-existing hint slots.

* fix(web-shell): retire watch-events errors by re-deriving validity

Round-4 review follow-ups, all in this PR:

- R3-1: replace the string-matched dependency clear with a per-field
  re-validation on edit, so unticking an unknown stored value retires the
  invalidOption instruction it satisfies (and a remaining valid event swaps
  the slot to the still-unsatisfied dependency message).
- R3-3: submit now scrolls the first erroring field into view even when it
  is not an advanced field; a stored watch-events refusal below the fold is
  no longer invisible.
- R3-2: pin the empty-panel guard with EN/zh absence assertions on a
  non-GitHub descriptor.
- R3-4: table-driven parity test feeds the plugin's own descriptor and
  config through both the editor mirror and the daemon validator
  (test-only devDependency, never bundled); a lone change on either side
  now fails the table.

Each fix carries a mutation witness; 286 channel tests green.

* fix(web-shell): re-derive only shown errors, in both switch directions

Round-5 review follow-ups, all in this PR:

- R4-1: refreshErrors re-derives only the error keys already on screen, so
  a remedy retires exactly the instruction it satisfies without surfacing
  validation the operator never submitted (submit stays the only population
  site), and the delivery switch re-derives in both directions so a shown
  instruction is never silently dropped. New tests pin pre-submit silence
  and shown-message survival across switch toggles.
- R4-2: narrow plugin.management once at module scope in the parity test so
  the optional contract member stays TS18048-clean if test files ever join
  the package typecheck, with a clear error instead of a load-time TypeError.
- Deferred note from the same round: pin the data-disabled styling hook on
  gated rows (dimmed) versus clearable stored rows (selected styling).

Each fix carries a mutation witness; 288 channel tests green.

---------

Co-authored-by: verify-round <verify-round@local>
2026-11-04 14:42:07 +00:00
cca888dfcc fix(core): bump MCP client and core to 2.4.0 to drop pre-bundled fast-uri 3.1.0 (#15925)
* fix(core): bump MCP client and core to 2.4.0 to drop pre-bundled fast-uri 3.1.0

The MCP client 2.0.0 pre-bundles ajv with an inlined fast-uri 3.1.0,
which dependency audits cannot see and overrides cannot reach. 2.4.0
re-bundles fast-uri 3.1.8, clearing CVE-2026-6321, CVE-2026-6322,
CVE-2026-18446, CVE-2026-75975 and CVE-2026-76172 from the published
bundle. Both packages stay exact-pinned in lockstep because the client
pins core to the same version.

Fixes #15919

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

* chore(vscode-ide-companion): regenerate NOTICES for MCP client/core 2.4.0

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

* fix(core): refuse redirects before the SDK, not only inside the fetch

2.4.0 resolves a same-origin 30x in its own layer, against
requestInit.redirect, before the compatibility fetch is called. The Agent
Plugin transport only set headers, so a configured or bearer credential was
replayed to another path on the same origin.

Ask the same question in one place and carry the answer into requestInit on
both transport paths, and pin it with a test that fails when the option is
absent.

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
2026-10-31 19:25:36 +08:00
ChiGao 0ac1fcca91 feat(cli): align OpenTUI input, editing and status lines with Ink (#14964)
* fix(cli): restore bounded OpenTUI dialogs and add migration gates

* fix(cli): preserve OpenTUI input and attachment ownership

* feat(cli): align OpenTUI status lines with Ink

* fix(cli): align OpenTUI editor and cancellation transactions

* fix(tui): harden migration cleanup and evidence after review

* test(tui): fail acceptance on unhandled errors

* docs(cli): clarify consolidated OpenTUI delivery status

* test(cli): isolate system settings in TUI parity fixtures

* fix(cli): settle OpenTUI cancellation when replay resets a session

* style(cli): format merged OpenTUI test callbacks

* test(cli): preserve visible input across session rotation

* test(cli): await cancellation before releasing held response

* fix(cli): preserve completion and escape precedence and validate status colors

* fix(cli): preserve attachment navigation with one completion

* fix(cli): display session titles in the OpenTUI composer border

* fix(cli): discard generated command work after cancellation
2026-10-22 08:22:04 +08:00
顾盼 a791e47a54 feat(cua): streamline computer use and port native reliability fixes (#14846)
* feat(cua): preload the JavaScript computer use runtime

* refactor(cua): remove the native Wayland backend

* feat(cua): move app computer use into the driver

* feat(cua): preserve background delivery and port platform fixes

* feat(cua): add explicit desktop input and browser runtime facade

* feat(cua): package the unified 0.26.0 computer use runtime

* fix(cua): finalize cross-platform native reliability

* fix(cua): isolate runtime releases from CLI versioning

* fix(cua): keep Electron out of root workspace installs

* fix(cua): synchronize consumers and async cleanup tests

* fix(browser): migrate smoke to preloaded cua runtime

* docs(cua): document MCP setup for Qwen Code Claude Code and Codex
2026-10-21 14:03:31 +08:00
ChiGao 6e6598e35c fix(cli): restore bounded OpenTUI dialogs and add migration gates (#14933)
* fix(cli): restore bounded OpenTUI dialogs and add migration gates

* fix(tui): harden migration cleanup and evidence after review

* test(tui): fail acceptance on unhandled errors
2026-10-21 10:05:14 +08:00
ytahdnand钉萁 b08e41b25e feat(web-shell): preview DOCX files across file entry points (#14868)
Co-authored-by: 钉萁 <[email protected]>
2026-10-20 22:40:46 +08:00
3745654f8a fix(channels): preserve Markdown structure when splitting messages (#14043)
* fix(channels): preserve Markdown structure when splitting messages

* fix(channels): preserve Markdown regression fixture formatting

* fix(channels): preserve Markdown chunks at budget boundaries

* fix(channels): preserve deep-list delivery and background code continuations

* fix(channels): Preserve Markdown fence source at chunk boundaries

* fix(channels): bound markdown message chunk amplification

* fix(channels): Replan after nested Markdown budget failures

* fix(channels): preserve affordable markdown within bounded plans

---------

Co-authored-by: Ethan Ye <[email protected]>
Co-authored-by: claudeethan <[email protected]>
2026-10-20 19:41:48 +08:00
72bbbd9ec2 feat(channels): add WeCom application callback support (#13976)
* feat(channels): add WeCom application callback support

* fix(channels): allow WeCom callback environment references on save

* fix(channels): harden WeCom callback admission and retries

---------

Co-authored-by: Ethan Ye <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
2026-10-20 19:41:24 +08:00
qqqysandqqqys c4f0acdef8 feat(extensions): add bounded generated HTML and JavaScript views (#14338)
Co-authored-by: qqqys <[email protected]>
2026-10-19 13:09:33 +00:00
a8eb74fdf5 fix(web-shell): polish files and trim preview resources (#14143)
* fix(web-shell): polish files and trim preview resources

* test(cli): observe pending child Hook promises

* fix(web-shell): address Files browser review findings

* fix(vscode): sync Pierre modification notices

* chore: ignore the repo-local .pnpm-store fallback directory

pnpm falls back to a project-local store at .pnpm-store/ when the global
store directory is not writable (e.g. sandboxed CI runners). It is a
regenerable cache that node_modules hard-links into; its thousands of
untracked files otherwise dirty the working tree and fail deterministic
workspace-clean verification.

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

* fix(web-shell): address Pierre preview and file display review findings

- getPreviewLanguage folds case on the extension only, so README.MD and
  config.YML highlight while whole-name detector keys (Gemfile, BUILD)
  keep their case-sensitive behavior
- formatFileName strips the full Default_Ignorable_Code_Point class so
  zero-advance Hangul fillers and variation selectors cannot desync the
  tree search projection from the rendered name
- pin the previously unwitnessed pierre-build guards: redirected Shiki
  subpaths count toward the 4 MiB budget, getModuleInfo may return null
  for redirected ids, emitted assets are skipped, and entry-module
  resolveId calls carry no importer
- pin the theme name->module mapping with exact token colors and give
  the formatFileName display sites discriminating coverage with
  repeated-space names

---------

Co-authored-by: 钉萁 <[email protected]>
Co-authored-by: qwen-code[bot] <qwen-code[bot]@users.noreply.github.com>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
2026-10-16 16:45:56 +08:00
ytahdnand钉萁 df5470e430 feat(web-shell): Add workspace file browser and previews (#13878)
* feat(web-shell): Add workspace file browser and previews

* fix(web-shell): Restore source preview CI fixtures and notices

* fix(web-shell): Address file pane review blockers

* fix(web-shell): Resolve review regressions and strengthen file checks

---------

Co-authored-by: 钉萁 <[email protected]>
2026-10-13 09:38:47 +00:00
ef38ad7753 feat(web-shell): add read-only Excel artifact previews (#13664)
* feat(web-shell): add read-only Excel artifact previews

* fix(vscode): refresh notices for Excel preview dependencies

* fix(web-shell): bound Excel merge loading and preserve preview content

* fix(web-shell): bound Excel preview work and preserve recovery

* fix(web-shell): harden Excel preview values and recovery

* fix(web-shell): include bundled ExcelJS dependency licenses

* fix(web-shell): guard column expansion and complete ExcelJS notices

* fix(web-shell): complete Excel color fallback and review guards

* test(web-shell): follow artifact filename labels in Excel previews

---------

Co-authored-by: 钉萁 <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
2026-10-13 02:43:10 +00:00
ytahdnand钉萁 f3f5e005a3 feat(web-shell): support copying selected reply content (#13833)
* feat(web-shell): support copying selected reply content

* fix(web-shell): preserve selected reply data and menu lifecycle

* fix(web-shell): keep selection copying faithful to visible content

---------

Co-authored-by: 钉萁 <[email protected]>
2026-10-12 09:43:56 +00:00
qqqysandqqqys 30582105fe feat(extensions): add interactive test failure explorer (PR2) (#13916)
* feat(extensions): add interactive test failure explorer app

* fix(extensions): lint test explorer build scripts

* fix(test-failure-explorer): keep expanded report controls reachable

---------

Co-authored-by: qqqys <[email protected]>
2026-10-12 06:07:55 +00:00
qqqysandqqqys e6ed740eb1 feat(mods): add TypeScript authoring and configuration (PR2a) (#13890)
* feat(mods): add TypeScript authoring and configuration

* fix(mods): preserve marketplace dependencies and update notices

---------

Co-authored-by: qqqys <[email protected]>
2026-10-11 12:59:02 +00:00
qqqysandqqqys 6775f24927 feat(extensions): add test failure report explorer (#13854)
* feat(extensions): add test failure report explorer

* fix(extensions): bound report snapshots before serialization

* fix(extensions): bound test report validation

---------

Co-authored-by: qqqys <[email protected]>
2026-10-11 12:58:55 +00:00
易良andQwen-Coder c468f2ab66 fix(web-shell): allow deleting queued prompts during admission (#13594)
* fix(web-shell): allow deleting queued prompts during admission

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

* fix(web-shell): keep aborted text-only prompts on the admission path

The cancelled-upload cleanup ran an unconditional throwIfAborted after
attachment handling, so a text-only submission whose signal was already
aborted rejected instead of reaching admission. Those submissions must
still be admitted so the accepted prompt id can be removed exactly, which
is the contract DaemonSessionProvider covers. Scope the early abort to
submissions that actually uploaded attachments.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmuxzdx6dcb

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-10-07 16:40:26 +00:00
933dc0a614 feat(managed-agent): admit glob in new hosted-workspace /2 profiles (#13166)
* feat(managed-agent): admit glob in new hosted-workspace /2 profiles

Hosted Sessions could not find a file the model was not told about: the
file profile has no search and the Shell profile only falls back to a
full durable dispatch per probe. Add the read-only glob tool behind
hosted-workspace-files/2 and hosted-workspace-shell/2, following the H1
precedent: the profile gates what the model is offered, the worker
admits GlobTool, and the frozen managed-runtime-tools/1 identity is
unchanged, so no Java or Broker change is needed.

The Harness validates the optional path with normalizeWorkspaceRelativePath
before acquisition; the worker pins the search to the Session's installed
context (never the mount-wide include list) and rewrites results to
Workspace-relative paths. An oversized result keeps the longest fitting
line prefix with a narrowing hint instead of the output-omitted path.
Glob is pre-approved under the default and auto-edit approval modes like
read_file.

Resolves #13030

* fix(managed-agent): contain glob pattern, links and error paths

Completes the containment boundary this PR creates by admitting glob into
the hosted-workspace /2 profiles:

- Reject a pattern that is absolute or carries a ".." segment. Glob
  resolves ".." against the filesystem and treats an absolute pattern as
  absolute, so the pattern was a second, uncontained search root.
  Compared by segment so a literal "a/..b/*.ts" stays usable.
- Compare realpaths, not lexical paths, so a symlink inside the Session
  context that points outside it is refused like the shell branch already
  does via admitsDirectory.
- Rewrite glob's error channel too. A refused or failed glob reaches the
  model and the durable record the same way its results do, so it leaks
  the Runtime host path unless it is rewritten the same way.
- Dispatch the trimmed pattern that was validated; glob treats an
  untrimmed pattern as a literal and the false negative is persisted.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuq3r0k30o

* fix(managed-agent): anchor glob root relativization and clear prettier debt

relativizeGlobText stripped the Session root with an unanchored
split/join, so a nested directory whose path repeats the root lost its
separators and two real paths fused into one that does not exist
(/s/x/s/y.ts -> "xy.ts"). Strip the root only where it begins a path
token; every privacy-boundary case is byte-identical to before.

Also normalizes the two added hunks prettier --check rejects, which the
lint gate's freshness step was short-circuiting.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuqegteq18

* fix(serve): contain hosted file reads and glob text rewriting (round 3)

- read_file/write_file/edit now realpath-contain file_path against the
  Session directory: the glob admission makes an in-context symlink
  enumerable, and without this a files-only Session could read a sibling
  Session's content through it (R3-1). The ENOENT fallback keeps a missing
  path a tool-level not-found, not a traversal accusation.
- relativizeGlobText stands down at the degenerate root '/' (the prefix is
  every path's prefix; rewriting only ate separators, including the echoed
  pattern's), and bareRoot gains the same leading boundary tokenPrefix has
  so a hit whose tail repeats the root string is no longer truncated
  mid-token (R3-2).
- Pin the anchoring invariants at the unit level — a tmpdir-rooted Session
  cannot express the repeating-root geometry through the worker route
  (R3-3).

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

* fix(managed-agent): preserve search profiles during continuation

* fix(managed-agent): judge the glob args that get dispatched, not the raw ones

Four round-4 review findings, each pinned by a test that goes red under its
own revert:

R2-3 — the pre-acquisition glob guard validated the raw `pattern` while the
value dispatched next to it was the trimmed one, so a whitespace-masked `..`
or absolute shape cleared the guard whose comment says it exists to refuse it,
and was refused only by the worker after a full durable Runtime round trip
with a message that does not name the argument. Trim first, then validate
exactly what is dispatched. Containment was never breached; the executor
applies the same rule to the dispatched value.

R4-11 — `path: null` is not `undefined`, so it fell into the non-string
refusal and was reported as an absolute-path/traversal attempt. Because
argument validation is batch-wide, that one encoding of an unset optional also
cancelled every valid sibling call in the turn. Treat `null` as the omitted
case alongside the blank string; other non-strings are still refused.

R4-9 — the `file_path` containment was gated only on "not shell", so the glob
this same diff admits inherited a rule for a parameter glob's declaration does
not have. A stray relative `file_path` on a glob call — schema confusion with
the file tools sharing the turn, or a hook that stamps the key on every call —
refused the whole search and accused the model of a traversal it never
attempted, uncorrectably, because glob names no such argument. Glob never
reads `file_path`; its own pattern and output containment is untouched.

R4-6 — the workspace activation envelope was built twice in one test file, so
a change to that request shape had to be made in two places and missing one
left half the suite posting a stale envelope silently. Keep the module-scope
pair as the single definition and alias the local names to it.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmurw1tuo1o

* fix(managed-agent): close the glob-profile containment review round

- R3-1: the file_path containment resolves the deepest existing ancestor,
  so `write_file` through an in-context symlink to a sibling Session is
  refused instead of creating the file there; a genuinely absent path
  keeps the tool's own not-found answer.
- R4-2: the Session-directory boundary now names Sessions, not mount
  contents — an escape is refused when the realpath lands in another
  INSTALLED Session's directory, while a linked dependency inside the
  same mount (node_modules/@acme/ui -> packages/ui) keeps the
  pre-containment /1 read-through behavior.
- R1-1: glob containment runs over glob's full collected set
  (ToolResult.collectedFilePaths), never the mtime display slice the
  count is not computed from; and both gates admit the brace-EXPANDED
  alternatives, so `{.,..}/**/*.md` or `{/etc,/zz}/host*` is refused
  before the search runs.
- R4-3: a left-to-right saturating-product scan bounds the brace
  expansion cost (4096 alternatives) at both gates before minimatch
  materializes anything — a ~70-byte pattern can no longer block the
  shared worker's event loop for seconds. minimatch is now a declared
  cli dependency.
- R4-1: the W1 recovery verifier accepts the hosted /2 profiles (and
  /2-shell captureBytes), so one /2 Session no longer aborts recovery of
  the whole shared storage; the W1 recovery design doc names them in
  both languages.

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

* fix(managed-agent): contain the glob walk, not just the pattern spelling

The /2 glob gates refused `..` by segment equality, so `[.][.]` and `\.\.`
spellings walked out of the Session and turned the output guard into a
host-filesystem existence oracle (existing outside file refused, missing
one answered "No files found"). The saturating-product budget also bounded
alternatives but not nesting depth or ranges, so a nested pattern overflowed
brace-expansion's recursion and `{1..100000}` stalled the shared worker.

- GlobTool gains an opt-in `containmentRoot`; the managed tool set passes the
  Session directory and glob prunes every entry whose lexical path, or whose
  parent's realpath, leaves it. Existing and missing outside paths now answer
  identically. The ordinary CLI does not set the option.
- `checkHostedGlobPattern` replaces the budget scan at both gates: at most
  1024 characters, nesting depth 8 and 64 alternatives, bounded from the
  pattern's structure before anything expands it.
- Recovery reads profiles through `isHostedWorkspaceProfile`.

Ported from #13168 (3e86d89c6d), where it was first written against this
PR's code, so the fix lives with the glob profile it protects.

* test(core): judge glob containment by reported paths, not the pattern echo

The no-match message quotes the caller's own pattern back, so asserting the
output does not contain "secret.txt" tripped on the pattern
"[.][.]/web/secret.txt" itself even though the walk stayed inside the root
and reported nothing. Assert instead on the paths a leak would have reported
- the sibling directory and the symlinked alias. Both containment legs stay
covered: neutering the lexical check fails rows 1-3, neutering the realpath
check fails rows 3-5.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmusi75ku2s

* fix(cli): Close hosted glob review gaps

* fix(cli): follow a dangling symlink leaf in managed file-tool containment

realpathDeepestExisting treated a dangling symlink as an absent path and
kept its lexical name, so write_file through an in-Session link whose
target does not exist yet (peek -> ../web/secret.txt) passed containment
and created the target in a sibling Session. Follow the link instead.

* fix(cli): keep an unresolvable sibling Session inside the boundary

ownsAnotherSessionDir skipped any sibling whose directory mount.resolve
could not answer for (deleted, unreadable, reached through a link), so
writes into that Session's place were admitted. Judge each sibling by its
joined path below the mount; fail closed when the mount root itself does
not resolve. Tests pin both this and the dangling-leaf write.

* docs(design): record the file-tool containment change for existing Sessions

The realpath containment applies to read_file/write_file/edit on every
Hosted profile. Workspace-capability workers keep linked dependencies inside
the mount readable; boot-v1 workers bound at the Session directory, so a
linked dependency outside it is now refused.

* fix(cli): Reject escaped parent glob patterns

* fix(cli): Close unresolved file boundary paths

* fix(serve): contain the path consumed by managed file tools

* fix(serve): bound hosted glob matching time

* fix(managed-agent): close the hosted containment and glob-approval findings

Five review findings on the hosted Session boundary, each pinned by a
witness that is red before its fix and green after:

- An absolute `file_path` skipped the whole realpath / sibling-Session
  containment block, because the gate that selected which paths needed
  resolving also gated the security check. Resolve only relative inputs
  (`path.resolve` hands an absolute one back unchanged) and judge every
  spelling a producer can send.
- A sibling Session bound at an ancestor of the caller's directory owned
  the whole mount, so in a pnpm-style Workspace every linked-dependency
  read was refused for as long as that sibling stayed installed. `'.'` is
  not hypothetical: it is what a Workspace selection without
  `cwd_relative` binds. An ancestor delimits no private area, so it is
  skipped.
- One sibling whose directory stopped resolving vetoed every other
  Session's reads, permanently, because the installation set is add-only.
  Decide that per binding — against the location the binding occupied —
  instead of per scan.
- `glob` was pre-approved by name on every profile, so an MCP-declared
  tool called `glob` was dispatched under `default` mode with no approval
  Action published. Pre-approve it only for the `/2` search profiles,
  which is what the design doc already said.
- A glob containment root reached through a symlink pruned the whole walk
  and answered "No files found" with no error, because the lexical arm
  judged canonicalized entries against the raw root spelling.

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

* fix(core): search contained glob roots through symlinks

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

* fix(serve): keep shared reads with stale session bindings

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

* fix(core): preserve matched paths through inward symlinks

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

* fix(serve): isolate stale sibling resolution errors

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

* fix(serve): judge mount-root Sessions against non-root sibling estates

R9-1: sibling ownership was decided by directory geometry alone, so a
Session bound at a non-root ancestor was blanket-exempt and a Session
bound at the mount root had no boundary at all — glob never consulted
the veto and non-escaping file targets skipped it.

- Exempt only a binding exactly at the mount root; a non-root ancestor
  sibling owns its whole subtree.
- A root-bound caller holds no private directory: every glob hit and
  file target is judged against non-root siblings, however spelled.
- A non-root caller's own-directory targets return before any binding
  lookup; out-of-mount escapes stay refused without the callback.
- Design doc (EN/zh-CN) states the geometric rule, the one exact
  exemption, and that install-time nesting policy stays out of scope.

Regression tests pin both halves and go red under the reverted
exemption or an escape-only veto. 795 worker tests, 865 tests across
the four affected serve suites, typecheck and focused lint pass.

* fix(serve): close the four standing Session-boundary containment findings

R10-1 — hold a `tool.build()` failure until the boundary has been judged.
Validation mutates `params` before it can fail, and the tool's own text quotes
the resolved host path, so `write_file {file_path:'../web'}` answered
"Path is a directory, not a file: <mount>/services/web" and the sanitized
refusal never fired. The model and the durable v3 record learned the mount root
and a sibling Session's real directory name.

R11-1 — apply the sibling-ownership arm to glob's *input* admission. A Session
bound at the mount root holds the whole mount as its containment root, so the
escape test alone admitted a search over a sibling's private estate and
answered, per pattern, whether that sibling holds a match, while the same
caller's `read_file` of that path was refused.

R11-2 — build the glob invocation before certifying its `path`. `unescapePath`
is not idempotent for a real name containing a backslash, so judging the
producer's raw spelling admitted `shared\ notes` while refusing `shared notes`:
one real target, two answers, and the admitted search root realpaths into the
sibling. The invocation is recorded rather than rebuilt, so the normalization
is applied exactly once, as the file arm already does.

R11-3 — make sibling ownership bidirectional. A Session installed strictly
inside another Session's directory now owns that nested estate against the
ancestor as well. A binding at or above the caller's own directory still
leaves that estate intact, one at the same directory still shares it, and an
unlocatable binding still cannot veto the caller's own estate, so the
stale-sibling contract and the mount-root exemption are unchanged.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
2026-10-06 13:11:12 +00:00
612a552959 feat(channels): add IMAP and SMTP email channel (#12939)
* feat(channels): add IMAP and SMTP email channel

* test(channels): include email in managed channel catalog expectations

---------

Co-authored-by: destire-mio <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-10-03 02:54:25 +00:00
356262d8e9 feat(agents): add A2A access and sharing for workspace agents (#12851)
* feat(agents): expose the local runtime host

* docs(agents): audit the original collaboration goal

* docs(agents): define the registered host boundary

* docs(agents): stage the remote host protocol

* feat(agents): register remote hosts

* fix(agents): repair two compile errors the new host work introduced

Running the checked-in gates against the 70 commits that landed since
192bb98cda found the cli side no longer typechecks.

`AgentHostBridge` narrowed `resumeSession` to `Promise<unknown>` and lacked
`enqueueMidTurnMessage`, which the port now requires. The same object is
handed to the dispatch port as an `AgentSessionBridge`, so both make it
unassignable there. This is the third time that type has drifted from the port
it feeds — after `getSessionStatsStatus` and `spawnOrAttach` — and the fix is
the same each time: name the real bridge's member instead of restating a
looser shape.

`config.apply` was reported possibly undefined despite an early return on
`config.error` above it. The guard reads correctly and proves nothing: the
success branch types `error` as an optional `undefined`, which never
discriminated the union, so the compiler was right and the code had been
relying on a narrowing that did not exist. It narrows on `apply` itself now,
which also survives into the transaction callback where it is used.

Also updates one assertion and one baseline for deliberate changes rather than
regressions. The persona now carries a standing identity contract in front of
the definition's prompt, so pinning the old literal pinned a shape rather than
a rule; the assertion compares an identity with blank instructions against one
with none, which is the invariant it always meant. `getAgentHostsFilePath` is
used inside store.ts, and the sweep does not count a defining file as a
caller, so it joins the other path helpers in the baseline.

Gates after: 183 assertions, both typechecks, the four sweeps, ten-process
concurrency, crash recovery, and fuzz over three seeds — all green on the
current head.

* fix(web-shell): remove unsupported agent task fields

* fix(serve): guard missing search sessions

* docs(agents): define service collaboration handoff and opt-in boundaries

* docs(agents): close the review gaps in the service-collaboration design

Verified the four §7 gaps against the current code first; all four hold. Then
folded seven findings from the review into the architecture and plan:

- §7's inventory grows from four sites to about twelve. Several were added by
  the earlier per-process work — persona at session creation, the run frame
  from `_meta`, the bridge strip/re-inject, the dispatch port's injection, the
  session title, the web-shell panel — and P0 has to gate all of them, not the
  four it knew about.
- §4 names the mechanism that actually enforces "do not trust a caller's
  runId": strip on every caller, re-inject only from daemon context, and a
  negative check on originatorClientId for the mid-turn queue. That last one
  was never written down and is fragile; P1 must give external tasks a channel
  that does not pass through `_meta`.
- §6 decides re-enable semantics. The existing crash recovery requeues a
  `running` run whose body is absent, which is exactly the re-dispatch §6
  forbids and cannot tell a crash from a gate closing. Stranded runs go to a
  distinct terminal state for a person to resume or cancel. An agent session
  resumed with the gate off is refused rather than downgraded.
- §2 notes that the caller-side token gate says nothing about remote spend,
  so P1's contract freeze must settle whether usage is reported.
- P0 gains a prerequisite in plain terms: a way to see the model's actual
  final instruction and tool list, so the off-contract can be diffed rather
  than read. Three options listed, (a) test-only capture recommended; this is
  the one item still awaiting a decision.
- scripts/audit is settled: keep the orphan sweep, the two tsconfigs and the
  rule harness, which caught two compile errors within minutes of the last
  parallel push; retire fuzz, concurrency and crash, whose one-time answers
  are already recorded.

* chore(audit): retire the one-time probes, keep the regression net

Per the successor plan §1. fuzz, cross-process concurrency and crash recovery
answered questions that do not need re-asking on every push — the file lock
holds across ten processes, a SIGKILLed holder is recovered from, 15,000
random operations held every invariant — and those answers stay recorded in
the 2026-09-08 plan. The orphan sweep, the two narrow tsconfigs and the rule
harness stay: they cost minutes and caught two compile errors and a stale
assertion within one run of the last parallel push landing.

* docs(agents): settle the P0 capture method on a test-only hook

Option (a): a capture hook registered only in test builds that records each
request's final systemInstruction, tool declarations and the originating
session sourceType, so the off-contract can be diffed by session type rather
than read. Nothing from this review remains awaiting a decision.

* docs(agents): ground §5, §8, P1 and P4 in what the code actually has

A second pass over the sections the first review skipped, each claim checked
against the branch.

§5: the local rule is already enforced — a run that stops without a closing
tool is recorded as unclosed, never as implicit success. Codex has no closing
tools, so every Codex turn ends that way; without a defined signal for 'a real
result', every Codex task would sit at unclosed forever. A mapping is proposed
for P3 to confirm.

§8: the §9.1/§9.6 re-analysis has not been done. deleteThread removes the
thread record and nothing else; what happens to that task's session
transcript is undefined, and the retire-keeps-transcripts rule needs a second
look under per-task isolation.

P1: no caller-scoped idempotency key exists anywhere — the outbox dedupes on
event id. It is one of the fields §2 defers to the first external path, and it
has to be persisted before a task is acknowledged.

P4: the host transport is a single enroll route. The take-work channel, the
lease/attempt handshake and result push-back are all new; 'reuse existing'
covers enrollment only.

* docs(agents): name the missing server-binding check, and pin the gate's resolution point

§6 says sourceType alone is not authorization and that 'server binding'
decides the privileged path. Checked against the code, that condition has no
enforcement site: routes/session.ts rejects a client-supplied agent-host type
but passes 'agent' straight through, and acpAgent.ts applies a persona after
confirming only that the roster has the agent — never that the daemon
dispatched this session. Any client with daemon access can therefore claim
sourceType: agent and receive an agent's persona and read-only tool surface
with no run booked. The thread tools still refuse for want of a run frame, but
the persona and tool surface are already handed out. Defined concretely: look
the session up as the sessionId of a claimed run, and refuse otherwise. Added
to §7's inventory as a check that must hold with the gate on as well as off,
and to P0's forge observation, which cannot be satisfied by the gate alone.

Also pins 'resolved at startup' to daemon startup. agentTeamEnabled is read
per session at Config construction; if the new gate copied that, routes and
sessions could resolve different values after a settings change. Sessions
read the daemon's resolved value. Per-workspace narrowing is scoped out of P0.

* docs(agents): make the two documents agree with each other

A read-through of both final drafts found three inconsistencies I had
introduced while folding findings in. The plan's handoff summary claimed
nothing remained awaiting a decision, while §5 of the architecture had since
gained a Codex result-mapping recommendation marked for confirmation before
P3; it now names that one item and separates it from the §9.1/§9.6
transcript re-analysis, which is undone work rather than an open decision.
§7's inventory said 'about twelve' and holds fourteen rows. And §7's header
pinned every row to 6a69c0b5bb when the last eight were checked at a later
head — one that differs from it only by documentation commits, so the
claims hold at both, and the header now says so instead of overstating the
pin.

* feat(core): capture what the model was actually asked, for the P0 off-contract

P0's whole claim is a negative — with collaboration off, nothing
collaboration-shaped reaches the model — and the successor architecture says
plainly that reading the source cannot establish it. Nothing could observe it
either: the request was assembled and sent with no point in between where its
final shape could be read. This is that point, and it comes first because
without it the gate would land with no way to show it works.

A decorator on ContentGenerator writes one JSON line per request: the final
system instruction flattened to text, the declared tool names sorted, the
session's source type and id, and the method. It forwards every call
untouched. It installs at the existing LoggingContentGenerator wrapping site,
outside it, so it sees the request as the model receives it after every other
decorator has had its say.

Test-only by construction rather than by convention: it is built only when
QWEN_CODE_CAPTURE_REQUESTS names a file, so a production run constructs
nothing and pays one env lookup. A capture that throws is swallowed —
observation must not break the thing being observed.

scripts/audit/check-request-capture.mjs exercises it against a fake
generator: no wrapping without the env var, the request reaches the inner
generator unchanged, instruction and tool names recorded, structured
instructions flattened, streaming captured too, countTokens forwarded rather
than eaten by the decorator, and a throwing capture leaving the turn intact.
15 assertions.

Calibrated, not just green: dropping tool-name collection turns the tool
assertion red, and wrapping regardless of the env var turns the no-op
assertion red. Restoring each returns 15/15.

Not run: build, repo typecheck, vitest. Lint is clean and the subsystem
typecheck reports nothing for either changed file.

* feat(agents): gate collaboration behind an opt-in and bind agent sessions to runs

P0 of docs/plans/2026-09-09-agent-service-collaboration-plan.md. Two separate
things, because the plan is explicit that they are separate: an experiment
opt-in that decides whether the feature exists, and a server-side binding check
that decides who may speak as an agent. The opt-in alone is not authorization.

The opt-in — `experimental.agentCollaboration`, default false, also settable
via QWEN_CODE_ENABLE_AGENT_COLLABORATION=1:

- `Config.isAgentCollaborationEnabled()` is the single truth source. Two core
  sites read it: thread-tool registration and the agent tool-invocation guard.
- In the daemon it is resolved once at startup, not per session, because the
  surface includes work no session owns (a recovery sweep, a 5s dispatch timer,
  the Host transport routes). Off means `registerWorkspaceAgentRoutes` and
  `registerAgentHostTransportRoutes` are never called, so the scanner is never
  built and the routes 404 — rather than handlers that refuse after the timer
  is already reading collaboration storage.
- Clients learn about it from a new conditional capability tag,
  `agent_collaboration_v1`, so a UI can drop the entry point instead of
  rendering it and letting the calls fail.
- Creating a `sourceType: agent` session with the opt-in off is refused, not
  downgraded (architecture §6): a downgrade hands back a session the client
  believes is an agent's.

Observed rather than read, with a six-combination probe over the real registry:
off, all of {ordinary session, ordinary subagent, agent session, agent's
subagent} expose zero `thread_*` tools; on, only the agent rows do. That probe
found the gate was one clause too wide. `forSubAgent` is now gone from it — a
subagent runs on `deriveConfig`, which is `Object.create(parent)`, so an agent's
own subagent reads `sourceType === 'agent'` off the prototype chain anyway;
all the clause added was the six tools on subagents of ordinary conversations,
which have no run frame and would have thrown on first use. Dropping it takes
that row to zero and leaves the agent-subagent row at six.

The binding — `findAgentSessionBinding`, checked on agent session creation and
deliberately NOT gated on the opt-in, since with collaboration on is exactly
when it has to hold. `sourceType`/`sourceId` arrive from the client, so on their
own they are a claim; what makes the claim true is that this workspace's store
holds a live run for that agent naming this session.

That check could not hold as the dispatcher stood: `bindRunSession` writes
`run.sessionId` only after `port.start()` returns, and `start` is where the
session is created — so the first turn of every thread would have refused
itself. The port now answers `plannedSessionId` up front and the dispatcher
reserves it on the claimed run before starting. `queued` is not a live status:
only a claimed run can name a session.

Verified by execution, not by reading. scripts/audit/run-workspace-agents.mjs
gains a section asserting the ordering from inside `start()` itself: 193 passed,
0 failed. Mutation-checked — dropping the reservation, the agentId check, or the
run-status check each turns assertions red (2, 2 and 1 respectively). Narrow-scope
tsc over the touched cli files is clean, and that filter was itself checked by
planting a type error.

Refs #11206

* feat(agents): gate the run frame and the shared-threads entry on the opt-in

Two more rows of the architecture §7 checklist.

Session.ts no longer reads a run frame off a prompt when collaboration is off.
The frame can only arrive on the trusted daemon channel and an opted-out daemon
never mounts the routes that dispatch, so in practice none is sent — this is
defence in depth, and it is cheap because every downstream consumer (mid-turn
agent input, the six thread tools) requires the frame this one seam
establishes. Gating it here shuts all of them without a second truth source.

The Web Shell reads the new `agent_collaboration_v1` capability instead of
rendering "Shared threads" unconditionally. The page previously opened straight
onto that view by default, so on a daemon without the opt-in every request
behind it 404s. Definition CRUD on the same page is untouched: it is
`workspace_agents`, a different and unconditional feature — a distinction worth
stating because the two live in the same component and read alike.

Verified the capability's producing end by execution: the tag is absent with no
toggles and with the toggle false, present with it true, is registered
conditional rather than baseline, and turning it on adds exactly that one tag
and removes none. The consuming end is a three-line React guard with no test
covering it; that half is reviewed, not verified.

Refs #11206

* feat(agents): close runs the collaboration switch strands, so recovery cannot revive them

Architecture §6 names a conflict this branch had not resolved. The dispatcher's
recovery sweep asks of every `running` run whether its runtime body is still
there; a missing body with attempts left means a crash, so it requeues and
starts again. A run the operator switched collaboration off underneath looks
exactly the same — the daemon restarted, the body is gone — so opting back in
would silently re-dispatch work nobody asked to resume. Recovery cannot tell
the two apart, and no "state unknown" label stops it.

So the difference is drawn at the one moment it is knowable, a daemon starting
with the opt-in off, and recorded in the store where recovery later finds a
terminal run rather than a live one. `strandLocalRuns` closes every `running` /
`finishing` local run as failed with `closeKind: 'stranded'` and a reason, and
the Web Shell labels it as stranded and counts it outstanding — a person decides
whether the work is re-raised or dropped; the system does neither. `queued` runs
are left alone: nothing started them, so there is no orphan and no ambiguity.

It is a one-shot, not a scanner — no timer, no routes, untrusted workspaces
skipped, and the store's existence checked before the transaction rather than
inside it, since opening one creates the directory and lock file. A workspace
that never used collaboration comes out of an opted-out startup with nothing
written into it.

Implemented as a new `RunCloseKind`, not a new `ThreadRunStatus` member. The
status union has 21 literal consumers across core, routes and the view with no
central terminal set, and adding to it would mean 21 unaudited branches;
`closeKind` is already the field that says how a run ended, and its two
validators and the UI's label switch are the whole blast radius. Worth flagging
as a reading of "独立的搁浅终态" rather than a literal one — the run is
distinguishable and terminal, but via the close kind rather than the status.

scripts/audit/run-workspace-agents.mjs: 203 passed, 0 failed, including that a
subsequent dispatch tick does not restart a stranded run, that a second sweep
writes nothing, and that a collaboration-free workspace is left without a store.
Mutation-checked: dropping the existence guard or the close kind each turns one
red.

Refs #11206

* test(agents): make the collaboration gate observation reproducible

The six-combination probe that found the gate's `forSubAgent` clause was a
one-off in a scratch directory. P0 asks for this observation as its pass
condition, so it belongs where it can be re-run: 21 assertions over real
`Config` objects and real tool registries, no daemon, no bridge, no model,
seconds to run.

Covers what the plan lists: with the switch off none of the four session kinds
sees a `thread_*` tool; with it on only the two agent kinds do; the two
experiment switches are independent in all four combinations and Agent Team
alone exposes nothing; the env override reaches collaboration and not Agent
Team; and the capability tag appears if and only if the toggle is set.

Mutation-checked: dropping `isAgentCollaborationEnabled()` from the gate turns
three assertions red.

Refs #11206

* feat(agents): freeze the A2A contract, with the mapping as code rather than prose

P1. A2A is a rolling document, so "A2A-compatible" only means something against
a pinned version — which is why the plan asks for one before any transport
exists. Pinned: protocol `1.0`, the `JSONRPC` binding, `@a2a-js/[email protected]`
(Apache-2.0, node>=20, and it ships `./server/express`, which is the shape the
daemon already is), the card at `.well-known/agent-card.json`. gRPC was the
alternative and would have added two runtime peers for no capability we need.

Every constant was read from the specification and from the SDK's own type
declarations rather than inferred, and the parts that can be wrong silently
live in `a2a-contract.ts` with assertions on them, not only in the document.
scripts/audit/run-workspace-agents.mjs is 227 passed, 0 failed.

Three findings the spec forced, each of which changes what P2 may assume:

- A2A 1.0 has no usage or token field anywhere in its data model, so remote
  usage cannot be required of a third-party agent. Ours rides in
  `Task.metadata` under a declared extension URI, and a missing figure must
  read as unknown — reporting it as 0 would make an agent that declines to
  report free to call. Mutation-checked.
- Deduplication is `MAY`, on a client-minted `Message.messageId`, with no
  scoping — two callers can present the same id. So the key is the server's:
  `externalRequestKey` scopes it by authenticated caller and target agent, and
  length-prefixes the parts, because ids are opaque strings from outside and a
  caller who can put the separator inside one could otherwise forge another
  caller's key. Also mutation-checked.
- `TASK_STATE_REJECTED`, `TASK_STATE_AUTH_REQUIRED` and thread-level
  cancellation have no local counterpart — `ThreadStatus` has no cancelled
  member, only runs do. So an inbound `cancelTask` cannot be represented today
  and P2 must add it before claiming cancellation works.

The load-bearing decision: an A2A `Task` is one local `Thread`, not one
`ThreadRun` — a Task survives `INPUT_REQUIRED` and further input, which is a
thread being answered and worked again, whereas a run is a single turn with no
protocol counterpart. `contextId` is then `rootThreadId`. `in_review` maps to
`INPUT_REQUIRED`, which loses the "asked a question" / "submitted for review"
distinction across the boundary; it is kept in the extension metadata.

Interop acceptance client: `a2a-sdk` (Python, PyPI 1.1.2) — a different
language and codebase from our server SDK, so it is not the two-ends-of-our-own
-client self-test architecture §6 rules out.

No transport, no Agent Card, no auth, no intake storage, and the SDK is not yet
a dependency. P1's threshold is the recorded mapping and unsupported list;
making it talk is P2.

Refs #11206

* feat(agents): accept external work idempotently and scope it to its caller

P2's first half — the part that is wrong or right without a network, so it does
not wait for one. Two properties, both of which the plan lists as acceptance
conditions and neither of which needs a remote peer to check:

A retry must not produce a second piece of work. The lookup, the intake record
and the thread are one transaction: split across two, a retry arriving between
them would be looked up, not found, and accepted again — which is the exact
failure the key exists to prevent, so writing the key afterwards is writing it
too late. A repeat returns the original thread and books no second run; it does
not re-post the message either, because a retry means the caller did not hear
the answer, not that it wants the work done twice.

Reusing a key for different content is refused, loudly and with its own error
class — the transport has to answer that case differently from every other
failure, since a caller that cannot tell "your request failed" from "you already
used this id for something else" will retry forever.

And one caller must not reach another's work. B serves several authorized
clients over one queue, so reads are scoped by authenticated caller rather than
merely filtered for display: `getExternalThreadForCaller` returns undefined for
"no such thread" and "not yours" alike, because a caller able to distinguish
them can enumerate another client's work.

The intake record lives on the thread, not in an index of its own. An index
would be a second write, and a second write can disagree with the first about
whether work was accepted; lookup by key is a scan, which costs what the store's
other scans cost and cannot go stale. Present-but-malformed is rejected rather
than ignored — dropping the field would hand the thread to whoever asked next.

scripts/audit/run-workspace-agents.mjs: 245 passed, 0 failed. Mutation-checked —
dropping the content-hash comparison, the list scoping, or the by-id ownership
check each turns assertions red (2, 1 and 2).

Still absent, and not claimed: the A2A transport itself, the Agent Card,
authentication, and anything about network reachability. Those need the
deployment decisions the plan holds for a human.

Refs #11206

* feat(agents): give a thread a cancelled state, closing the gap P1 recorded

P1's contract listed thread-level cancellation as a local gap that blocked
`cancelTask` — A2A makes it a required operation, and `ThreadStatus` had no
member for it, only runs did. So there was nothing for an inbound cancel to
become, and a server could not honestly claim A2A without this.

Seven places asked `thread.status === 'done'`, and every one of them meant
"this thread is over" rather than "someone pressed done": dispatch admission,
candidate selection, recovery, close handling, assignment and status
resolution. They now ask `isThreadTerminal`. Adding a second literal at each
site would have been seven chances to miss one, and the one missed would have
kept dispatching work for a task its caller had already withdrawn.

Cancelling retires the thread's runs the way the existing "mark done" route
does — a queued run no selection will ever pick still reads as pending work,
and a live one is asked to stop rather than quietly relabelled. `cancelling` is
a request, not a report, so the number of still-live runs comes back separately:
the receipt can say "nothing further will start" without claiming the body has
stopped, which the plan asks to be reported apart. Cancelling an already-done
task leaves it done rather than rewriting how the work ended.

scripts/audit/run-workspace-agents.mjs: 259 passed, 0 failed.

Two of the three mutations initially SURVIVED, and the reason is worth
recording: the cancelled thread shared an agent that already had a live run, so
candidate selection skipped it for being busy and never consulted the thread's
status — the assertion passed without testing anything. And the late-post
assertion could not reach admission at all, because a thread still holding a
queued run coalesces a new message into it and short-circuits first. Both are
fixed: cancellation gets an idle agent of its own, the test first asserts the
run is queued and its agent is free, and the late-post case drains its run
before cancelling. All three mutations now turn assertions red.

Web Shell groups a cancelled thread with done rather than idle — an idle group
is things still waiting on someone, which a withdrawn task is not.

Refs #11206

* feat(agents): serve the five required A2A operations, with grants that actually gate

P2's server side, transport-free. What a JSON-RPC layer adds is framing and
status codes; what can be got wrong — who may call, what a retry does, which
task a caller may see, what state it is told — is all here, so all of it is
exercised without a socket.

Grants are per caller and per agent, never per daemon. The plan is explicit
that the daemon's management token is never handed to an external
collaborator, so authenticating as a caller must not be a route to it: a grant
names one agent, and opening agent A says nothing about agent B. Secrets are
returned once at issue and stored only as digests, compared in constant time.
`analysis` is read-only and is what the plan opens first; `full` covers it,
never the reverse, and the two are spelled out rather than ordered by index so
adding a scope cannot silently widen an existing one.

Every authorisation failure reaches the caller as one undifferentiated refusal.
An unauthorised caller that can tell "no such agent" from "wrong secret" from
"revoked" can enumerate this daemon's agents; the distinctions stay on our side
for the log. Same reasoning gives `getTask` one answer for "no such task" and
"not yours". The extended Agent Card is built per caller for the same reason —
it lists the agents that caller may address, which is exactly its grants.

Streaming and push notifications are advertised as false because they are not
implemented. The spec gates its optional operations on those flags, so
advertising one we do not serve turns a client's correct behaviour into a
failed call.

scripts/audit/run-workspace-agents.mjs: 294 passed, 0 failed, covering every
authorisation boundary the plan lists — wrong secret, ungranted agent, unknown
agent answering identically, a valid secret presented by the wrong caller, a
second client sharing the queue but not the work, scope, expiry, revocation
(which also closes off already-submitted work), re-issue replacing rather than
accumulating, and a retired agent admitting nobody. Mutation-checked on four
guards; each turns 1-3 assertions red.

One assertion failed on first run and the failure was mine, not the code's:
retiring an agent that still has live work is refused, so the busy agent I
reused was never actually retired and the test asserted a refusal in a world
where nothing had happened. It now retires an idle agent and asserts the
retirement took before asserting its consequence.

Still absent: the JSON-RPC transport, the public discovery card, and any claim
about network reachability.

Refs #11206

* feat(agents): decide what a Codex turn ending is evidence of

P3's blocking decision, implemented. Codex has no equivalent of the six
`thread_*` tools, so it never says how its work ended. Applied literally, the
local rule — a run reaching `completed` without a closing tool is `unclosed`,
never implicit success — would leave every Codex turn `unclosed` forever with a
person marking each by hand.

Architecture §5's recommended rule: a turn ending plus a structured result item
is a result; a turn merely ending is `unclosed`. Implemented blind to content —
it sees `turn/completed` statuses and `item/completed` *types*, never item text.
"Done!" is not evidence, and a rule that read it would be guessing success from
natural language, which §5 forbids and which the local path already refuses.

`fileChange` and `exitedReviewMode` count: an edit with a path and a diff exists
whether or not anyone describes it, and the second is Codex's own explicit
completion event. `agentMessage` deliberately does not, and that absence is the
point — it is where "I've finished the analysis" would appear.

**A consequence you should weigh before P3 runs:** a read-only analysis, which
is exactly the first task the plan opens to an external caller, produces an
`agentMessage` and nothing else. Under this rule it lands `unclosed` and waits
for a person — every time. That is the conservative answer the rule was chosen
for rather than an oversight, but it means the common case is not hands-off. If
that is too strict, the fix is to give the adapter a deliverable to emit, not to
start reading the prose. Flagging because §5 marks this decision as needing
confirmation before P3, and this is what confirming it costs.

A delivered result closes as `review`, not silently: the work came from another
runtime under someone else's control, so a person accepts it rather than the
system accepting on their behalf. An interrupted or failed turn claims no close
kind at all — recording one would say the agent decided something.

Method, event and item names are the published Codex App Server protocol's, not
invented; `CodexItemType` is enumerated rather than `string` so a new item type
is a compile error where someone must decide whether it is a deliverable.

scripts/audit/run-workspace-agents.mjs: 308 passed, 0 failed. Mutation-checked:
counting `agentMessage` as a result, or not treating a failed turn as failed,
each turns three assertions red.

No Codex is contacted, installed or required. Which signals count is the part
worth getting right, and it can be argued with before anyone has a server.

Refs #11206

* feat(agents): lease runs to outbound Hosts, so a ghost cannot overwrite its successor

P4's core, without the network. A managed Host reaches out and nothing reaches
in, so the daemon cannot tell a Host that is thinking from one whose network
died — work it holds has to become available again on its own. The danger in
doing that is the obvious one, and it is the one the plan names: the first Host
comes back and writes its result over work a second Host has since done.

A lease is what makes reclaiming safe. Re-acquiring mints a new `leaseId`, never
reuses one, and every write is checked against both that id and the run attempt.
The id catches a worker whose hold was taken over; the attempt catches the
subtler case where the run was requeued and restarted — possibly by the very
same Host — so an id from the previous attempt would otherwise still look
current. Both refusals are distinct, because they mean different things to
whoever is debugging.

Renewal is a heartbeat, not a claim: a lapsed lease is refused rather than
revived, since a Host unreachable for longer than the window is exactly the case
the window exists to notice. A Host can hand its own lease back early but cannot
free another's, and terminal runs are not leasable at all — a Host handed one
would do work nothing will accept.

A malformed lease fails the run record rather than being dropped. A dropped
lease reads as "nobody holds this run", which is the single answer that lets two
Hosts execute the same work.

scripts/audit/run-workspace-agents.mjs: 326 passed, 0 failed. Mutation-checked:
skipping the attempt check, reusing the lease id on re-acquisition, or not
checking whether another Host holds it each turn 1-3 assertions red.

The pickup long-poll, the lease transport and result return are not here. They
are the parts that need two machines, and they are worth nothing until this is
right — which it now demonstrably is.

Refs #11206

* feat(web-shell): answer P5's three questions in one pass

"Who is queued, who needs an answer, whose is the result" — the three things
P5 says a person must be able to answer at a glance, computed together rather
than as three passes. Separate passes are how one thread ends up counted as
both waiting for a person and still running, which reads as two pieces of work
where there is one; the assertion that every thread lands in exactly one place
is there to keep it that way.

Needing a person outranks a live run deliberately. A blocked thread can still
show a run mid-teardown, and telling someone it is running makes them wait for
something that will not happen. Mutation-checked by swapping the order.

A workspace now holds locally raised work and work raised by an external A2A
caller side by side in one list, so finished items carry an owner — without it
"whose result is this" has no answer on the row. Sub-threads fold into their
parent: a split is how an agent organises its own work, and surfacing each one
turns one task into a list nobody asked for.

scripts/audit/run-workspace-agents.mjs: 335 passed, 0 failed.

This is the view logic, not the page. The remaining P5 work — one entry point
that starts local-plus-remote collaboration without first making a Team or
opening a task tab — needs P2/P3 to be reachable before it has anything to
show.

Refs #11206

* docs(agents): record what each phase actually delivered, and what it did not

Per the plan's own rule — report observations, not percentages, and write
"not run" where nothing ran. Each phase gets what landed, what is still
absent, and the human decisions still blocking it.

Refs #11206

* docs(agents): hand the two transport tasks off with their constraints attached

A2A JSON-RPC and the Host outbound pickup are both transport-only now: the
semantics, storage and authorisation behind each are implemented and covered
by assertions. The brief says which functions to call rather than what to
build, so neither gets reimplemented alongside what already passes.

The constraints carry their reasons, because several are load-bearing in ways
that read as style otherwise: A2A's four authorisation failures must stay one
undifferentiated refusal at the transport or the caller can enumerate agents;
the public Agent Card is a different document from the per-caller one and must
not list agents; streaming and pushNotifications stay false because the spec
gates its optional operations on them; and every Host write goes through
checkRunLease rather than a second reconciliation, since that check is the
only thing stopping a returning worker overwriting its successor.

Acceptance for each is an observation, not a build: A2A against the Python
a2a-sdk chosen in P1 as the independent interop client, and the Host path
against two Hosts where the first one's late write is refused as stale_lease.

Refs #11206

* feat(cli): expose workspace agents over A2A

* feat(cli): add managed host run pickup

* feat(web-shell): choose agent execution hosts

* fix(agents): end workspace turns at handoff

* feat(agents): execute managed host assignments

* feat(agents): run managed work with codex

* fix(agents): complete delegated work handoff and resume waiting agents

Keep each ACP attempt active until its own terminal result, isolate managed Hosts from coordinator scheduling, and route peer results back to waiting agents.

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

* fix(agents): make Host pickup refuse terminal threads, and revive the audit suite

Two findings from reviewing the transport work.

**The audit harness had stopped running.** `thread_create` now requires
`assignee` — a defensible narrowing, since an unassigned sub-thread is work
nobody owns — but the harness still called it without one, so it threw on
startup and all 335 assertions silently stopped covering anything. Every one of
the seven transport commits landed with the suite dead. Harness updated; it is
green again, and the one assertion that then failed was its own fault, not the
code's: pinning `autoTurnsUsed === 7` pinned the booking behaviour too, when
what it is for is that a child inherits the parent's count rather than resetting.

**`pickupRunForHost` handed out work the dispatcher refuses.** It is a second
selection path beside `selectCandidates`, and it had no terminal-thread check —
so a Host could be given a queued run on a thread whose caller had cancelled it
or whose owner had marked it done, while the dispatcher correctly ignored the
same run. This is exactly the failure `isThreadTerminal` was introduced to
prevent when the seven existing sites were unified, and it is an eighth site
added afterwards without it.

Observed rather than argued: the harness gains a section that first asserts a
Host *can* pick up live work — without that the negative case passes for the
wrong reason, which it did on the first attempt — then marks the thread
terminal and asserts both paths agree. 339 passed, 0 failed; mutation-checked by
removing the guard again.

The held-lease branch gets the same check. A thread that goes terminal while a
Host holds it should not have that hold extended — the work is over.

Refs #11206

* test(agents): cover the Host pickup and result paths that had none

`pickupRunForHost` decides what work leaves this daemon for another machine and
`applyHostRunResult` is where a stale worker would do damage. Both landed with
no assertions; neither needed a network to be checked.

Pickup: a Host takes only work for an agent placed on it, never another Host's
and never a locally-executed agent — and the local dispatcher leaves
managed-host work alone, which is the other half of that separation, since
without it both sides run the same task. A reconnecting Host resumes the lease
it already holds rather than being handed a second copy of its own work under a
new id. Concurrency is enforced across Hosts.

Results: a lease id nobody holds, a different Host under the right lease id, a
previous attempt, and an unknown run are each refused — and the refusals are
asserted before the legitimate write, so none of them passes merely because the
run was already closed. The holder's own result closes the run with the kind it
reported rather than an implicit success, moves the thread to review, and
resending it is recognised as a replay instead of closing twice. A failure
reports as a failure with the Host's reason and no close kind invented for it.

Mutation-checked, all four bite: dropping the hostId check on results (3 red),
not recognising a replay (1), not distinguishing which Host is asking (6), and
skipping the concurrency limit (1). 369 passed, 0 failed.

One assertion was wrong on the first attempt and the API was right: testing
the concurrency limit against a single Host asserted nothing, because a Host
asking again resumes the assignment it already holds and the resume branch
answers before the limit is ever consulted. The limit only decides anything
when a second Host is placed on the same agent, which is what it now tests.

Refs #11206

* test(agents): drive the A2A transport over real HTTP

`routes/a2a.ts` mounts routes on an Express app, so the honest way to check it
is to mount them and make requests — testing its internals would test a shape
no client speaks. A fake workspace registry points at a temp project root; the
store beneath is the real one, and there is no daemon and no model. It skips
with exit 0 when the SDK is not installed rather than failing for the wrong
reason.

What it pins is the part a JSON-RPC layer can quietly get wrong. The public
card lists no agents and names none, so anyone may fetch it. Six different
authorisation failures — no credentials, wrong secret, ungranted agent, unknown
agent, another caller's identity, unknown workspace — all answer with the same
code and the same message, and a task that is not yours is byte-for-byte
indistinguishable from one that does not exist. Streaming is refused, matching
the capability the card advertises. Idempotency and caller scoping survive the
transport: a resent message id yields the same task and the store holds one
piece of work, a reused id with different content is a distinct error naming
the existing task, and a second client gets its own task and cannot read,
cancel or list the first's.

Three assertions were wrong before they were right, each for a reason worth
keeping:

- Every request was missing `A2A-Version`, so the SDK assumed the legacy 0.3
  wire version and refused all of them with one code — which turned "all
  refusals look alike" green without testing anything. The header is now sent,
  and the version negotiation itself is asserted.
- The unauthenticated case sent empty params, so it failed parameter validation
  before reaching auth and reported a leak that was not there. Every refusal now
  sends a well-formed request, so the only difference between them is
  authorisation.
- The public card's `skills` is absent rather than `[]`, because protobuf
  `toJSON` omits empty repeated fields.

34 passed, 0 failed. Mutation-checked: leaking why a refusal happened (2 red),
listing agents on the public card (2), and answering differently for "not
yours" than for "no such task" (1). A fourth mutation, swapping the not-found
error type, correctly survives — it changes both cases alike and leaks nothing.

Refs #11206

* test(agents): pin the asymmetry in who must name a thread's owner

`thread_create` requires an assignee; `createThread` does not. That reads like
an inconsistency and is not one: an agent splitting work must say who does it,
or it has created work nothing will ever dispatch, while a person may raise a
thread and decide later. Both halves are now asserted, along with the fact that
the refusal names the missing field and leaves no orphan thread behind — it is
rejected by the tool's own schema, so the model never reaches an execution it
could misread as partial success.

Also records the four audit scripts and what each covers in the plan, since
"335 passed" in a commit message ages badly and the counts had already moved.

373 passed, 0 failed.

Refs #11206

* chore: sync pnpm-lock.yaml with @a2a-js/sdk dependency

* fix(agents): refuse an executor a workspace Agent cannot honour, and clear the CI gates

Three findings from reviewing the branch after main was merged in.

**A definition's external executor was silently dropped.** Subagent definitions
gained an `executor` block (#11003): the turn runs on an external agent over
ACP. Workspace Agents borrow a definition's prompt, model and tool config
through `agentType`, but they never go through `createAgentHeadless`, which is
the only thing that honours an executor — they are their own top-level session.
So attaching such a definition took its instructions and ran the turn locally as
Qwen: the operator asked for one runtime and got another wearing the first one's
prompt. It now refuses, on exactly the reasoning the neighbouring rendered-prompt
case already gives, and the message points at `execution.mode: "managed-host"`,
which is how a workspace Agent actually says it runs elsewhere. Confirmed by
execution before and after: the assertion failed with "resolved" first.

**Seventeen files across the PR failed the Prettier gate**, mine and the
transport work's alike. CI runs Prettier with `--experimental-cli`, which
rejects files the classic CLI passes, so neither of us saw it. All formatted;
the whole changed set is clean.

**`routes/a2a.ts` had never been linted** and carried six errors. The failure
mapping now has an exhaustiveness default, so adding an `A2AFailure` member is
a compile error at the one place that decides what a caller is told rather than
a silent fall-through; the unused registry parameter is marked; and the two
streaming stubs are declared as methods returning `AsyncGenerator` instead of
generators that never yield — which also means an unsupported operation fails
when it is called rather than when a client first iterates a stream that will
never arrive. The transport audit still reports streaming refused.

Also checked and found sound, so recorded rather than changed: unknown tools
still classify as `deny`, so an upstream tool landing without a classification
fails closed; both lockfiles carry `@a2a-js/sdk` and its transitive `jose`; and
the new upstream `SessionSourceService` is about attached content sources, not
`sourceType: agent`, so it does not touch the server-binding check.

376 / 34 / 21 / 15 across the four audits, all green after the merge.

Refs #11206

* test(agents): a waiting agent is woken once, not on every later close

e0f75795cc made a close @-mention peers whose runs ended `waiting`, so they
resume. The risk in that is repeat wake-ups: if the wait were not acknowledged
when answered, every later close on the thread would wake the same agent again
and spend its turn budget on nothing new. Now asserted end to end: both agents
mid-turn on one thread (a wait is only legal while something can still wake
it), the waiter closes `waiting`, the closer's review wakes it with one new run
and acknowledges the wait, the woken run is drained, and a second close wakes
nothing.

Every step is read from the store rather than a tool's return shape. The first
version of this scenario had the waiter call `thread_wait` alone on the thread;
the tool refused ("waiting would strand it"), the refusal sat in `llmContent`
rather than `.error`, and the scenario went on asserting against a run that had
closed `unclosed`. Draining the woken run before the second close is also
deliberate — left queued, a second wake-up would coalesce into it and the count
would stay flat for the wrong reason.

Mutation-checked. Dropping the @-mention turns both wake assertions red.
Dropping the acknowledgement in `closeRunInTransaction` turns the "acknowledged"
assertion red but not the end-to-end one — because a person's post also
acknowledges outstanding waits (`thread-actions.ts`), and the second closer turn
here is triggered by one. So the property is defended at two sites, and the two
assertions pin one each. Recorded rather than engineered around.

383 passed, 0 failed.

Refs #11206

* fix(agents): restore the core build, which every CI job on this branch was failing on

`thread_create` was narrowed to require `assignee`, and `thread-tools.test.ts`
was not updated with it. That is a type error, `tsc --build` fails on it, and
`npm run build --workspace=packages/core` fails with the build — so **all four
failing CI jobs on this PR fail on the same two lines**: Lint & Static, Test,
Integration Tests (no-AK) and web-shell E2E Smoke each build before they do
anything else. The main CI has been red on this branch since 2026-09-08 and has
never been green.

Two call sites, both in a test about where a sub-thread lands rather than about
assignment, so they take the same `bob` the neighbouring cases use.

Also adds `scripts/audit/tsconfig.workspace-agents-tests.json`, because this
should not have needed CI to find. The existing narrow configs exclude
`*.test.ts` — they were written to check production sources — but CI compiles
test files too, so a break confined to a test was invisible locally while being
fatal remotely. The new config covers the workspace-agents tests and
`thread-tools.test.ts`, and was verified by reverting the fix: it reports both
TS2345s, then none.

383 passed, 0 failed on the behavioural audit; the four A2A/gate/capture suites
are unaffected.

Refs #11206

* fix(cli): fold the agent-run type import into Session.ts's existing type block

`Lint & Static` was still red after the build fix, on two warnings — and CI runs
`eslint . --max-warnings 0`, so a warning is a failure.

`Session.ts` already had one `import type { … }` block and one value block from
`@qwen-code/qwen-code-core`. This branch added `type AgentRunContext` inline in
the value block, which gives the module two type-import surfaces and trips
`import/no-duplicates` on both. Moved into the existing type block, which is the
arrangement main has and lints clean.

Confirmed it was ours rather than pre-existing by linting main's copy of the
file in place: clean there, two warnings here.

This is also a mistake in how I had been checking. I ran eslint with
`| grep -v "no-duplicates"` throughout this work and read the result as clean —
filtering out the one warning class that CI treats as fatal. `npm run lint:ci`,
the exact CI command, now passes repo-wide with no output; that is what I should
have been running all along.

383 passed on the behavioural audit; Prettier clean.

Refs #11206

* fix: regenerate the settings schema, and guard an optional Config method

Two more CI failures on 8d3e506a3, one mine and one from the session-naming work.

**`Lint & Static`: "settings.schema.json is out of date."** I added
`experimental.agentCollaboration` to `settingsSchema.ts` and never regenerated
the JSON schema the gate diffs against. The generator cannot run in this
sandbox — it resolves `@qwen-code/qwen-code-core/envVarResolver`, and the
installed copy here predates that subpath export — so the entry is written by
hand from its sibling `agentTeam`'s exact shape: same key order
(`description`, `type`, `default`), same position between `agentTeam` and
`artifact` as in the source. The description is asserted equal to the one in
`settingsSchema.ts` verbatim, and the file is Prettier-clean. CI's regenerate-
and-diff step is what confirms it; I could not.

**`Test (ubuntu)`: `config.getWorkspaceAgentName is not a function`** — every
failure in `acpAgent.test.ts` traces to one unguarded call in
`createAndStoreSession`. The method exists on a real `Config`, but this layer
is handed Config-shaped objects that are not always complete, which is why
`getWarnings`, `getSessionId` and `getFailedMcpServerNames` in the same file are
all called through a `typeof … === 'function'` guard. This one now is too, so a
missing method costs the session its agent title instead of failing session
creation outright.

That test file cannot run here (it needs the workspace packages built, which
OOMs in this sandbox), so the second fix is reviewed rather than executed — CI
is the check. The alternative, teaching the test double the method, was
rejected as the less conventional of the two: the guard follows three
precedents in the same file.

383 passed on the behavioural audit; `npm run lint:ci` clean repo-wide.

Refs #11206

* fix: guard every optional Config read, and refresh NOTICES.txt

**954 of the CLI shard's 557 test failures were one line of mine.**
`Session.ts` called `this.config.isAgentCollaborationEnabled()` unguarded, and
that layer is handed Config-shaped objects that are not always a full Config.
This is the identical bug I diagnosed in `getWorkspaceAgentName` one commit ago
and fixed there without looking at my own two call sites — the general lesson
was in hand and I did not apply it.

Both sites now go through one `#collaborationEnabled()` helper, so a third
cannot forget the guard, and the third unguarded read in `acpAgent.ts`'s agent
session branch is guarded too. Absent means off everywhere, which is the safe
reading in both places: no run frame is established, and an agent session on a
Config that cannot say whether the operator opted in is refused rather than
granted. Swept the repo for other unguarded calls to either method; there are
none.

**NOTICES.txt** was the remaining `Lint & Static` failure (the settings-schema
entry from the previous commit worked). The regenerated file is one line:
`[email protected]` → `[email protected]`, the transitive dependency `@a2a-js/sdk` pulls in.

Worth recording how that was produced, because the first attempt was wrong: run
in this worktree as it stood, the generator saw 655 dependencies against an
incomplete `node_modules` and rewrote the file with 589 insertions and 7481
deletions. CI's own diff was `2 +-`. A real `npm ci --ignore-scripts` first, then
regenerate, reproduces CI's one-line diff exactly. A generated licence file is
not something to hand-write or approximate.

Audits: 383 / 34 / 21. `npm run lint:ci` clean repo-wide. The CLI test shard
cannot run here (it needs the workspace packages built), so the guard is
verified by the failure signature it removes rather than by a local green run.

Refs #11206

* fix: bring the drifted tests and registries up to what the code now does

CI's Test job is down to 24 failures across three shards; this clears all of
them. Local runs became possible partway through — `tsc --build packages/core`
with a 12GB heap succeeds in this sandbox, contrary to what I had recorded —
so every fix below was run rather than reasoned about, except the three
`server.test.ts` capability cases, which are blocked locally by an unrelated
`web-templates` generator gap and were verified by comparing my expected order
against `getRegisteredServeFeatures()` directly (index 47, between
`workspace_agent_generate` and `workspace_env`, absent without the toggle).

Registries that had to learn about what we added: `agent_collaboration_v1` is
now documented in the serve protocol's conditional-features table, present in
the registered-feature list in declaration order, has its predicate branch in
the drift-insurance test, and `QWEN_CODE_ENABLE_AGENT_COLLABORATION` is declared
in the process.env allowlist with its reason. The six `thread_*` tools gained
Web Shell display names and zh translations. The four `--agent-host-*` serve
flags are registered as fast-path fallbacks — a managed Host is a different
startup mode, so handing off to the full CLI is right.

Tests that encoded behaviour we deliberately changed: thread status reasons are
prose for a person now, with the run id in `outstanding`; the GAP label dropped
"earlier"; the persona states "not a subagent" rather than avoiding the word;
`thread_create` reports work queued rather than woken; a close @-mentions
waiters, so a review leaves the thread in progress rather than in review; a
failed run posts a system message, so acknowledgement sequences shift; an
`open` sub-thread with nobody on it cannot satisfy a wait; and the resumed
workspace-Agent ceiling denies shell while always adding the thread tools.
`ResidentAgentContinuationResult` is a string union, not a boolean.

Two were worse than stale. `dispatcher.test.ts` asserted the delivery watermark
moves at start, which `954c1ffa29` deliberately stopped doing — the local port
reports `consumedOnStart: false` and the input is confirmed after the transcript
flush. And `config.test.ts`'s collaboration-tools case asserted against
`getAllToolNames()`, which this file stubs to `[]` at module scope: its positive
half could never pass and its negative half could never fail. It now reads the
recorded `registerFactory` calls, clearing the shared prototype mock between the
two registries so the ordinary session does not inherit the agent's
registrations.

Local: 1171 core tests and 111 cli tests that were failing now pass; audits
383 / 34 / 21; `npm run lint:ci` clean repo-wide.

Refs #11206

* fix: complete the i18n keys and record that terminateTurn is no longer Goal-only

CI is down to two failures from twenty-four; this clears both.

**i18n.** Adding the six `thread_*` display names to zh alone was half the job:
`check-i18n` requires every key to exist in `en.js` first and to be translated
in both Chinese locales, so the run failed with "Extra key in zh.js (not in
en.js)". English base keys and zh-TW translations added; `check-i18n` now passes
all checks. The Web Shell keeps its own `toolName.*` table, which also needed
the six — that is what its own drift test caught.

**`terminateTurn` outside a Goal turn.** The test asserted an ordinary turn
keeps its tool loop, and its comment explained why: "nothing but the Goal tool
sets it today". That is no longer true — the workspace-Agent closing tools set
it too, and `#endTurnAfterToolRun` (renamed from `#endGoalTurnAfterToolRun`)
deliberately dropped the Goal-turn guard, because a hand-off makes later work in
the same physical turn stale for the same reason a checkpoint does. The case now
asserts the flag is honoured wherever it is set; tools that do not set it keep
the ordinary loop, which the neighbouring cases already cover.

`npm run lint:ci` clean, `check-i18n` green, and the two test files pass
locally (`toolFormatting.test.ts` 51/51; the Session case is verified against
CI's own received value, since that file needs a `web-templates` generator this
sandbox cannot complete).

Refs #11206

* fix(scripts): do not crash the dev launcher on a manifest without an exports map

The last two test failures cleared; `Lint & Static` went green for the first
time. What surfaced behind them was five failures in `scripts/tests/dev.test.js`,
all from one line this branch added.

The acp-bridge source remapping reads that package's `exports` and iterates it
directly. With no `exports` field — which is what the launcher sees when the
manifest read is stubbed, and what it would see against any manifest that omits
one — `Object.entries(undefined)` throws at module load, so the launcher dies
for every caller rather than skipping a remapping it has no input for. Absent
now means nothing to remap.

Verified by running the suite: 6/6 after, and removing the `?? {}` again
reproduces `TypeError: Cannot convert undefined or null to object` across the
file.

Refs #11206

* fix(mesh): authenticate A2A requests before task lookup

* fix(mesh): renew exact Host leases without claiming more work

* fix(mesh): quote task frames and record review regression checks

* feat(mesh): stream live agent replies into project conversations

* test(mesh): add repeatable Playwright streaming acceptance

* fix(mesh): stream provider thoughts and clarify queued agent state

* fix(mesh): request Codex reasoning summaries for streamed runs

* fix(mesh): show startup loading and toggle activity panel

* fix(mesh): resume persistent Codex host conversations

* fix(mesh): retain Codex processes and deliver in-flight follow-ups

* fix(web-shell): clarify pending agent execution confirmation

* fix(web-shell): move agent activity into right panel and bridge loading states

* fix(web-shell): retain native chat header for collaboration

* docs(design): record cross-machine agent-host acceptance

* refactor(web-shell): drop the Agents session-source tab

An agent session holds one run's transcript, not a conversation, and the run
row inside its own conversation already opens it. Listing runs beside
conversations made the sidebar's Sessions group read as if they were
conversations, so the source switch is Tasks and Channels again.

* fix(core): keep the merged agent-session fixtures as separate transcript entries

* fix(cli): import core symbols from their defining modules

* style: format the merged files with Prettier

* fix(cli): cover --agent-host-allow-http in the serve fast-path option map

* fix(web-shell): keep the collaboration entry from crashing or churning

The collaboration entry dereferenced `collaborationThread.id` after a
comparison that is true when both sides are undefined, and read
`capabilities?.features.includes(...)` where only the first hop was
optional. Both threw against the partial capability snapshots and mocked
workspaces the suites use.

The entry also returned fresh `providers` and `submit` values on every
render, and App.tsx handed the composer a freshly built `atProviders`
array, so ChatEditor re-rendered on unrelated renders and its props never
compared equal. Memoize the hook's return, the two collaboration
callbacks, and the merged provider list.

The sidebar suites load AgentCreatePage through the threads route, so
their daemon-react-sdk mocks need DAEMON_APPROVAL_MODES.

* test(scripts): provide readdirSync in the dev launcher's fs mock

scripts/dev.js scans packages/channels with readdirSync at import time,
but the node:fs mock in dev.test.js never exported it, so five tests
failed with 'No "readdirSync" export is defined on the "node:fs" mock'
before their assertions ran. Return an empty directory listing: the scan
only keeps a shared worktree install from mixing another checkout's
channel build, and no test here depends on a registered channel.

* fix(core): classify the omni media tools for workspace agents

* fix(cli): restore the goal-turn argument on the end-turn path

Renaming the goal-turn helper to #endTurnAfterToolRun during the merge
dropped main's goalTurn parameter, which left packages/cli failing to
compile (TS2304). Restore the parameter, pass the enclosing goal turn at
both call sites, and guard the ending-tool-call bookkeeping so a turn
without a goal still ends normally.

* feat(serve): stream agent collaboration events over SSE

Adds GET /workspaces/:ws/agent/events. It pushes `changed` when anything
under the workspace's agent store is written (found with fs.watch, so
routes, the dispatch loop, host routes and thread tools inside agent
sessions are all covered) and `progress` frames with a running agent's
reply, thinking, current tool and pending approval, published straight
from the dispatch port and coalesced to one frame per 100 ms.

The on-disk progress snapshot drops from every 500 ms to every 3 s and
only when it changed; it stays so a reloaded page can show a reply that
is still being written.

A run that shows no activity for 15 minutes is cancelled and fails with
`agent_run_stalled`, except while it waits for a person to answer a tool
approval. Approval requests and resolutions for the run's turn are now
reported (resolution clears only the request on screen).

Stage and detail are codes/raw text instead of hard-coded Chinese UI
copy; the client localizes them.

* feat(web-shell): live agent replies, waiting states and approvals

The collaboration views subscribe to the workspace event stream instead
of polling every second (the sidebar every five): `changed` triggers one
coalesced refetch and `progress` frames fold into the open thread so a
reply streams as it is written. Subscribers of the same workspace share
one connection. When the stream is down or unsupported the views fall
back to the old polling.

Every live run now shows one line saying what it is doing: queued,
runtime offline, starting, thinking, replying or running a named tool
with elapsed time, and after five quiet minutes that it may be stuck,
with Stop. A run waiting on a tool approval shows an approval card whose
options answer through the existing session permission route.

New copy lives in i18n (en and zh-CN); the mesh-streaming e2e spec
follows the renamed strings.

* feat(web-shell): turn run details into a team panel

The side panel opened from a collaboration thread is now the team: every
agent that has been handed or worked on the thread, lead first, each with
a one-line state (queued, runtime offline, thinking, running a named
tool, waiting for your approval, may be stuck, stopped after 15 minutes
without progress, done). Clicking a member opens its session. Live run
details, subtasks, the parent task and past runs follow.

Panel, tab and header labels move to i18n; the mesh-streaming e2e spec
follows the new names.

* feat(web-shell): mention an agent from any conversation

`@agent` now works mid-conversation, not only in an empty chat. The new
collaboration thread carries the last few messages as context, and the
first agent addressed becomes its lead (assignee), so a follow-up without
an @ reaches it and a sub-thread report wakes it instead of leaving the
thread blocked with nobody to act.

The mention picker reads the roster once per five seconds instead of on
every keystroke, and shows each agent's program, runtime and state.

* fix(core): make agent replies and mentions work in a conversation

Four defects that broke the basic "mention an agent, let it bring in
others" loop, all confirmed by the PR audit:

- An agent that answered in plain text (the normal way a chat model
  replies) left its answer only in the run's progress snapshot: peers,
  thread_read and parent reports never saw it, and the thread went
  `blocked` as "ended without a hand-off". The answer is now posted as
  the agent's message through normal admission (so an @mention in it
  wakes that agent), idempotently per run attempt, and such a run closes
  without an `unclosed` obligation.
- Only a person's post that reached nobody now marks the thread blocked;
  an agent reply that wakes no one is the normal end of its turn.
- `@` written straight after Chinese text was not a mention, and a name
  ran into the next word ("请@迁移助手看一下"). The lookbehind now rejects
  only ASCII word characters and dots (emails stay excluded) and a token
  resolves to the longest agent name it starts with, unless more ASCII
  name follows ("@alice2" is not alice). The Web Shell entry uses the
  same rule to pick the lead.
- thread_create reused any same-titled child, including a finished,
  cancelled or reassigned one, so the new hand-off was silently never
  made. It now reuses only a live child with the same assignee.

The web chat hides a run's live preview once the run's answer exists as
a post, so it is not shown twice.

* fix(core): classify the deferred tool bridge for workspace agents

main added the tool_call bridge after this branch forked, so the
exhaustive classification record no longer compiled and every CI build
failed. It can invoke any registered tool, so it is denied like exec.

* feat(web-shell): keep waiting, retry and approval errors

A quiet run's stall warning now offers Keep waiting (hides it for five
minutes) next to Stop. An agent whose latest run failed or timed out
gets a line with Retry, which asks it to continue where it stopped. A
failed approval shows the daemon's reason in the error banner and
reopens the card instead of silently reverting.

* fix(serve): type fixes for the agent event watcher and stall test

* feat(serve): join a coordinator as a runtime with one link

`qwen serve --join <link>` takes the single link the coordinator shows
under Agent → Runtime → Add runtime (<coordinator>/join/<workspace>/<token>)
instead of --agent-host-server, --agent-host-workspace-id and the
QWEN_AGENT_HOST_ENROLLMENT_TOKEN variable typed separately. The token is
single-use and short-lived; the host saves its own credential after
enrolling, as before.

A host now advertises every program it can run (Qwen Code always, Codex
when its CLI is on PATH or was asked for) so one machine appears as one
runtime offering several programs.

* feat(web-shell): Agent page tabs and a one-line Add runtime dialog

The Agent page now always shows its three tabs (Agents, Conversations,
Runtimes); they were hidden whenever the page was opened from the
sidebar, which instead carried three sub-links. The sidebar keeps a
single Agent entry.

Add runtime is a dialog: it issues a single-use join token, shows
`qwen serve ... --join '<link>'` (and an npx variant) with copy buttons,
warns when the address is localhost or plain HTTP, and waits — the live
stream flips it to "<name> is connected", listing the programs that
machine offers, with a shortcut to create an agent on it. The old inline
enrollment and remote-connect forms are gone; connecting an existing
serve by address and token is the dialog's second tab. The client no
longer builds the long enrollment command itself.

Removes web code the PR audit confirmed dead: ThreadConversations.tsx,
summariseWorkspaceWork, ThreadGroup.label, the unreachable create-method
chooser in AgentCreatePage, the initialCreateTask/api/runtime/loading
props, unused CSS module classes and a duplicated prop spread.

* refactor(agents): remove dead workspace-agent code confirmed by audit

Drops the scripts/audit harness (not wired into CI), request capture,
the Codex turn-result adapter, the notification pipeline nothing could
trigger, unused store/dispatcher/lease/A2A exports and legacy
background-agent plumbing. Recover only reports work when a parent
report is actually pending.

* fix(web-shell): hide agentCollaboration from the settings message

The experimental flag has no alias in the settings dialog map, which the
alias-drift test rejects.

* feat(agents): bind agents to a runtime program and share them over A2A

An agent on a runtime can pick the program to run (Qwen Code or Codex)
when that runtime offers both; the host runs each assignment with the
program its agent is bound to and falls back to its default otherwise.

Agents can be shared with callers outside the workspace: each share is
its own A2A grant with a 7-day expiry, the token is shown once with a
ready-to-run SendMessage curl, and any share can be revoked.

A failed run's retry row now hides while that agent has a live run and
once the conversation is closed.

* fix(agents): push runtime liveness changes, not every heartbeat

Heartbeats rewrite hosts.json every five seconds, and each write made
every open tab refetch the whole roster and thread list. A runtime that
died wrote nothing, so it never showed as offline without a reload.

The event stream now compares what a person can see (runtimes, their
programs, online or offline) on each hosts.json write and on a
five-second timer, and only announces a change. Add runtime also
recognises a machine that rejoins under its saved identity.

* feat(web-shell): polish Agent page, New agent and the @ picker

- New agent needs only a name; it can start from a .qwen/agents role,
  preselects the runtime it was opened for, and shows both programs
  with the reason one is unavailable.
- Agent rows read "Program · Runtime", offer "@ Mention" and fold
  configure/share/pause/retire into a menu; moving an agent to another
  runtime keeps its program.
- The @ picker shows each agent's program and runtime and ends with
  "New agent…"; at-items can now be actions via onSelect.
- The collaboration composer says who a reply reaches and that other
  members are not interrupted; skip reasons and leftover strings are
  localised.

* feat(agents): show a running agent's tool calls as steps

A running member now lists its latest tool calls under its status line,
one per line with running, done or failed, like a CI job's steps. The
turn stream reports updates as an object so steps travel with stage and
text; remote runtimes still report stage and text only.

Also: subtasks in the team panel name their assignee, join links last
15 minutes as the spec says, and a share created from a localhost page
warns that its address only works on this computer.

* fix(agents): skip a liveness check when the store is busy

A failed hosts.json read looked like every runtime leaving, which
announced two spurious changes.

* feat(agents): let a person's post pass the token budget

The tree-wide token cap now binds only agent-authored posts, like the
turn budget: agents on an exhausted tree stop waking each other, while
a reply from a person still books work. Every automated path posts as
an agent or the system, and A2A intake always starts a new root thread,
so nothing unattended gains a bypass.

* refactor(agents): move cross-runtime and A2A to a follow-up PR

This PR now carries local collaboration only: agents on this computer,
shared threads, streaming, waiting states, the @ picker and the Agent
page with Agents and Conversations tabs.

Moved out, to return in a stacked PR: agent hosts (join links, the
--join and --agent-host-* flags, enrollment, heartbeat, lease and
pickup routes, the host client and Codex host sessions), per-agent
runtime and program binding, the Runtimes tab and Add runtime dialog,
the A2A server, grants, external intake and agent sharing, and the
@a2a-js/sdk dependency. Records written by builds that had them keep
loading; the extra fields are ignored.

* fix(web-shell): polish found by rendering the collaboration views

- The @ picker read its labels through useI18n() above App's
  I18nProvider, so its category, agent states and "New agent…"
  showed raw keys; App now passes its translator in.
- The team panel called a quiet run "connection lost" after 20 s
  without a snapshot, which a long tool call always triggers. It now
  judges by the agent's own activity, and its copy is localised.
- Context carried over from the originating chat is folded under the
  header instead of rendered as a large user message.
- Starting and resuming runs get their own status line.
- Agent rows keep their columns aligned, and a paused agent reads
  "Paused" rather than "Offline".
- The mesh-streaming spec aborts /events (it had thrown on it), runs
  in a zh-CN locale that survives reloads, and matches current copy.

* feat(web-shell): a typed @name lists matching agents

Typing @re used to fall back to file search and show nothing, so an
agent was only reachable by first picking the Agent category. An @
provider can now claim a typed query that names no category; the agent
provider claims it when the query starts an agent's name, and anything
else (e.g. @src/) still searches files.

* feat(agents): show queue position; cover the live stream in e2e

- A queued run reports how many of its agent's queued runs start
  first (priority, then queue order, as the dispatcher takes them), and
  the status line reads "queued, N ahead" when N > 0.
- The mesh spec gains a case where a reply arrives only over the
  /events stream, so the push path is exercised, not just polling.
  Checked that it fails when the frame targets the wrong run.

* perf(web-shell): keep collaboration strings out of the transcript bundle

The /export html transcript embeds the whole dictionary, and the
collaboration strings (about 22 KB in two languages) pushed it past its
1.3 MB budget once the runtime and share copy landed. They move to their
own module that the transcript build stubs, exactly as Live Voice's do;
tests guard the split, the stub's shape and that no transcript surface
looks a collaboration key up.

* fix(agents): hand-offs that stranded parents or replayed finished turns

- A child thread that answers in plain text now reports to its parent;
  before, a waiting parent never heard back.
- Starting a thread from chat sends the person's message as the
  assignment, so the first run starts from it instead of receiving it
  mid-turn.
- A turn that ended before reading a later message completes, and the
  unread message goes to a successor run instead of replaying the turn.
- A status post earlier in a run no longer hides its final answer.
- The agent input receipt no longer needs chat recording.
- Smaller: charge usage on recovery failure, clear a requeued run's
  progress, reject retired assignees in thread_create, fix a subscribe
  race that could orphan file watchers, and cap the sidebar's live
  streams at two.

* fix(web-shell): collaboration screens in the reader's language

Rendering both languages showed the collaboration UI mixing them: run
triggers and status reasons arrived from the server in English (the
live-run line in Chinese), the task details view and the new
conversation form hard-coded one language each, and the sidebar load
error was Chinese only. All of it now goes through messages.ts, and the
server's fixed sentences are translated one for one on the client.

Also from the render:
- A streaming answer sorted by the run's start, so it sat above a
  status post the same run made later.
- Durations read "585 秒" in one place and "9 分 45 秒" in another.
- "Open conversation" floated unstyled above the details header; it is
  now a header button, and the conversation context is folded there as
  in the chat.
- Approval buttons use the main app's labels for allow and reject.

* test(web-shell): render the collaboration screens in the visuals preview

No visuals scenario reached the collaboration UI, so a PR that changed
it got an empty preview, and the one browser spec for it lacks @smoke
and never runs in CI. Six views in both themes now: the conversation,
its team panel and details, the @ picker in an ordinary chat, the
Agents page and the conversation list. One thread carries every run
state the screens tell apart, the server's own status sentences are
used so their translation is what renders, and each scene asserts its
content before capturing, so an empty picker fails instead of
photographing.

The spec is added to the base arm's allowlist so later PRs get a real
before/after instead of a "new scenario" every time.

* docs(mesh): record live collaboration verification

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

* test(web-shell): add DAEMON_APPROVAL_MODES to sidebar delete-test mock

The mesh sidebar changes pull ThreadsRoute -> AgentCreatePage into this
test's module graph; AgentCreatePage reads DAEMON_APPROVAL_MODES from the
mocked daemon-react-sdk at module scope, so the suite failed at collect
time. Match the sibling sidebar mocks.

* fix(agents): close review-found wedge and boundary holes in workspace agents

- Disabling an agent now settles its queued runs as cancelled in the
  roster transaction: a queued run of a non-addressable agent could never
  start yet pinned its thread in in_progress and blocked retirement, with
  nothing in-band explaining why.
- thread_wait admission no longer counts finishing runs: a finishing run
  already executed its close tool, so a wait admitted against it could
  never be discharged and stranded the thread as blocked.
- claimRun re-checks thread terminal status under the lock; candidate
  selection reads the thread list outside it.
- Agent store files are written 0600 with 0700 directories; transcripts
  hold full conversations and previously landed at the umask default.
- Web Shell: Retry is offered only for roster-addressable agents;
  hand-typed @lead detection applies the picker's enabled/retired
  predicate; the sidebar session merge keeps the daemon catalog order
  the organized bucketing relies on.

* fix(web-shell): add zh-CN entries for experimental.agentCollaboration

Merging main brought in workspace-settings-i18n.test.ts, which requires every
served setting to have settings.label.<key> and settings.description.<key> in
SETTINGS_MESSAGES_ZH. This branch added experimental.agentCollaboration to the
settings schema before that gate existed, so the merge surfaced exactly two
missing entries and failed Test (ubuntu-latest, Node 22.x) with
"expected [ ...(2) ] to deeply equal []".

Add the two zh entries next to the sibling experimental.agentTeam pair, in
schema order. English needs no entry: SETTINGS_MESSAGES_EN carries no
experimental.* labels and the Web Shell falls back to the schema's English
label/description.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmuiw1qih83

* feat(agents): split A2A sharing from remote runtimes

* feat(agents): add durable collaboration state

* refactor(agents): split web shell from execution

* fix(serve): revoke stale agent event streams

* feat(web-shell): add workspace agent collaboration UI

* chore(web-shell): drop historical mesh test log

* fix(web-shell): gate restored agent threads

* fix(web-shell): isolate agent collaboration scopes

* fix(agents): harden A2A intake boundaries

* fix(agents): stop a cancelled child from pinning a waiting parent

hasLiveDescendant counted any non-'open' child as live, so a cancelled
sub-thread with nothing left to deliver kept its parent's waiting run
forever — the no-live-dependency guard could never fire and the thread
stayed stuck in progress. A terminal child (done or cancelled) now
counts as live only while it still owes a pending parent_report; the
done-with-pending-report case keeps waking the parent as before.

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

* fix(agents): preserve recoverable workspace state

* test(settings, tools): restore agentCollaboration registry coverage

The split refactor (8366c8d9) dropped the zh-CN entries while the setting
stayed served, which the i18n coverage gate rejects; the settings panel row
also lacked a published alias, which the alias-drift gate and the
settings-exclusions E2E both require. The six thread tools join the
tool-surface budget's not-budgeted list: they are registered only inside
agent-collaboration runs and never reach the general request surface.

* fix(agents): close second-round review findings in collaboration runtime

Core:
- Parent reports neutralize @ in child-controlled text so report content
  cannot suppress the assignee wake or wake the wrong agent.
- First delivery into a long thread discloses the retained pre-window
  prefix instead of committing the watermark past it silently.
- The DELTA section is bounded to the recent window with an explicit
  omission line; the rest stays reachable through thread_read.
- An unreported terminal run no longer suppresses child_blocked, while a
  reported one no longer double-reports; and a person-done thread never
  stacks child_cancelled on top of child_done.
- A stranded run is its own blocking obligation, human-released only,
  instead of an auto-releasable ordinary failure.
- The dead-end rule reads the most recent human admission, so a later
  system report cannot flip a genuinely blocked thread back to
  in_progress.
- Unknown @mentions now require a near-miss of a roster name, so prose
  like @media or @param no longer carries routing authority.
- Disabling an agent settles queued runs before the roster write, and the
  settle re-runs on retry, so a failed scan cannot half-apply the change.

Daemon/CLI:
- A rejected cosmetic session title can no longer turn a successful spawn
  into a terminal launch failure.
- The recovery sweep tests roster addressability (matching the DELETE
  teardown) and tears down workspaces where no agent can take work,
  draining pending parent reports first.
- Teardown awaits the owner's in-flight tick before releasing the host
  session, closing the orphan-spawn race.
- Session catalogs exclude both agent-host and agent body sessions via a
  plural excludeSourceTypes list, so --continue, pickers, and listings no
  longer surface machine-owned sessions; branch creation no longer
  conflicts with a resident mesh agent; restoring a finished agent's
  session strips the agent source so the transcript stays readable.

* fix(agents): keep status reasons English, seed unreadable thread

`ThreadStatusResolution.reason` is documented as "why, in a form a UI can
show beside the status", and every reason `resolveThreadStatus` returns is
English except the two `in_progress` ones, which were Chinese — a consumer
would render mixed languages to the user. Count them in English instead
("1 Agent is running", "2 Agents are running, 1 queued") and pin the
plural/mixed branch, which nothing covered before.

`store.test.ts` seeded the unreadable-thread case with `{}`. A record with
no `schemaVersion` makes `assertKnownVersion` raise `AgentSchemaVersionError`,
which `listThreadsUnlocked` deliberately rethrows instead of deferring, so the
test asserted the deferred "thread records are unreadable" error while the
code raised the fail-closed schema one. That is the red `Test (ubuntu-latest,
Node 22.x)` lane on 45a9668a8f (job 108654095052). Seed a versioned but
unparseable record, which is what "unreadable" means on this path; the
fail-closed schema behaviour keeps its own coverage in the version tests.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmukdzlxfat

* fix(serve): resolve the collaboration opt-in per workspace

The daemon mounted the agent routes from a boot snapshot that skipped
workspace scope, while every hosted session resolved the flag from the
full merge where workspace scope wins — the two could disagree about the
same workspace. The route gate and the recovery sweep now consult the
per-workspace merge (env var still overrides process-wide), so a flipped
workspace takes effect without a daemon restart: its routes answer 404
agent_collaboration_disabled, the sweep strands its leftover live runs
and tears the owner down. The routes are only mounted at boot when some
registered workspace has the flag on, and first-time enablement on an
otherwise-unconfigured daemon still requires a restart, which the doc
row and the setting's requiresRestart marker now say honestly.

* fix(agents): harden the workspace lock and validate run progress

- Give the workspace lock an onCompromised handler like every other
  proper-lockfile site, and tolerate the ERELEASED release that follows a
  compromise so the transaction's result still stands.
- Drop the per-workspace Mutex once nobody holds or waits on it, so a
  long-lived daemon does not keep one per workspace it ever touched.
- Validate the persisted `progress` snapshot: its outputText decides whether
  a completed run closes as unclosed, so a malformed value must not load.
- Replace citations of an external project and of review rounds with the
  reasoning itself; fix the stranded-runs copyright header.

* fix(serve): keep opted-out workspaces untouched and agent sessions owned

- Recovery skips an opted-out workspace that has no collaboration store
  instead of tearing it down, which read (and so created) the store every
  five seconds once any other workspace had opted in.
- A run whose task session is resident without its agent source (a person
  restored the transcript) closes it before reloading it as the agent's;
  attaching would run the turn without the persona, tool ceiling, or thread
  tools.
- `agent_collaboration_v1` is advertised only while the routes are mounted,
  so a workspace opted in after boot no longer gets a capability whose
  routes 404 until restart.
- A settings file that fails to parse keeps the last collaboration answer
  for that workspace rather than reading as "off" and stranding its live
  runs.

* fix(web-shell): follow the per-workspace collaboration opt-in

- A workspace that answers `agent_collaboration_disabled` is skipped by
  the sidebar: no load-failed notice, no event stream retrying a 404, no
  five-second poll. Streams open after the first read so none starts
  against such a workspace.
- An @ message whose roster cannot be read (collaboration off here, or the
  daemon unreachable) is sent as ordinary chat instead of failing, so
  `@README.md` in a non-collaboration workspace still sends.
- Status reasons are matched in the English wording the server now uses,
  including the stranded-run reason, which had no translation.
- A non-JSON error response no longer surfaces as a JSON parse error.
- The workspace session list is only re-sorted when collaboration rows are
  merged in, and a missing timestamp sorts last instead of yielding NaN.
- Move the remaining hardcoded collaboration strings into messages.ts.

* fix(agents): keep A2A work inside its grant and give it an end state

- An agent or system post in a tree opened over A2A books only the granted
  agent, and thread_create refuses to hand such a tree to another agent. A
  caller could otherwise ask the granted agent to relay "@other ..." and
  read the other agent's answer through GetTask. A local person can still
  bring anyone in, and the task's answer is the granted agent's post only.
- An external task reaches a terminal state: once no run is live, a failed,
  cancelled or stranded run is FAILED and anything else is COMPLETED. It
  used to stay WORKING, or INPUT_REQUIRED with continuation refused, so a
  client polling for a terminal state waited forever.
- Errors that are not A2A errors are logged and answered as a generic
  internal error instead of passing store messages (absolute paths, other
  threads' ids) to the caller; a malformed task id is TaskNotFound.

* fix(serve): gate A2A on the per-workspace collaboration opt-in

The merge of the per-workspace opt-in left the A2A registration reading
the removed boot-time `agentCollaborationEnabled`. Register the A2A routes
when any workspace has collaboration on, like the rest of the surface, and
treat a request for a workspace that has opted out as unauthenticated, so
grants left on disk stop working with the rest of its collaboration routes.

* fix(serve): accept the agent-host bridge's closeSession in the dispatch port

1d3173a53e picked closeSession from AcpSessionBridge, whose Promise<void>
return the agent-host bridge's Promise<unknown> does not satisfy, so the
cli build failed. The port only awaits it, so declare it loosely.

* fix(agents): close the stranding paths the lifecycle review found

- Only a near-miss of a roster name is an unknown mention; `@Override`,
  `@media`, a long npm scope no longer suppress the assignee. A shorter
  ASCII name no longer matches into a longer Latin word (`@maría`).
- `hasLiveDescendant` requires evidence of pending work (a live run, an
  outstanding obligation, or a report owed), not a non-`open` label, so a
  wait cannot rest on a quiet child.
- A close discharges peers' waits by stamping their runs, including a
  waiter still `finishing` that the same close just woke.
- A blocked child whose failure was never reported now tells its parent
  (child_blocked), and a done thread does not stack child_cancelled.
- `strandLocalRuns` settles the thread status, ends `cancelling` runs as
  cancelled, and only treats ENOENT as "never used"; stranded runs get
  their own obligation kind and reason.
- `updateWorkspaceAgents` always writes, the outbox default filter owns
  parent reports only, a `system` author defaults to `authorKind: system`,
  and the live-run status set is shared.
- Split the progress validator into small predicates (Prettier gate).
- Tests: stranded-runs, finishing waiter, quiet child, blocked-child report,
  prose mentions, cancelled thread, retired agent, re-enable, tree-scoped
  budget; retention bounds derive from the constants.

* style(agents): fit the a2a-server store import on one line

* style(agents): drop a stray blank line in the workspace store

* fix(serve): consult only trusted workspaces for the collaboration opt-in

anyAgentCollaborationEnabled read the settings of every registered
workspace, including untrusted ones, whenever capabilities or route
registration were evaluated. An untrusted workspace cannot use
collaboration, and loading its settings writes debug logs during what must
be an untrusted-safe read (multi-workspace-sessions.test).

* fix(web-shell): read the workspace only in the agent activity tab; show the collaboration setting

ArtifactPanel called useWorkspace() on every render, so hosts rendering it
without a workspace provider (SourcePreview.test.tsx on main) threw. The
agent activity tab now reads it itself.

The settings panel hid experimental.agentCollaboration while settings.ts
publishes an alias for it, which the settings alias drift test rejects.
The setting is per-workspace and carries requiresRestart, so it is shown.

* fix(agents): make the thread retention bounds bind

trimThread kept every run that had spent tokens, so MAX_THREAD_RUNS never
dropped a real run, the messages those runs referenced kept
MAX_THREAD_MESSAGES from binding too, and the outbox was never trimmed at
all: a long-lived thread file grew without limit and was rewritten whole
under the workspace lock on every write.

- A run past the bound is dropped whether or not it spent tokens; its spend
  is folded into the thread's new optional `trimmedTokens`, and
  `threadTokens()` (runs plus trimmed) feeds both `tokensUsed` and the
  tree budget at admission, so the budget does not reset.
- Acknowledged outbox events beyond MAX_ACKNOWLEDGED_OUTBOX (50) are
  dropped; pending ones are always kept. An acknowledged event is never
  replayed, and replay idempotency rests on the message's originEventId.

* fix(agents): hold running work to the tree token budget

The budget was checked only at admission, so a run already going kept
spending until it ended (a crash-replayed run closed at 666,749 against a
200,000 budget). Each dispatch pass now records every running run's spend
so far — written only when it moved — and moves the running runs of a tree
at or over DEFAULT_THREAD_TOKEN_BUDGET to `cancelling` with error
`token_budget_exhausted`; the ordinary cancel path then stops them. The
overshoot is at most what a run spent since the previous pass (the host
ticks every second). A run a person triggered is left running, matching the
admission rule that a person's post passes the budget.

* docs(verification): hand off the workspace agent token budget acceptance matrix

* docs(verification): align the acceptance matrix table

* docs(verification): end the budget brief with a newline

* fix(serve): keep the collaboration settings probe out of session debug logs

isAgentCollaborationEnabledFor runs at boot and per request through
loadSettingsCached, which logs a cache hit/miss line on every call; those
lines landed in whichever debug log session was ambient and broke the
untrusted-read isolation test in multi-workspace-sessions.test.ts. The probe
now runs under runWithoutDebugLogSession.

Also fix the new budget tests: port() drops unknown overrides, so
totalTokens has to be spread on after it.

* fix(agents): classify structured memory tools

* refactor(agents): keep execution opt-in out of UI

* feat(web-shell): expose agent collaboration setting

* fix(web-shell): add display names for the thread_* wire tools

The six new thread tools landed without display-name entries, tripping
the TOOL_DISPLAY_NAMES drift guard (Test lane). Pin all six.

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

* docs(agents): align collaboration PR stack

* fix(web-shell): deduplicate agent tool labels

* fix(agents): isolate disabled collaboration paths

* perf(web-shell): lazy-load collaboration workspace

* fix(agents): close A2A acceptance gaps

* test(web-shell): inspect split output chunks

* test(web-shell): stabilize collaboration completion capture

* fix(web-shell): expose workspace agent sharing

* feat(web-shell): render agent collaboration with the chat's own components

A collaboration conversation had two renderings: the chat, and a separate
"Task details" page with its own look (numbered posts, a plain textarea and
Send button, a native select, monospaced names). The chat itself also
hand-rolled pieces the transcript already has. This makes the conversation
one ordinary Web Shell chat and moves everything else into the Team panel.

- Agent replies and thoughts carry an `author` (name + agent color) that the
  message list renders as a sender row, instead of a bold name in Markdown.
- Live work sits in the transcript's "Parallel agents" group (its classes are
  reused), headed by the server's status sentence.
- Approvals are the main chat's ToolApproval panel.
- The composer toolbar says who a draft will reach.
- The details page is removed. The Team panel lists members once, each
  expanding to its run detail, with "Make lead", subtasks, history and the
  budget. Conversation rows on the Agents page open the chat.
- The Agents page uses the role templates page's frame: title, actions,
  a toggle group, and cards with the same avatar.

* fix(web-shell): keep agent collaboration out of the Plugins page

The Plugins page embeds the Agents manager without a chat to open a
conversation in, so a conversation opened there had no Team panel. Show
collaboration only where the host can open its chat (the sidebar's Agents
entry); the Plugins tab keeps managing role definitions.

* fix(web-shell): preserve collaboration chat controls

* fix(serve): make the agent host owner lifecycle race-free

Two overlapping dispatch() calls could each build an owner: the
check-then-create window awaited the replaced owner's stop() before
publishing to the map. The loser never reached `owners`, so neither
teardown path could ever see it and its keepalive timer ran for the life
of the process. Build and publish synchronously, then drain the replaced
owner.

AgentHostSessionOwner.stop() was idempotent but not single-flight. A
boolean flag let every caller after the first resolve at once, without
awaiting the [inFlight, dispatching, ensuring] drain that its own comment
says the callers' teardown order depends on — so a DELETE racing the
recovery tick could release and close the host session while an ensure()
was still inside spawnOrAttach. Memoize the drain so every caller awaits
it; clearInterval still runs synchronously before the first await.

Also narrow two comments that claimed more than the code does: the
mid-edit settings fallback covers the user and system scopes only,
because the loader recovers a workspace-scope parse error into a valid
`{}` instead of rethrowing, and `agentCollaborationEnabled` is resolved
per response rather than once at boot.

R4-3, R4-6 fixed; R4-4 narrowed to what the code actually covers;
capabilities half of R4-1.

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

* fix(web-shell): correct collaboration UI state, mention routing and stream handling

Mention interception dropped core's second boundary guard, so a longer
Latin word resolved to a shorter agent name ("@maría" to `mar`) and an
ordinary chat message was silently converted into work assigned to an
agent nobody addressed — one the server-side parser says was never
mentioned. Add the guard, matching `agentForToken`.

`describeRun` returned the failure copy before consulting `closeKind`,
which left its own `stranded` case unreachable and labelled every
stranded run "failed at collaboration-disabled": an internal constant
interpolated into a localized sentence, asserting an agent failure where
nothing failed. Check `closeKind` first, the order core's `obligationFor`
already uses.

`teamMembers` ranked a member's runs by `startedAt ?? 0`, so a run
cancelled or failed while still queued always lost to an older run that
did start, contradicting the field's own "otherwise the latest" contract.
Use `endedAt ?? startedAt`, the recency key `lastActivity` already uses.

The member status tone was a single-class Tailwind utility tied on
specificity with `.memberStatus`'s own `color`, so the later-injected CSS
module sheet won and every status line rendered muted grey. Move the
tones into the module, where every sibling status colour already lives.

The agent event stream was declared open on `response.ok` alone, so a 200
that is not an event stream reset `retryMs` on every cycle: the backoff
never grew and the open/closed flap kept recreating both consumers' poll
intervals before they could elapse. Require the content type.

`onOpenCollaboration` was the only sidebar navigation handler that did
not close the mobile drawer, leaving the drawer and its pointer-capturing
backdrop over the thread it had just opened.

Give the ArtifactPanel collaboration-gate test the flush its positive half
already had. Without it `<Suspense fallback={null}>` satisfied `toBeNull()`
whether or not the gate existed, so the negative half could not fail.

R4-10, R4-11, R4-12, R4-13, R4-15, R4-16, R4-17.

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

* docs(agents): state the real capabilities contract and name the built daemon

`agent_collaboration_v1` is recomputed per request rather than snapshotted
at boot, so "absent means no workspace had it on at startup" was false in
both directions: the tag also goes absent once every workspace has opted
out, and the pre-runtime bootstrap envelope omits it while reading no
workspace settings at all. Say what absence actually encodes.

The token-budget verification doc told the witness to start `qwen serve`,
which runs whichever installed release PATH resolves to rather than the
artifact step 1 just built — a daemon without this PR, whose every matrix
row then 404s and reads as a budget-contract failure. Name
`node dist/cli.js serve`, matching the sibling verification docs, and ask
the witness to confirm the tag before starting.

R4-2, and the protocol-doc half of R4-1.

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

* fix(serve): close inactive agent sessions safely

* fix(agents): close the four open R4 review findings

R4-4: the collaboration opt-in predicate now loads settings with a new
`preserveInvalidWorkspaceSettings` option. A half-written workspace
settings file used to be "recovered" by rewriting it to `{}`, which the
predicate read as an opt-out — the recovery tick then stranded every live
run, and the rewrite made the opt-out permanent. The strict load throws
instead, so the predicate keeps its last answer and the file is left alone.
`loadSettingsCached` keeps strict entries apart from default ones.

R4-5: session listings and counts hide `agent` sessions on the live side as
they already did on the persisted side (organized view, metadata filter,
catalog live merge, `/sessions` live path, content search, session-info
`live`), through one `isHiddenCatalogSource` predicate.

R4-7: a skill loaded in a workspace-agent session applies none of its side
effects (hooks, allowedTools grants, review workflow). Its body still
reaches the model. A skill hook spawns a command and PreToolUse fires
before the invocation guard, so it was a way out of the read-only
boundary. `Config.isWorkspaceAgentSession()` is now the single gate used by
the tool registry, the invocation guard and this check.

R4-8: `agent` joins `agent-host` as a reserved source on REST
create/load/resume and ACP-HTTP `session/new`, and ACP-HTTP `session/load`
strips a persisted `agent` source as REST restore already does. A client
could otherwise restore a session under an agent's deterministic session id
with `sourceType: 'agent'` and have the dispatcher send a real run into it.
Only the in-process dispatcher creates agent sessions.

* fix(agents): close remaining collaboration review gaps

* fix(agents): deny skill command fallback

* fix(web-shell): restore transcript size gate

* fix(agents): deliver a reused sub-thread title's new hand-off details

The retry-dedupe predicate keys only on (parent, lowercased title, assignee,
non-terminal), so a second, genuinely different delegation that happens to
reuse a title was swallowed: its body and acceptance criteria were written
nowhere, no message reached the reused thread, and the tool still returned
ok() — so the parent's later summary reported work as delegated that no one
was ever told about.

Dedupe still has to hold (a retry must not duplicate a live hand-off), so the
reuse branch now posts this call's task and acceptance criteria onto the
existing thread through the same admission path as the create branch, and
stays silent for a bare retry that carries nothing new. The reply says the
details were posted rather than implying nothing happened.

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

* fix(serve): report the tree token total admission actually enforces

`budget.tokensUsed` was computed by an inline sum over `run.usageByRound`
only, duplicated at two sites, so it omitted `trimmedTokens` — the spend of
runs retention dropped. Admission charges the tree with
`threadTokens() = (trimmedTokens ?? 0) + sumRunTokens(runs)`, so once a thread
passed MAX_THREAD_RUNS the route reported a smaller number than the one the
server enforced: a nearly-empty budget bar next to a run closed for
`token_budget_exhausted`.

Both sites now call the store's exported `threadTokens()` helper instead of
re-deriving the total, so there is a single implementation left to drift.

This also fixes the token-budget verification recipe, which named that field as
its only observation surface and so read row 6's retention invariant as
violated, and whose step order edited `DEFAULT_THREAD_TOKEN_BUDGET` after
building — esbuild inlines the constant, so the edit never reached the running
daemon and the whole matrix executed against 1_000_000 rather than 20_000.

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

* fix(web-shell): order the live transcript by the daemon's clock

`applyProgress` stamped the browser's clock into `progress.receivedAt`, a field
the daemon writes with its own clock. `ThreadChat` uses it as the transcript
sort key against daemon-written `post.at` and `run.startedAt`, so with a remote
daemon — an explicitly supported shape — a browser clock two minutes behind
folded every streamed frame to `serverNow - 120s` and sorted the agent's live
output above the posts it was replying to.

The progress event already carries `activityAt`, which the daemon refreshes on
exactly this frame, so it is the same instant on the same clock.

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

* fix(agents): close remaining review gaps

* perf(agents): list threads once per store transaction

Admission, closing and disabling each call listThreads() several times
inside one workspace lock hold, re-reading and re-validating every thread
file each time; with many threads the hold outlasted another process's
lockfile retries (10 tries, 5-100 ms). The transaction now reads the
directory once and keeps the listing current through its own writes,
handing callers copies so an unwritten in-place edit cannot leak.

* fix(web-shell): rank the retry candidate by the run's latest activity

retryable picked each agent's latest run by startedAt alone, so a run that
failed while still queued (no startedAt) lost to an older run that did
start and no retry was offered. Use the same runRecency key as teamMembers
(R4-12).

* fix(agents): close the round-1 A2A review findings

- Withdrawing an external task cancels every thread in its tree, not just
  the root; a split-off sub-thread kept running while the caller was told
  the work stopped (R1-1).
- The transport checks workspace trust before the opt-in probe, so an
  unauthenticated request cannot make the daemon read an untrusted
  workspace's settings (R1-2). A failing grant-store read is logged and
  answered 500 instead of reaching Express's default error page.
- `toExternalA2ATaskState` decides a stranded wait from the obligations: a
  wait beside a question or a review is an answer (COMPLETED); only a wait
  left alone is FAILED (R1-3). It reuses thread-status's LIVE_RUN_STATUSES
  (R1-7).
- Reading, listing and cancelling tasks need a valid grant and an existing
  agent, not an addressable one: retiring or disabling an agent no longer
  hides its callers' tasks or leaves them uncancelable (R1-4).
- Resolve the caller's workspace over `list()`, excluding internal
  runtimes (R1-5); validate grant ids and expiry the way the store reads
  them back (R1-12); stamp TaskStatus with the thread's last activity, not
  the read time (R1-14); show the share's real expiry (R1-15); drop the
  unused grant mutators from the core barrel (R1-20); put Thread's JSDoc
  back on Thread (R1-22); let an agent split work to itself in an external
  tree (R1-24).

* docs(agents): state that an A2A share follows the agent's later configuration

Decided contract for what an issued share authorizes: it is checked against
the agent's live definition on every request, so changing the agent after
sharing applies to shares already issued. The share dialog says so, the A2A
contract doc (English and Chinese) records the decision and how to give a
caller less (revoke, or share a separate agent), and a test pins it.

* test(agents): hoist the caller in the live-policy share test

* style(web-shell): keep the share notice on one line

* fix(agents): preserve external task results and workspace ownership

* fix(agents): reject invalid grant creation timestamps

---------

Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
2026-09-30 08:17:13 +00:00
qqqysandShaojin Wen 23e0a451e6 fix(workflow): reject unsupported scripts and oversized batches early (#13012)
* fix(workflow): reject unsupported scripts and oversized batches early

A dynamic import() in a workflow script only failed when V8 reached it,
after earlier agent() calls had already been dispatched and paid for. The
shared compile step now parses the wrapped body and refuses any real
import() expression before the body runs, so inline, saved, resumed and
nested scripts are all refused without dispatching an agent or writing
run state.

parallel() thunks, pipeline() items and pipeline() stages are each
limited to 4096 entries per call. A longer list rejects the whole call
before any thunk or stage runs, as an ordinary rejection that can be
caught; run-level failures keep their existing propagation. Each list is
copied once after its length check so a Proxy or custom iterator cannot
stretch the batch past it.

The authoring reference documents both limits with a batching example.

* fix(workflow): keep the import check fail-closed with its own cause

Run the syntax-tree walk inside the same handler as the parse, and raise a
failure of either as WorkflowUnsupportedSyntaxError, so a script the check
cannot read is refused without the TypeScript/quoting hint meant for
ordinary syntax errors. Pin import() refusal in class static blocks, class
fields and parameter defaults, and regenerate the VS Code companion
notices for the new acorn and acorn-walk dependencies.

---------

Co-authored-by: Shaojin Wen <[email protected]>
2026-09-30 04:48:40 +00:00
2cfd390650 feat(computer-use): let a remote session use your desktop through a node_repl relay (#11799)
* docs(plans): add remote computer use plan for a local Mac

Plan for letting a Qwen Code session on a headless Linux dev box operate
the user's local Mac through computer use: the principle, the existing
pieces, three options, and a Mac companion that joins the reverse tool
channel.

* docs(plans): add handoff and slice-0 verification brief for remote computer use

Add a handoff document for the session or agent that continues this
work, and a real-Mac verification brief for slice 0. Update the plan
with a newly found pitfall: on macOS the driver's LaunchServices
auto-launch does not forward environment variables, so an auto-launched
daemon never enables the HTTP MCP endpoint.

* docs(plans): replace the driver relay with a node_repl relay for remote computer use

Rereading main shows the bundled computer-use skill never talks to the
qwen-cua-driver daemon: ComputerUse.create() embeds the driver inside the
node_repl process, and a standalone daemon rejects trusted sessions from
any connection that is not its embedding parent (serve.rs:787, error 77).
That invalidates the v1 plan's option B and makes options A and C relay
the wrong layer (the raw tool surface meant for external agents).

The plan now relays node_repl itself over the reverse tool channel, keeps
the skill's App API intact, and lists the one skill change this needs
(read references with read_file instead of inside node_repl). The
verification brief is rewritten around a ten-minute fact check on a real
Mac plus the slice-1 prototype acceptance; the handoff document follows.

* docs(plans): make the Mac relay a qwen subcommand, not a standalone app

Maintainer decision: the Mac-side relay for remote computer use is a
qwen subcommand (qwen mac-bridge) run from a terminal. That matches how
local computer use already works, where qwen launches node_repl from the
terminal and the TCC grants are recorded against the terminal, so it
adds no install step and no new permission identity.

The plan drops the signed-app, bundle-id and menu-bar requirements, makes
Ctrl-C the local stop control, and demotes the signed host to an optional
slice that is off by default. The handoff document records the decision
so the next session does not reopen it, and the verification brief uses
the subcommand throughout.

* docs(plans): name the relay qwen bridge and drop the macOS-only framing

The relay is for any machine with a graphical session, not for macOS:
it only moves MCP frames, the SDK reports the connected platform, and
the skill already carries per-platform references. macOS is the platform
verified in this round because the TCC facts were read from its code
path. The subcommand is qwen bridge, one new top-level command in
packages/cli/src/commands/bridge.ts; the plan file is renamed from
mac-companion to desktop-relay and the handoff and verification brief
follow.

* feat(computer-use): let a remote session use this desktop through a node_repl relay

A Qwen Code session on a headless machine can now drive the desktop the
user is sitting at. That computer lends its own node_repl (with
@qwen-code/cua-sdk and the embedded driver) to one remote session:

- `node-repl-mcp desktop-relay install` puts the runtime under
  ~/.qwen/desktop-relay and registers a launchd socket on 127.0.0.1:47821
  in inetd mode, so nothing runs until something connects.
- The Web Shell gains a "Use this computer" footer entry. It hands the
  daemon URL, session and token to the relay, which asks for approval in
  a native dialog, starts node_repl and registers it for that session
  over the daemon's reverse tool channel. A dropped connection ends the
  relay; control never resumes without a fresh approval.
- Terminal sessions reach the same socket through an SSH RemoteForward;
  their first tools/call asks for approval.

One node_repl serves the daemon's several MCP clients: the relay answers
repeated initialize requests from the first result, rewrites request ids
and turns replies above the 10 MB frame cap into errors. The skill needs
no change; main already reads references with read_file when node_repl
cannot.

Not built, typechecked or tested locally; CI and the real-Mac brief in
docs/verification/remote-computer-use/ are the verification.

* chore(deps): record ws in the pnpm lockfile for node-repl-mcp

The pnpm lockfile is generated from package-lock.json by
`corepack pnpm import`; the previous commit updated only the npm one,
so frozen installs failed on the node-repl importer's new ws and
@types/ws specifiers. Both resolve to the versions the cli importer
already records.

* fix(node-repl): drop the console override in the desktop relay agent

The repo lints packages with no-console, and the override guarded against
output nothing on the agent path produces.

* fix(node-repl): restore the desktop relay agent entry point

The previous commit spliced runtime.ts at stale offsets and left it
unparseable. This applies the intended change to the last good copy: no
console override and no unused format import.

* fix: harden remote computer-use relay

* fix(node-repl): forward unambiguous cancellations through the desktop relay

Stopping a turn in the remote session sends notifications/cancelled for
the running tools/call. The relay dropped every cancellation because the
reverse channel does not say which multiplexed MCP client sent it, so a
node_repl cell kept driving the desktop after the user had stopped the
agent. Forward a cancellation, with its request id rewritten, when exactly
one pending request carries that id; still drop it when none or several
do.

* docs(computer-use): refresh the remote desktop relay handoff for another machine

Switch the verification setup to the pnpm install main now uses, add a
check that stopping a turn stops a running desktop cell, and add a
troubleshooting guide that walks the chain from browser to launchd to
daemon to macOS permissions. The handoff records the current state, the
two decisions still open, and why SSH workspaces do not replace this.

* docs(computer-use): record the Linux-side verification results for the desktop relay

The Mac-side end-to-end run is still blocked: this machine has no GUI and no
reachable macOS host. What could be verified here is recorded instead - the
relay's real HTTP surface over a genuine fd-0 socket with launchd's inetd
activation emulated (B.1-B.4 plus the edge cases curl cannot express), and a
code-level walk of the cancellation chain the 2026-09-23 fix relies on.

Two new findings: a failing osascript is indistinguishable from an explicit
Deny and leaves nothing in agent.log, and launchd's default ThrottleInterval
may serialize these short-lived per-connection processes, so it should be
measured first on the Mac.

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

* fix(web-shell): gate desktop relay and surface browser permission

* fix(computer-use): keep desktop relay usable after cancellation

* fix(computer-use): complete remote desktop relay acceptance

* fix(node-repl): harden desktop relay consent, lifecycle, and cancellation paths (#11799)

- skill: probe the desktop tool with `in` so the code-mode tools proxy
  does not abort the script before the fallback runs
- agent: bound the raw JSON-RPC buffer (64 KiB, matching the HTTP path)
  and retire tools/call cancelled while the consent dialog is open
- launchd: roll back the plist when bootstrap fails; confirm bootout
  actually unloaded the agent before deleting its plist
- consent: log dialog failures to stderr instead of reporting them as a
  human denial with no trace
- mcp-child-relay: forward an ambiguous-id cancellation to every match
  instead of dropping it, clamp relayed yield_time_ms under the daemon's
  30 s frame budget, and never let a throwing child reject handle()
- docs: replace the overlay merge recipe with a three-way merge, scope
  the tccutil reset guidance, and align the cancellation check with the
  channel timeout

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

* fix(node-repl): bound desktop relay connection failures

* fix(web-shell): preserve active desktop relay revocation

* docs(computer-use): correct setup and redact verification details

* fix(desktop-relay): close review security gaps

* fix(desktop-relay): harden process ownership and prewarm

* fix(desktop-relay): pin the setup command to a published node-repl version

DESKTOP_RELAY_INSTALL_COMMAND, packages/node-repl/README.md and
docs/users/features/computer-use.md all handed out
`npx -y @qwen-code/node-repl-mcp@latest desktop-relay install`. The
published `latest` (0.1.6) has no `desktop-relay` subcommand and its
entrypoint never reads argv, so it connects a stdio MCP server and blocks
on stdin: zero output, nothing installed, no launchd socket. The Web Shell
panel then re-serves that same string behind a Copy button in exactly the
`missing` phase the command produced, so the loop has no exit.

Pin the spec to the package version the way the repo already does for this
package (packages/core/src/skills/bundled/browser-use/skill.test.ts), and
bump packages/node-repl 0.1.6 -> 0.1.7 in this PR so the publish carries
the relay. Without the bump, cd-cua-driver.yml rejects the release with
"already exists with different integrity" and the pinned version would
still lack the subcommand. Sync the three version lines the release
validate-version gate reads (computer-use SKILL.md, browser-use SKILL.md,
computer-use user guide) plus the hard pin in
scripts/tests/release-workflow.test.js.

desktop-relay-client.test.ts now fails when the constant drifts from
packages/node-repl/package.json or reintroduces `@latest`.

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

* fix(desktop-relay): stop reporting relay state that was never confirmed

Five review findings shared one cause: the Web Shell panel and the launchd
teardown each asserted a state they had not actually confirmed.

- deriveDesktopRelayStatus let another session's live relay mask this
  session's connect failure, because input.error was only consulted after
  the other-session return. The error now wins over other-session; a relay
  that is live for this session still outranks it, since that is the truth.
- awaitingApproval and error were component-global while the probe was
  scoped by sessionId and daemonUrl, so switching session or daemon showed
  the previous one's pending dialog or stale error. Both are reset when
  either changes.
- retainLiveDesktopRelayProbe kept a live observation for any non-ready
  probe without limit, so a relay that vanished stayed "connected" for as
  long as the panel was mounted. Retention now expires after three
  inconclusive probes, which still covers a transient blip.
- disconnectDesktopRelay resolved void and never read the response status,
  so a revocation that never reached the relay was indistinguishable from
  one that did, and disconnect() cleared the probe unconditionally. It now
  reports success only on an ok response, and the panel keeps its last
  observation until the relay confirms, leaving Disconnect retryable.
- currentUid() returned 0 under sudo, so uninstall queried gui/0 while the
  agent was loaded in the invoking user's domain. The existing "still
  loaded" guard in uninstallLaunchAgent therefore could not see the agent
  and deleted the plist out from under it. SUDO_UID now supplies the
  domain; Number() rather than parseInt() rejects a malformed value
  instead of truncating it into a wrong uid.

Addresses review findings R3-5, R3-6, R3-7, R2-10 and R3-14. Each has a
regression test that was verified to fail when its fix is reverted.

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

* fix(desktop-relay): cancel stale approvals and bound registration

* fix(desktop-relay): keep revocation available when unreachable

* fix(desktop-relay): serialize consent and allow cold runtime startup

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: qwen-code-ci-bot <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
2026-09-28 06:43:22 +00:00
Shaojin Wen 692a5d7446 feat(managed-agent): Add the public API contract and contract tests (#12808)
* feat(managed-agent): Add the public API contract and contract tests

Move the reviewed Managed Agent OpenAPI into the server module as the
single source for the public and WebShell routes (v1.13), and check the
server and the WebShell client against it.

- A contract test compares the mapped Spring routes, the API records,
  and the real requests, responses, declared headers and SSE frames of
  every partial route with the spec. Current differences are recorded
  as expected failures that the later Stage D slices remove; a fixed
  gap that stays listed fails as well.
- The WebShell client types are generated from the spec without its
  planned surface, and a test fails when the committed types are stale.
- Rename, unarchive, archive and delete, which the server already
  serves, are recorded as partial routes.

Part of #12793.

* test(managed-agent): Cover archived reads and WebShell first input in the contract

Address review feedback on the contract test and its callers.

- The real-traffic scenario now reads an archived Session through get
  and list, and creates a WebShell Session with its first message. That
  records three more gaps: archived Sessions read back with status
  "archived", which the contract's enum lacks, and the WebShell create
  request carries the same "text" input type as the other D2 lines.
- A property that appears in several oneOf or allOf members counts as
  planned only when every member marks it planned.
- Replace the deprecated JsonNode.fields() calls.
- The managed-progress e2e spec builds a Session with agentId and
  events with data, as the generated types require.
- The design note records the gaps that the gap file cannot hold:
  undeclared error responses, the full-page has_more semantics,
  workspace-bound Sessions, and the merge order with W0d.

* test(managed-agent): Reject unknown gap categories and quiet the validator

Address the sandboxed verification of the contract test.

- Every line of the known-gaps file must start with a category that a
  test filters on. A line with any other prefix was never compared, so
  it could not be reported as resolved; it now fails every contract
  test and names the line.
- The validator treats keywords outside JSON Schema as annotations
  without logging, which removes the nine "Unknown keyword" warnings
  that loading the whole OpenAPI document produced on every run.
- The design note no longer says that no route changes status: archive
  and delete move from planned to partial, and none becomes implemented.

* docs(managed-agent): State exactly what openapi-typescript reads from supports-color

The override comment and the design note said the generator reads only
stdout.hasBasic, which 7.2.0 provides. It reads stdout and its hasBasic
flag; 7.2.0 exports the same stdout, false without a TTY, and the
truthiness check on stdout is what keeps colours off there.
2026-09-27 04:08:41 +00:00
易良andQwen-Coder 010941d202 fix(standalone): pin @lydell/node-pty-linux-arm64 and fail release on missing prebuilds (#12649)
* fix(standalone): pin @lydell/node-pty-linux-arm64 and make missing prebuilds fatal at release

The wrapper publishes a linux-arm64 prebuild at the same 1.2.0-beta.10 pin,
but the root and core manifests never declared it, so the linux-arm64
standalone archive and the linux-arm64 Desktop runtime both degraded to
"PTY not available" while every other target shipped a working web
terminal.

With the pin in place every shipped target resolves a prebuild package, so
the release archive build can stop degrading silently: release.yml now
exports QWEN_STANDALONE_REQUIRE_NODE_PTY_PREBUILD=1 next to the existing
audio-capture gate, turning a missing prebuild into a hard failure instead
of another #11872.

Fixes #11872

* fix(standalone): update pnpm lockfile for the linux-arm64 node-pty pin

The two package.json manifests gained @lydell/node-pty-linux-arm64 but
pnpm-lock.yaml was not regenerated, so every CI job failed at the
frozen-lockfile install with ERR_PNPM_OUTDATED_LOCKFILE.

* docs(standalone): align the linux-arm64 PTY guide and tripwire with the pin

Pinning @lydell/node-pty-linux-arm64 makes linux-arm64 a supported PTY target,
but INSTALLATION_GUIDE.md still told users that archive ships without PTY and
reports "PTY not available". CI could not catch the drift because
install-script.test.js pinned that exact sentence as its tripwire, so the guide
and the assertion have to move together.

List all five shipped targets in the guide, retarget the tripwire at the new
sentence, and refresh the matching stale comment in desktop-shell's
test-release.js degrade fixture - that arm now only fires for a target the map
does not know.

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

* fix(standalone): complete the node-pty linux-arm64 pin in esbuild externals

`create-standalone-package.js` documents that "esbuild.config.js keeps every
node-pty specifier external" so the bundled CLI resolves the platform prebuild
from `lib/node_modules` at runtime. The external list carried five of the six
pinned platform packages and omitted `@lydell/node-pty-linux-arm64` — the exact
platform this PR pins. Nothing imports that specifier statically today, so the
bundle is unaffected; the gap is that the invariant the archive's PTY support
rests on was one entry short of complete.

Probe (esbuild, `--platform=node --format=esm`, entry importing both
specifiers):

- without the entry: `Could not resolve "@lydell/node-pty-linux-arm64"`
- with the entry: builds clean and emits the import left external, matching
  the other five platforms

Also corrects two comments this PR rewrote in the previous round. Both claimed
the desktop degrade arm "fires only for a target the map does not know (a
future platform)". That trigger is unreachable: `desktopTarget()` throws
`Unsupported desktop target` for anything outside its five-entry allowlist and
`NODE_PTY_PREBUILD_PACKAGE` maps exactly those five, so `prebuildPackage` is
always a real name. Verified by running the script:

    $ QWEN_DESKTOP_SKIP_BUILD=1 QWEN_DESKTOP_TARGET=freebsd-x64 \
        node packages/desktop-shell/scripts/prepare-runtime.js
    Error: Unsupported desktop target: freebsd-x64
        at desktopTarget (prepare-runtime.js:351:11)

The reachable trigger is the one the warn string already names and the one
`test-release.js` reproduces four lines below its own comment: a known target
whose prebuild package is unpinned in the source root manifest.

Comment-only apart from the single external entry. prettier --check and
eslint --max-warnings 0 clean on all three files; node --check passes.

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

* test(standalone): pin the node-pty release gate and its target map

Closes out three review findings on the node-pty pin. Test- and
comment-only apart from one new export; no production behavior changes.

R1-1 prepare-runtime.js: the nodePtyPackageSpecs() docstring still claimed
its null return "is how an unsupported target degrades". desktopTarget()
throws for anything outside its five-entry allowlist, and
test-release.js:testRuntimeNodePtyTargetMapping() asserts
NODE_PTY_PREBUILD_PACKAGE's keys equal exactly that allowlist, so an
unknown target can never reach it. A null here is a dropped pin. Verified
by re-running that tripwire's extraction against the edited file: both
literals still match and keys == allowlist == the same five targets.

R1-2 create-standalone-package.js: export TARGET_PREBUILD_DIR and assert
every release target maps to a package the manifests actually pin. The
guide sentence this PR adds states that invariant while release.yml:707
makes violating it abort the whole archive step, yet the map had zero test
references. The expectation is derived from readNodePtyPackageSpecs() so
the version list stays single-sourced, and is one-directional because
@lydell/node-pty-win32-arm64 is pinned while no target maps to it.

R1-3 install-script.test.js: the release.yml assertion matched only the
variable name, which also appears in the packager's usage text, so nothing
connected producer to consumer and no test executed the fail() branch at
create-standalone-package.js:578. Tightened to the full wiring line and
added the behavioral twin of the audio-capture pair: gate on with the
addon removed must throw, gate off must still build a PTY-less archive.
packageFakeStandalone() gained an env option for this; spreading
process.env leaves its 15 existing callers unchanged.

Mutation-checked, each mutant applied alone then restored and md5
verified: win-x64 mapped to an unpinned name, gate reader renamed to '2',
gate polarity flipped to !==, and release.yml no longer evaluating to '1'
are each caught by a new or tightened assertion. The polarity flip was
previously undetectable on the PR lane, where the linux-x64 prebuild is
present so the gate is never reached.

Focused: install-script.test.js 130 passed / 11 skipped (141).
prettier --check and eslint --max-warnings 0 clean on all three files.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmufv3et42h

* docs(standalone): state the real node-pty degrade conditions in comments

prepare-runtime.js said the degrade arm fires only "where the repo pins
nothing", but nodePtyPackageSpecs returns null at the first unpinned name of
the pair, so it also fires when the wrapper pin is the one that went missing
while the warning names the prebuild package. Say "either name in the pair"
and note which package the warning names.

create-standalone-package.js justified the non-fatal default with "a developer
archive stays usable on an unmapped future target", which cannot happen:
main() calls copyNativeAddon first and it reads the same TARGET_PREBUILD_DIR
in an unconditional path.join(), so an unmapped target dies with a TypeError
in the audio step before copyNodePtyAddon runs. Replace the rationale with the
case that does reach the warn arm (a target whose prebuild the host install
does not have).

Comment-only; no behaviour change.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmug1iw422v

* test(standalone): make the node-pty gate assertions discriminate

Pin the `Required ` prefix on the fatal-gate regex. copyNodePtyAddon builds
one message and emits it on both paths, so the prefix-less regex was also
satisfied by the degrade warning folded into the child's stderr — the suite
would have reported the gate as covered after the prefix was dropped from
fail(). The sibling clipboard gate assertion already spells the prefix.

Assert QWEN_STANDALONE_REQUIRE_NODE_PTY_PREBUILD structurally on the `Build
Standalone Archives` step env instead of as a whole-file substring, so
commenting the line out or moving it into a neighbouring step's env goes red.
build-standalone-release.js spawns the packager with no env override, so that
step's env is the only route to the gate. The comment defending the substring
pin named a collision with create-standalone-package.js's usage text, which
cannot occur: the searched string is release.yml alone.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmug1iw422v

* test(standalone): cover the node-pty target map and the win-x64 dir name

Two coverage gaps left by the previous round of gate tests:

- The map test looped over TARGET_PREBUILD_DIR itself, so a release target
  with no map entry was never visited, deleting a whole entry merely removed
  an iteration, and an emptied map passed vacuously. Drive the loop from
  TARGETS and assert the lookup before the pin. Still one-directional:
  @lydell/node-pty-win32-arm64 is pinned while no target maps to it.

- Both gate tests reached the packager through packageFakeStandalone, which
  hardcoded --target linux-x64, the one dialect where the prebuild dir name
  equals the target name. Add a win-x64 arm so the 'win-x64' -> 'win32-x64'
  translation in copyNodePtyAddon is covered on every lane rather than only
  on test_windows. Parameterise the staged-modules fixture and the packaging
  helper to make that reachable without a Windows host.

Test-only change; no production code is modified.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmug5t7mp33

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-25 04:38:07 +00:00
jinyeandQwen-Coder c822995d3a feat(cli): Add managed runtime attestation contract (#12447)
* feat(cli): Add managed runtime attestation contract

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

* fix(cli): Strengthen runtime attestation conformance

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-22 08:52:01 +00:00
ChiGao 74b5eb9e6d chore(cli): bump the OpenTUI renderer to 0.5.10 (#12321)
* chore(cli): bump the OpenTUI renderer to 0.5.11

The upstream release makes wrapped layout byte-accurate, which removes an
extra leading space on wrapped paragraph continuations and stops greedy
wrapping from breaking a line short. Both were visible divergences from the
incumbent renderer. It also adds line breaking between CJK characters.

* chore(cli): pin the OpenTUI renderer to 0.5.10 and gate the wrap fix

0.5.11 stops printing the markdown link target text, which is the only
thing link-click.ts can match on to implement click-to-open, so the
newer release silently breaks that path. 0.5.10 carries the same three
wrapping fixes without that regression and declares no engines floor.

Add a wrap-parity golden covering the paragraphs whose framing drove the
bump, wired into the tui-parity workflow, so re-pinning to 0.5.8 or
accepting a later release that regresses wrapping turns CI red. Verified
in both directions: green on 0.5.10, all three cases red on 0.5.8 while
the pre-existing parity scripts stay green there.

Also correct the two places whose claims measurement disproved — the
link-click header comment asserted no 0.5.x release emits OSC 8, and the
migration design doc still called 0.5.8 the pinned version — and record
in the parity sweep that whole-row divergence narrowed rather than
closed, since the rows carrying these paragraphs also carry two
unresolved divergences.
2026-09-22 08:11:25 +00:00
易良andQwen-Coder baf0ff41f7 refactor(build,release): Use pnpm workspace builds and publishing (#12400)
* refactor(release): Use pnpm for workspace publishing and versioning

* fix(ci): revert desktop-release install to npm ci for Windows compatibility

The pnpm migration added a bash conditional to check for pnpm-lock.yaml,
but the step did not specify shell: 'bash'. Windows runners default to
PowerShell, which cannot parse bash syntax, causing the build to fail
with a ParserError.

Since desktop-release always checks out the same commit and the root
lockfile is now pnpm-lock.yaml, the conditional is unnecessary. Revert
to the simple npm ci command that works on all platforms.

Fixes the Windows build failure in Desktop release v0.24.2.

* refactor(build): Schedule workspace builds with pnpm

* fix(ci): Preserve Windows release installation and version filters

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-21 15:38:36 +00:00
易良andQwen-Coder 64dd058c9c ci(pnpm): install with pnpm everywhere and retire package-lock.json (#11859)
* ci(pnpm): install dependencies with pnpm in CI and release

CI tests the graph release ships only if both install it the same way, so
this moves both, keeping every `npm run` script and `npm publish` as they are.

- ci.yml (7), release.yml (3) and release-vscode-companion.yml (2) install
  with `corepack pnpm install --frozen-lockfile`; release keeps
  --ignore-scripts plus its explicit postinstall and generate steps.
- setup-node drops the npm cache and sets package-manager-cache: false,
  which setup-node v5+ needs once package.json declares a pnpm
  packageManager.
- A composite action caches the pnpm store on the three always-hosted jobs;
  the ECS pool keeps its store on local disk.
- version.js no longer lets npm reify node_modules: --no-workspaces-update on
  both `npm version` calls, and a package-lock-only refresh in step 9.
- The notices generator, prepare-package's sharp pin and the standalone
  release's native package specs read the installed tree or pnpm-lock.yaml
  instead of package-lock.json keys.
- New size baselines for the three workflows, which were already over them.

* chore(vscode): regenerate NOTICES from the pnpm-installed tree

Generated in CI (pnpm Worktree Smoke on ubuntu-latest) by the ported
generator. Same entry set as before except the five packages npm and pnpm
resolve differently: fdir 6.5.0, picomatch 4.0.5, mdurl 2.1.0,
@types/node 22.20.1 and mime-db 1.52.0 (form-data's pinned copy). No entry
lacks a license text or repository.

* fix(pnpm): resolve undeclared zod peers to the repo's zod, as npm does

The first CI run on this branch failed the export renderer's size budget:
1,930,015 bytes against 1,930,000, where the same commit built on npm's
tree comes to 1,858,186. Comparing both trees' esbuild metafiles and
prebuilt bundles put the whole difference in web-shell's transcript.js
(+100,903 bytes pre-minify), and the added code is zod 4 plus the
@modelcontextprotocol/ext-apps schemas; the sdk daemon bundle is identical.

web-shell bundles ext-apps, whose zod and MCP SDK are peers, and declares
no zod itself, so pnpm filled the peer with the highest locked zod (4.4.3)
while npm serves the hoisted 3.25.76. Declaring zod 3.25.76 as a root
devDependency makes pnpm resolve every such peer to it
(resolve-peers-from-workspace-root is on by default), which is npm's
hoisting behaviour. mobile-mcp keeps its own zod 4.4.3, as under npm; the
npm tree does not change.

* fix(pnpm): keep npm's root @types/node so root-level typechecks match

The no-AK integration gate typechecks integration-tests/, which takes
Node's types from the root node_modules. npm hoists @types/node 20.19.1
there, because acp-bridge and core pin it; pnpm, with no root declaration,
hoisted 22.20.1, whose spawnSync overloads type process-registry.ts's
stdout as string | NonSharedBuffer and fail TS2322. The same code passes on
every npm-installed PR.

Declaring 20.19.1 as a root devDependency puts npm's copy back where a
root-level tsconfig looks. The npm tree does not change.

* ci(pnpm): install with pnpm in the remaining workflows

The follow-up to switching CI and release: every other workflow that
installs the root workspace now uses the pinned pnpm with
--frozen-lockfile, so no job tests a graph that release does not ship.

- e2e, tui-parity, repo-hygiene, web-shell-visuals, serve-ab, sdk-java,
  windows-runner-smoke, qwen-autofix, the SDK releases, finalize-release,
  sync-release-to-oss, the root install in desktop-release and
  cd-cua-driver, and the sandbox Dockerfile.
- setup-node drops the root npm cache (package-manager-cache: false); hosted
  jobs restore the pnpm store through the shared composite action, and
  qwen-autofix keeps af-012's rule that the persistent pool restores no
  remote cache.
- serve-ab and web-shell-visuals install a separate base checkout that can
  predate pnpm-lock.yaml, so those installs fall back to npm ci there.
- Out of scope, unchanged: packages with their own lockfile (cua-driver's
  TypeScript SDK, desktop-shell, live-host, mobile-mcp), and the triage
  lanes with npm-cache.yml and the npm audit gate, which move with the
  package-lock.json retirement.
- New size baselines for the touched workflows; qwen-autofix stays under
  the 470000-byte gate.

* fix(pnpm): link the workspace packages that root-level code imports

npm links every workspace package into the root node_modules, so
integration-tests/ and scripts/, which are not workspace packages
themselves, can import @qwen-code/* by name. pnpm links a workspace
package only where it is a declared dependency, and the root declared
none, so the no-AK integration gate could not find
@qwen-code/qwen-code-core.

The root now declares the seven packages that root-level code imports at
runtime as file: devDependencies: acp-bridge, channel-base, the CLI,
core, sdk, web-shell and web-templates. npm already links them, so its
tree does not change (letting npm re-resolve the lock reproduces the
same root entry); .pnpmfile.mjs rewrites them to workspace:*, which pnpm
links at the root.

* ci(pnpm): move triage, the store producer and the root audit to pnpm

- qwen-triage's verify and tmux-testing lanes install the PR with
  `corepack pnpm install --frozen-lockfile` from a restored pnpm store.
  The one retry clears node_modules first, because pnpm install, unlike
  npm ci, keeps it.
- npm-cache.yml becomes pnpm-store.yml, keyed on pnpm-lock.yaml.
- security-checks audits the root workspace with `pnpm audit`, which reads
  the lockfile, so the root install step goes. The vendored lockfiles keep
  npm, and the retry recognises pnpm's endpoint error too.
- qwen-autofix drops the hosted-only store cache: its heavy jobs forbid
  local `uses: './...'` actions, and the file sits at the size gate.
- cd-cua-driver: setup-node@v4 has no package-manager-cache input.
- package-assets fixtures install sharp rather than listing it in
  package-lock.json, matching how prepare-package now resolves it.
- AGENTS.md, CONTRIBUTING.md and the npm workspace guide install with pnpm.

* feat(review): install pnpm repos in qwen review's build/test

qwen review installed dependencies only for a repo with package-lock.json
and reported a pnpm repo as unsupported, so reviews of qwen-code would
lose their build and test once its npm lockfile goes.

- lockfileInstaller() picks npm or pnpm from the committed lockfile. With
  both, `packageManager: pnpm@...` chooses pnpm; anything else keeps npm.
- A pnpm repo installs with `corepack pnpm install --frozen-lockfile`
  through the same disk, budget and sandbox path as `npm ci`, and counts
  as installed once node_modules/.modules.yaml exists.
- Scripts still run through `npm run`, and the npm path is unchanged.

* ci(pnpm): retire the root package-lock.json

pnpm-lock.yaml is now the only root lockfile.

- check-lockfile keeps the pnpm integrity check and Playwright parity,
  which now reads pnpm importers and snapshots. The npm-vs-pnpm agreement
  and build-approval checks go: pnpm 11's strictDepBuilds and every CI
  install's --frozen-lockfile enforce what they covered.
- pnpm-lock-freshness.yml goes; it regenerated pnpm-lock.yaml from
  package-lock.json.
- Release: version.js drops its package-lock refresh, the release commit
  stages pnpm-lock.yaml and the SDK release stages no lockfile, so neither
  `git add` fails on the missing file. `npm version` in the SDK and
  mobile-mcp releases passes --no-workspaces-update so it cannot reinstall
  the root with npm.
- cd-mobile-mcp installs from the root pnpm lockfile. setup-node in
  cd-mobile-mcp, qwen-code-pr-review and desktop-release no longer keys an
  npm cache on the root lockfile.
- check-desktop-isolation reads pnpm importers; build.js, build_sandbox.js
  and preflight install with pnpm.
- pnpm-lock.yaml, pnpm-workspace.yaml and .pnpmfile.mjs join the autofix
  supply-chain class and repo-hygiene's deny list; pnpm-lock.yaml joins
  autofix's generated-diff exclusions, sdk-java's path filter and the
  platform-sensitivity manifests.
- .gitignore lists /package-lock.json so an npm install cannot commit it
  back. Tests and docs follow.

* fix(standalone): read the node-pty versions from the pnpm lockfile

* chore(vscode-ide-companion): regenerate NOTICES.txt after the lockfile swap

* chore(vscode-ide-companion): regenerate NOTICES.txt from the pnpm dependency tree

The committed NOTICES.txt was generated from an npm install and does not
match the tree produced by the pnpm install the release pipeline now uses
(659 resolved dependencies, differing @types/node and mime-db versions).
Regenerating with the pinned pnpm install makes the freshness check
reproducible.

* fix(ci): drop the npm cache from the daily dependency audit

actions/setup-node's npm cache keys off the root package-lock.json, which this branch retires, so the scheduled job would fail at Set up Node before the audit ran. No pull_request run reaches this workflow to catch it. The root tree is never installed here either: pnpm audit reads pnpm-lock.yaml directly.

Matches the sibling workflows that already set package-manager-cache: false; scripts/tests/security-workflows.test.js now pins it.

* chore(ci): stop banking main-side drift in the workflow size ratchet

The ratchet exists to turn a workflow's growth into one reviewed line. This branch re-recorded entries for 17 workflows it never touches, several upward by 0.4-2.8 KB - drift that happened on main, banked into the manifest without review.

Restore those to their merge-base values, so .size-baseline changes only for the 22 workflows this branch actually edits. The entries were not needed to go green: ci.yml passes the PR's base commit, so a file that drifted on main and is otherwise untouched earns a warning, not a failure.

* fix(release): initialize standalone state before main

* test(browser-use): read Playwright pin from pnpm lock

* fix(ci): address pnpm migration review blockers

* fix(desktop): stop reading the retired root lockfile

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-20 02:11:25 +00:00
b7543aeb1b feat(browser-use): add Chrome Native Messaging relay (#11242)
* feat(browser-use): add Playwright browser SDK

* docs(browser-use): document browser use architecture

* fix(browser-use): make browser input actions reliable

* feat(browser-use): preserve screenshot coordinate metadata

* fix(browser-use): recover stale sockets across VM realms

* fix(browser-use): bound locator action retries

* fix(browser-use): keep page input in the background

* fix(browser-use): reject ambiguous input arguments

* fix(browser-use): validate locator composition identity

* fix(browser-use): recover socket locks across VM realms

* fix(browser-use): preserve input results across navigation

* fix(browser-use): capture screenshots through bounded CDP frames

* fix(browser-use): separate input and navigation waits

* feat(browser-use): add Chrome native relay

* fix(chrome-extension): avoid foreground tabs and stale cursor markup

* fix(chrome-extension): require Chrome 125 for flat sessions

* feat(browser-use): add product integration

* test(browser-use): add managed Chrome smoke coverage

* feat(node-repl): expose structured image metadata

* test(browser-use): preserve complete smoke reports

* feat(browser-use): bundle skill and runtime with CLI

* fix(browser-use): install pinned Playwright in fresh workspaces

* feat(browser-use): require explicit skill opt-in

* feat(browser-use): make bundled skill available by default

Keep explicit skill disables authoritative, update documentation and tests, and use the latest Node REPL package in setup instructions.

* fix(ci): build browser-use before Live Host publish

Build the bundled browser runtime before core in the Live Host npm dependency chain, and cover that prerequisite order with a regression test.

* fix(browser-use): output screenshot metadata explicitly

* fix(node-repl): preserve image metadata outside prose budget

* fix(browser-use): address review round on workspace wiring and runtime correctness

- register the workspace in .pnpmfile.mjs, pnpm-lock.yaml, and the
  unit-vitest-configs registry, with the shared-pool timeout ternary and
  off-Linux unhandled-error exemption (fixes the red Install/Test checks)
- map Playwright's 'warning' console type to the contract's 'warn' level
- reject captures whose pixel dimensions contradict the css-pixels
  coordinate contract instead of certifying rescaled images
- keep one unattachable derived tab from failing tabs.list while still
  propagating a lost session
- clear the cached dialog when Chrome resolves it out of band or the page
  navigates, guarded by dialog identity so a newer dialog survives
- synthesize a parseable Browser.getVersion (numeric Chrome major, host
  platform token) so Playwright enables the right keyboard editing commands
- keep YAML-quoted snapshot keys and link props in interactive listings and
  stop claiming subtrees that filtering removed
- always release the mouse button when a drag gesture fails

* fix(dev): build browser-use runtime before startup

* fix(browser-use): isolate cleanup and correct smoke checks

* fix(browser-use): resolve managed Chrome through Playwright

* fix(browser-use): harden native relay and tab lifecycle

* fix(browser-use): drain host output and persist restored tab state

* fix(browser-use): scope dialog handles and clean navigation waiters

* fix(browser-use): preserve input outcomes and release modifiers

* fix(browser-use): drain session teardown and release debugger attachments

* fix(browser-use): correct locator validation and evaluation

* fix(dev): reuse prebuilt Browser Use runtime

* fix(browser-use): address review round on locator, bridge, and screenshot correctness

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

* fix(browser-use): require installed extension before native host setup

* fix(browser-use): address review round on schema probe bound and regex validation

* fix(browser-use): start transport before Chrome preflight

* fix(browser-use): exclude staged runtime from code checks

* fix(core): declare Browser Use build dependency

* fix(browser-use): preserve successful navigation and dialog input

* fix(browser-use): harden input drain and tab registration cleanup

* fix(browser-use): keep sessions bound to one Chrome profile

* fix(browser-use): bound commands and validate evaluation JSON

* fix(browser-use): reuse unchanged Native Host registration

* fix(browser-use): wait for fresh extension installation

* fix(browser-use): address review round on screenshot origin, dialog scope, and bridge privacy

- screenshot: tolerate settle-probe failure (measure the ratio from the
  captured pixels, re-capture on HiDPI), measure layout after the settle
  wait, and reject screencast frames whose scroll offsets disagree with
  the published origin
- session: clear the cached dialog only on main-frame navigation;
  abort tab attachment when the tab is removed mid-attach
- errors: classify crashes and timeouts by Playwright's structured error
  name instead of page-influenced message text
- locator.press: release chord modifiers when a later token is rejected;
  cap locator.type at the 60k characters its per-character budget can
  deliver inside the 120s ceiling
- snapshot: keep AI-mode [cursor=pointer] generic nodes in the
  interactive listing
- bridge: derive the fallback socket directory as an env-independent
  per-user 0700 subdirectory on all POSIX hosts and verify ownership
  before binding

* fix(browser-use): close review-critical gaps in error mapping, budgets, and downloads

- errors: classify by Playwright error name first; text phrases match only
  the start of the first message line so page-controlled text (log tails,
  quoted markup, page-thrown values) cannot pick the reported code, and a
  TimeoutError name wins over crash-shaped page text
- transport: cdp.send bridge requests default to 130s so operations up to
  the 120s schema ceiling report their own deadline; other requests keep
  the 30s default
- transport: the /tmp fallback socket directory is a per-user leaf directly
  under the sticky root (no shared intermediate a first mover could own);
  ensureSocketDirectory re-verifies the leaf after creation and walks
  ancestors up to the sticky root, requiring each to be owned by this user
  or root and not group/other-writable
- runtime: withModifiers no longer rewrites a completed action into a
  failure when a modifier release fails; chord failure cleanup releases the
  chord's own tokens (including non-modifiers) instead of four fixed names
- playwright: enable CDP download events on tab attach (Playwright's
  noDefaults connect leaves them off, so waitForEvent('download') could
  never resolve); locator.downloadMedia races the caller deadline, aborts
  the in-page fetch at it, resolves media contained in a located wrapper
  before an ancestor link, restricts URL schemes, and names CORS rejections
  distinctly; locator.allTextContents waits for the first match within the
  caller budget and still resolves [] when nothing attaches
- screenshot: full-page captures get a 16 Mi-pixel budget (1920x3000 pages
  now succeed) and the encoded byte ceiling drops to the 4 MiB model image
  limit so captures are not dropped at the model boundary
- snapshot: interactiveOnly promotes only a terminal [cursor=pointer]
  attribute (page text quoting the marker no longer promotes the line), and
  truncation skips over-long lines instead of discarding the rest
- schemas: reject empty string/regex matchers and exact:true combined with
  a regex matcher (Playwright silently drops exact for regexes)
- tests: the SDK evaluate fixture now mirrors production serialization;
  POSIX socket tests carry win32 gates; posix.join keeps the derived socket
  path a pure function of uid and platform on every host

* fix(browser-use): close review criticals in error mapping, chords, downloads, and snapshots

- errors: classify Playwright's genuine strict-mode violation, which
  crosses CDP as the raw exception description and reaches the client
  behind an "Error: " layer ("locator.click: Error: strict mode
  violation: ... resolved to N elements:"); requiring the element count
  keeps a page-thrown lookalike from forging LOCATOR_NOT_UNIQUE
- runtime-helpers: on a failed chord press, release the tokens
  Playwright actually derived ('+' separates only after a non-empty
  token, so a standalone '+' is a literal key) rather than the caller's
  array elements; an element embedding '+' previously released nothing
  and left modifiers held. locator.press uses the same tokenizer
- locator.downloadMedia: resolve an ordered candidate list (element,
  media descendants, anchor descendants, closest ancestor) and keep the
  first candidate that exposes a URL, so a wrapping anchor no longer
  beats the image it contains and a source srcset or an unloaded
  video's source child can supply the URL
- snapshot: unwrap Playwright's single-quoted YAML key before the
  [cursor=pointer] suffix test, so clickable nodes whose accessible
  name forces key quoting stay in the interactive listing
- docs: align the Input completion contract with the shipped modifier
  cleanup semantics (cleanup failures are discarded; only the action's
  own error propagates) in both languages

* fix(browser-use): close round-13 review criticals in errors, srcset, evaluateAll, snapshots, and screenshot origin

* fix(browser-use): point the pnpm importer at a vitest snapshot that exists

Merging main brought in #11625, which regenerated pnpm-lock.yaml and
dropped the [email protected] snapshot keyed with @vitest/browser and
[email protected]. The packages/browser-use importer still referenced
that key, so every Install leg failed with
ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY. Re-key the importer to the snapshot
its sibling packages already use; nothing else in the lockfile changes.

* fix(browser-use): close round-14 criticals in error classification, dialog ordering and screenshot ratio

R13-2: the strict-mode branch of sanitizeOperationError was the only
text-phrase branch not gated on the evaluate channel, so a page-thrown
primitive "strict mode violation: ... resolved to 2 elements:" picked
LOCATOR_NOT_UNIQUE. On that channel only Playwright's "Error: "-wrapped
form classifies now; other channels keep the unwrapped form.

R14-1: Playwright hands a dialog over on a later turn than the bridge
reports its CDP events (a microtask in CRSession plus the in-process
dispatcher's setImmediate), so an open+close pair delivered in one chunk
left a phantom dialog that blocked the tab. Each tab now traces dialog
openings and closes in bridge order and drops a delivered dialog the
bridge already reported closed; a close with no traced opening (a dialog
open before the tab was attached) is never charged to a later dialog, and
the microtask clear stays for the delivered case.

R14-2: window.devicePixelRatio is page script, and a spoofed value became
an unbounded clip.scale while the pixel re-measure only ran when the
probe failed. The probed ratio is now trusted only within 0.25..8, and the
measure-and-recapture correction runs whenever the first fallback capture
disagrees with the requested CSS region, deriving the real ratio from
Chrome's own pixels (measured / (width * scale)).

Also resolve the temp root in the ensureSocketDirectory tests: the
ancestor sweep rejects symlinks, and macOS reaches os.tmpdir() through
/var -> /private/var, so the tests failed locally on macOS.

* fix(browser-use): tear down accepted peers on start failure and pin the transport's untested paths

The esbuild banner declared require/__filename/__dirname/process bindings
the bundle never uses, so any bundled module declaring one of them built
green but failed to load; drop it (and the unused .wasm loader) and import
dist/index.js after bundling so an unloadable bundle fails the build.

When start() fails after listen (chmod, handshake), a peer validated in
between kept server.close() waiting forever; destroy and clear the
accepted sockets first, mirroring stop(). net.Server has no
closeAllConnections(), so that is the whole fix.

Tests now cover the QWEN_BROWSER_USE_SOCKET_PATH override, a hello with a
mismatched protocol version or extension id, onConnectionChange on the
real transport, an in-flight request when the extension disconnects and
the configured request budget, the ok:false error-code mapping,
FrameDecoder split-frame reassembly and its length ceiling, the
start-failure teardown, and all five schema refine predicates.

* fix(browser-use): mirror the contract bounds in the SDK and state the documented limits

nth() now rejects indexes outside the contract's -10000..10000 before
dispatch, and all() fails closed when the plan already has 32 steps or
the match count exceeds what nth() can address, instead of handing back
locators that only fail on first use. Both evaluate script builders
normalise method-shorthand and class-method sources the way Playwright
does (compile-only vm.Script probe, then a function prefix), so those
well-typed callbacks no longer die as a page-side SyntaxError.

providerTab clamps title and url to the claimTab schema's 20000-char cap
so the documented openTabs() -> claimTab() round trip survives long URLs,
and orderOpenTabs() establishes the http(s)-only, newest-first listing
the model documentation promises. downloadMedia derives the file name
from the URL path only for http(s) sources and leaves blob:/data: names
to the browser.

The model-facing documentation now states the 1s read default, that
dom_cua.scroll's x/y are wheel deltas rather than coordinates, and that
a cross-origin resource without CORS headers cannot be downloaded from
the page origin; the design docs record that boundary as follow-up work.
Tests pin every one of these, plus buildLocator's frame, combinator,
filter and positional branches, the file-chooser SDK surface, and the
claimTab argument guards.

* fix(browser-use): keep background tab registration from moving the selection

registerTab selected every tab it registered, and two of its callers are
background paths (derivedTabTracked, syncDerivedTabs via tabs.list), so
a read-only tabs.list could move tabs.selected onto a popup. Selection is
now set only by newTab and claimTab from the info they return.

Also: tabs.get is allowed while a dialog is open, matching tabs.list and
tabs.selected, which already return the dialog-safe TabInfo; the
auxiliary-button click moves Playwright's tracked cursor before sending
the CDP button events so a later wheel lands where the click did; the
dead providerTabId lookup is removed; openTabs applies orderOpenTabs.

Tests: the runtime test file mocks node:timers so vi.useFakeTimers()
actually controls the input drain bound (the 250ms test drops to ~0ms),
and new cases pin the drain clearTimeout, the registration probe's
session detach, promptText pass-through, tabs.get under a dialog, the
openTabs/claimTab fail-closed guards and ordering, popup adoption from
derivedTabTracked, the abandoned navigation waiter's swallowed rejection,
the uploadFiles path guards, and the screencast fallback and byte budget
on the unconstrained capture path.

* test(browser-use): pin the element bindings the runtime gives evaluate scripts

The SDK emits scripts that read the free variables `element` and
`elements`, and the runtime binds them when it compiles the script inside
the page; no test ran either page-side callback, so swapping the binding
name on one side kept the suite green while every locator.evaluate would
throw a ReferenceError in production. Run the real callbacks with fake
elements so the contract is pinned from both ends.

* fix(browser-use): fail closed on page-controlled strict-mode text and prefer contained media over anchor hrefs

* fix(browser-use): bound page title reads and download a located anchor's file over its icon

* fix(browser-use): correct media downloads and frame composition

* fix(browser-use): bound locator.count and allTextContents by the caller deadline

* fix(browser-use): preserve native snapshots for DOM CUA

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

* fix(browser-use): classify a closed tab from the tab's own state

The pinned playwright-core client renders a closed target as a plain Error:
only TimeoutError sets `name`, so the `name === 'TargetClosedError'` branch
in sanitizeOperationError never fired, and because evaluate-channel text
fails closed by design, a tab that closed or crashed while a page command
ran was reported as OPERATION_FAILED instead of the documented STALE_TAB.

Decide tab-gone in the dispatcher from `tab.stale` and `page.isClosed()`,
which the page observers keep current, before the text classifier runs.
Drop the dead name branch and replace the test that fabricated the name
with one built the way the client builds the error, plus dispatch-level
cases for both signals that fail without the dispatcher check.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(browser-use): restore the kernel realm process binding and land the round-5 review batch

Criticals from review round 5, each reproduced before fixing:

- The bundled skill imports the staged runtime inside the node_repl kernel,
  whose untrusted vm context has no `process` global and denies the
  `node:process` specifier. 3fc2dc3e10 removed the bundle banner that bound
  `process` through createRequire, so the skill's first cell threw
  `ReferenceError: process is not defined`; the build's own load check runs
  in the main realm and could not see it. Restore the binding on the index
  build and add a kernel-backed test that runs SKILL.md's first cell through
  the real kernel and fails on the ReferenceError (R5-4).
- Bump @qwen-code/node-repl-mcp to 0.1.6: this PR changes its published
  source (image metadata channel) and the package is excluded from the
  repo-wide bump, so the release lane's integrity check would fail. Pin the
  browser-use skill to that version, have the skill test assert the pin
  equals the package version, and extend the cua-driver release gate to
  cross-check the browser-use pin (R5-2, R3-5).
- Report an incompatible extension as EXTENSION_VERSION_MISMATCH so
  browser discovery surfaces the update guidance instead of an empty list
  after the connect budget (R5-5).
- Make tab grouping non-fatal in tabs.create, matching its siblings, so a
  cosmetic grouping failure no longer closes the tab just created (R5-6).

Review suggestions verified still applying, with tests proven red under
mutation: typecheck the scripts directory; pin the Native Messaging chunk
boundary and envelope; return installedPaths from the installer; treat a
blank install-home override as unset; warn on a skipped foreign manifest;
refuse a foreign launcher with the refusals' missing red arms; keep the
preflight download waiter handled; settle the parked-profile test; name
the private-directory cause in the bridge start error; declare the finalize
status union once; reject unknown or closed kept tabs; pin the extension's
protocol constants to the SDK source; resolve the extension test root from
the module; drop the duplicated permission assertion; make the group
barrier real; read the overlay style rules; map a vanished tab to STALE_TAB
in the relay; clamp listed tab text to the schema bound; remove the
unreachable onDetach child-session branch; assert the raced tab in the
disconnect test; cover the history scheme filter and session-name reset;
and fix the extension-source and prerequisite wording in the docs.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* test(browser-use): let the SauceDemo smoke recognize equivalent skill usage

The model-driven smoke completed the task correctly on every run but its
transcript checks asserted one exact spelling: the literal runtime entry
path in the model's code, and one parent-scoped price selector with an
exact number of dollar values in the cell's output. A model that builds
the entry from the skill root across cells, scopes the price locator
differently, or echoes its chosen items after the read failed the smoke.

Key the import check on what every spelling shares: the built-in skill
root the CLI substitutes into SKILL.md, the entry file name and the setup
call. Match price reads on the price class, prefer a read that printed
exactly the expected count, take the leading set otherwise, and take the
last such read for the cart since the flow reads it after the inventory.
The smoke-checks test covers all three import spellings, a foreign runtime
and a missing setup call, and the parser's scoped, echoed and non-list
reads. Verified against a real Chromium 151 run: all six checks true.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(browser-use): close six fail-open paths in the playwright adapter

Round-22 review found six Critical defects in the new package; each is
fixed at its root and pinned by a test that goes red without the fix:

- locator.evaluateAll opened a second full timeout window after the
  attach wait had consumed the caller's budget; the wait and the read
  now share one deadline, and a wait that consumed the budget settles
  [] without evaluating.
- tab.goto passed any string to page.goto, so a file:// URL would have
  handed local file contents to the tab's evaluate and snapshot reads;
  goto is gated to http(s) before the navigation runs.
- Synthesized CDP error responses carried no code, so a reply orphaned
  by a target crash tripped Playwright's internal assert and tore down
  the whole transport; they now carry -32001, the orphan-swallow code.
- A dialog already open when a tab attaches is replayed by neither
  Chrome nor Playwright, so the DIALOG_OPEN gate stayed blind while the
  modal blocked the renderer; registration now probes the renderer and
  gates the tab on an unanswered probe, with getJsDialog reporting it
  under a stable handle whose accept/dismiss run over CDP.
- A dialog opening Playwright never delivered stayed in the trace
  forever and swallowed the charge of every later dialog on the tab; a
  closed, undelivered entry is retired once the dispatcher turn has
  passed.
- snapshotRefLocator validated only the ref's shape, so a ref from a
  pre-navigation snapshot could act on whichever element the new
  document happened to hand the same string; refs are bound to the
  snapshot that emitted them and cleared on main-frame navigation.

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

* build: exempt the staged Browser Use skill runtime from the dependency-direction check

The browser-use package build stages the bundled skill's runtime, node_modules
included, under packages/core/src/skills/bundled/browser-use/runtime (git-ignored),
and the TUI dependency-direction gate fails on any node_modules inside a protected
root. Declare that staged, ignored root as a build artifact so the gate skips it
without a diagnostic; every other skipped-directory name still fails.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MWe8PKNMpsKdQruTWkvFFc

* build: leave the staged Browser Use runtime out of the dependency-direction walk entirely

The staged root is a build artifact, not source: skipping only its
node_modules while still parsing the SDK bundle next to it was inconsistent.
Do not walk anything under a declared staged root; every other node_modules
inside the protected roots remains a diagnostic.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MWe8PKNMpsKdQruTWkvFFc

* build(browser-use): stage the skill runtime only for the bundle and for dev mode

The package build used to copy the bundled skill runtime, node_modules
included, into packages/core/src, which every built tree then carried and
which the TUI dependency-direction gate rightly rejects. Follow the qc-helper
convention instead: the bundle step stages the runtime into dist/bundled for
the shipped CLI, and `npm run dev` stages the git-ignored development copy
next to the source SKILL.md, because only dev mode reads bundled skills from
the source tree. No build step writes into src any more, so the gate
exemption added earlier is dropped and the check script returns to main's
version. The kernel-realm test stages its own copy into a temporary skill
directory rather than depending on a build side effect.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MWe8PKNMpsKdQruTWkvFFc

* fix(browser-use): deliver bridge messages in arrival order and keep dev info flags fast

Three fixes from the round-3/4 verification and review round 7–8:

- Transport: a response only settles a promise, so its consumer ran on the
  microtask queue while events from the same socket chunk were emitted
  synchronously and overtook it. Playwright installs a page's renderer and
  lifecycle listeners inside `Page.getFrameTree().then(...)`, so an
  overtaking `Runtime.executionContextCreated` found no listener and a
  claimed tab lost its main world for good (B1). Inbound messages now go
  through one per-socket queue whose drain yields a macrotask after
  settling a response, so every continuation runs before the next message;
  chunks arriving meanwhile join the same queue. Regression:
  `delivers a response ahead of the events that share its socket chunk`.
- Relay: `requireControllableTab` judged a popup by `tab.url`, which is
  empty until the navigation commits, while the claim keyed on
  `pendingUrl`; the runtime's immediate `tabs.get` therefore rejected the
  tab the relay had just reported (R8-2). It now judges by
  `pendingUrl || url`, like the claim. Regression:
  `a popup known only by its pending URL stays controllable`.
- dev launcher: the dev-mode runtime staging skips `--version`/`--help`
  (and `-v`/`-h`), restoring the fast path the existing tests pin (R7-1).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MWe8PKNMpsKdQruTWkvFFc

* test(browser-use): make the smoke evidence and the persist-before-connect pin honest

Round 6–7 review findings on the verification tooling:

- The SauceDemo smoke decided "the staged runtime was loaded" from three
  substring tests over all cells joined, so a run whose staged import threw
  and fell back to a foreign entry still passed (R6-1). It now looks at the
  import expressions per cell: a literal path under the staged runtime, or a
  path assembled from the substituted skill root, in a cell that reaches the
  setup export; a literal entry loaded from anywhere else disqualifies the
  run.
- Price reads were attributed to the cart by count and read order, so an
  inventory sorted low-to-high passed as a cart read and the fallback to the
  first read never returned null (R6-2). Reads are now attributed by the
  tab.url() confirmation of cart.html the prompt requires: inventory before
  it, cart after it, and no cart read without it.
- The persist-before-connect invariant was asserted inside the mock's
  postMessage, which the bridge calls under a bare catch, so the reordered
  mutant stayed green (R7-2). The mock now records every hello with the
  identity persisted at that moment and the test asserts the order outside
  the bridge; the mutant is red.
- SKILL.md states the node-repl-mcp 0.1.6 requirement next to the
  emitImage step the agent always reads, with how to detect and re-register
  an older server (R6-3).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MWe8PKNMpsKdQruTWkvFFc

---------

Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: qwen-code-ci-bot <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-18 05:50:24 +00:00
1c193a9a3e feat(browser-use): add Playwright-based Browser SDK (#11241)
* feat(browser-use): add Playwright browser SDK

* docs(browser-use): document browser use architecture

* fix(browser-use): make browser input actions reliable

* feat(browser-use): preserve screenshot coordinate metadata

* fix(browser-use): recover stale sockets across VM realms

* fix(browser-use): bound locator action retries

* fix(browser-use): keep page input in the background

* fix(browser-use): reject ambiguous input arguments

* fix(browser-use): validate locator composition identity

* fix(browser-use): recover socket locks across VM realms

* fix(browser-use): preserve input results across navigation

* fix(browser-use): capture screenshots through bounded CDP frames

* fix(browser-use): separate input and navigation waits

* fix(browser-use): address review round on workspace wiring and runtime correctness

- register the workspace in .pnpmfile.mjs, pnpm-lock.yaml, and the
  unit-vitest-configs registry, with the shared-pool timeout ternary and
  off-Linux unhandled-error exemption (fixes the red Install/Test checks)
- map Playwright's 'warning' console type to the contract's 'warn' level
- reject captures whose pixel dimensions contradict the css-pixels
  coordinate contract instead of certifying rescaled images
- keep one unattachable derived tab from failing tabs.list while still
  propagating a lost session
- clear the cached dialog when Chrome resolves it out of band or the page
  navigates, guarded by dialog identity so a newer dialog survives
- synthesize a parseable Browser.getVersion (numeric Chrome major, host
  platform token) so Playwright enables the right keyboard editing commands
- keep YAML-quoted snapshot keys and link props in interactive listings and
  stop claiming subtrees that filtering removed
- always release the mouse button when a drag gesture fails

* fix(browser-use): scope dialog handles and clean navigation waiters

* fix(browser-use): preserve input outcomes and release modifiers

* fix(browser-use): drain session teardown and release debugger attachments

* fix(browser-use): correct locator validation and evaluation

* fix(browser-use): address review round on locator, bridge, and screenshot correctness

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

* fix(browser-use): address review round on schema probe bound and regex validation

* fix(browser-use): preserve successful navigation and dialog input

* fix(browser-use): harden input drain and tab registration cleanup

* fix(browser-use): bound commands and validate evaluation JSON

* fix(browser-use): address review round on screenshot origin, dialog scope, and bridge privacy

- screenshot: tolerate settle-probe failure (measure the ratio from the
  captured pixels, re-capture on HiDPI), measure layout after the settle
  wait, and reject screencast frames whose scroll offsets disagree with
  the published origin
- session: clear the cached dialog only on main-frame navigation;
  abort tab attachment when the tab is removed mid-attach
- errors: classify crashes and timeouts by Playwright's structured error
  name instead of page-influenced message text
- locator.press: release chord modifiers when a later token is rejected;
  cap locator.type at the 60k characters its per-character budget can
  deliver inside the 120s ceiling
- snapshot: keep AI-mode [cursor=pointer] generic nodes in the
  interactive listing
- bridge: derive the fallback socket directory as an env-independent
  per-user 0700 subdirectory on all POSIX hosts and verify ownership
  before binding

* fix(browser-use): close review-critical gaps in error mapping, budgets, and downloads

- errors: classify by Playwright error name first; text phrases match only
  the start of the first message line so page-controlled text (log tails,
  quoted markup, page-thrown values) cannot pick the reported code, and a
  TimeoutError name wins over crash-shaped page text
- transport: cdp.send bridge requests default to 130s so operations up to
  the 120s schema ceiling report their own deadline; other requests keep
  the 30s default
- transport: the /tmp fallback socket directory is a per-user leaf directly
  under the sticky root (no shared intermediate a first mover could own);
  ensureSocketDirectory re-verifies the leaf after creation and walks
  ancestors up to the sticky root, requiring each to be owned by this user
  or root and not group/other-writable
- runtime: withModifiers no longer rewrites a completed action into a
  failure when a modifier release fails; chord failure cleanup releases the
  chord's own tokens (including non-modifiers) instead of four fixed names
- playwright: enable CDP download events on tab attach (Playwright's
  noDefaults connect leaves them off, so waitForEvent('download') could
  never resolve); locator.downloadMedia races the caller deadline, aborts
  the in-page fetch at it, resolves media contained in a located wrapper
  before an ancestor link, restricts URL schemes, and names CORS rejections
  distinctly; locator.allTextContents waits for the first match within the
  caller budget and still resolves [] when nothing attaches
- screenshot: full-page captures get a 16 Mi-pixel budget (1920x3000 pages
  now succeed) and the encoded byte ceiling drops to the 4 MiB model image
  limit so captures are not dropped at the model boundary
- snapshot: interactiveOnly promotes only a terminal [cursor=pointer]
  attribute (page text quoting the marker no longer promotes the line), and
  truncation skips over-long lines instead of discarding the rest
- schemas: reject empty string/regex matchers and exact:true combined with
  a regex matcher (Playwright silently drops exact for regexes)
- tests: the SDK evaluate fixture now mirrors production serialization;
  POSIX socket tests carry win32 gates; posix.join keeps the derived socket
  path a pure function of uid and platform on every host

* fix(browser-use): close review criticals in error mapping, chords, downloads, and snapshots

- errors: classify Playwright's genuine strict-mode violation, which
  crosses CDP as the raw exception description and reaches the client
  behind an "Error: " layer ("locator.click: Error: strict mode
  violation: ... resolved to N elements:"); requiring the element count
  keeps a page-thrown lookalike from forging LOCATOR_NOT_UNIQUE
- runtime-helpers: on a failed chord press, release the tokens
  Playwright actually derived ('+' separates only after a non-empty
  token, so a standalone '+' is a literal key) rather than the caller's
  array elements; an element embedding '+' previously released nothing
  and left modifiers held. locator.press uses the same tokenizer
- locator.downloadMedia: resolve an ordered candidate list (element,
  media descendants, anchor descendants, closest ancestor) and keep the
  first candidate that exposes a URL, so a wrapping anchor no longer
  beats the image it contains and a source srcset or an unloaded
  video's source child can supply the URL
- snapshot: unwrap Playwright's single-quoted YAML key before the
  [cursor=pointer] suffix test, so clickable nodes whose accessible
  name forces key quoting stay in the interactive listing
- docs: align the Input completion contract with the shipped modifier
  cleanup semantics (cleanup failures are discarded; only the action's
  own error propagates) in both languages

* fix(browser-use): close round-13 review criticals in errors, srcset, evaluateAll, snapshots, and screenshot origin

* fix(browser-use): point the pnpm importer at a vitest snapshot that exists

Merging main brought in #11625, which regenerated pnpm-lock.yaml and
dropped the [email protected] snapshot keyed with @vitest/browser and
[email protected]. The packages/browser-use importer still referenced
that key, so every Install leg failed with
ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY. Re-key the importer to the snapshot
its sibling packages already use; nothing else in the lockfile changes.

* fix(browser-use): close round-14 criticals in error classification, dialog ordering and screenshot ratio

R13-2: the strict-mode branch of sanitizeOperationError was the only
text-phrase branch not gated on the evaluate channel, so a page-thrown
primitive "strict mode violation: ... resolved to 2 elements:" picked
LOCATOR_NOT_UNIQUE. On that channel only Playwright's "Error: "-wrapped
form classifies now; other channels keep the unwrapped form.

R14-1: Playwright hands a dialog over on a later turn than the bridge
reports its CDP events (a microtask in CRSession plus the in-process
dispatcher's setImmediate), so an open+close pair delivered in one chunk
left a phantom dialog that blocked the tab. Each tab now traces dialog
openings and closes in bridge order and drops a delivered dialog the
bridge already reported closed; a close with no traced opening (a dialog
open before the tab was attached) is never charged to a later dialog, and
the microtask clear stays for the delivered case.

R14-2: window.devicePixelRatio is page script, and a spoofed value became
an unbounded clip.scale while the pixel re-measure only ran when the
probe failed. The probed ratio is now trusted only within 0.25..8, and the
measure-and-recapture correction runs whenever the first fallback capture
disagrees with the requested CSS region, deriving the real ratio from
Chrome's own pixels (measured / (width * scale)).

Also resolve the temp root in the ensureSocketDirectory tests: the
ancestor sweep rejects symlinks, and macOS reaches os.tmpdir() through
/var -> /private/var, so the tests failed locally on macOS.

* fix(browser-use): tear down accepted peers on start failure and pin the transport's untested paths

The esbuild banner declared require/__filename/__dirname/process bindings
the bundle never uses, so any bundled module declaring one of them built
green but failed to load; drop it (and the unused .wasm loader) and import
dist/index.js after bundling so an unloadable bundle fails the build.

When start() fails after listen (chmod, handshake), a peer validated in
between kept server.close() waiting forever; destroy and clear the
accepted sockets first, mirroring stop(). net.Server has no
closeAllConnections(), so that is the whole fix.

Tests now cover the QWEN_BROWSER_USE_SOCKET_PATH override, a hello with a
mismatched protocol version or extension id, onConnectionChange on the
real transport, an in-flight request when the extension disconnects and
the configured request budget, the ok:false error-code mapping,
FrameDecoder split-frame reassembly and its length ceiling, the
start-failure teardown, and all five schema refine predicates.

* fix(browser-use): mirror the contract bounds in the SDK and state the documented limits

nth() now rejects indexes outside the contract's -10000..10000 before
dispatch, and all() fails closed when the plan already has 32 steps or
the match count exceeds what nth() can address, instead of handing back
locators that only fail on first use. Both evaluate script builders
normalise method-shorthand and class-method sources the way Playwright
does (compile-only vm.Script probe, then a function prefix), so those
well-typed callbacks no longer die as a page-side SyntaxError.

providerTab clamps title and url to the claimTab schema's 20000-char cap
so the documented openTabs() -> claimTab() round trip survives long URLs,
and orderOpenTabs() establishes the http(s)-only, newest-first listing
the model documentation promises. downloadMedia derives the file name
from the URL path only for http(s) sources and leaves blob:/data: names
to the browser.

The model-facing documentation now states the 1s read default, that
dom_cua.scroll's x/y are wheel deltas rather than coordinates, and that
a cross-origin resource without CORS headers cannot be downloaded from
the page origin; the design docs record that boundary as follow-up work.
Tests pin every one of these, plus buildLocator's frame, combinator,
filter and positional branches, the file-chooser SDK surface, and the
claimTab argument guards.

* fix(browser-use): keep background tab registration from moving the selection

registerTab selected every tab it registered, and two of its callers are
background paths (derivedTabTracked, syncDerivedTabs via tabs.list), so
a read-only tabs.list could move tabs.selected onto a popup. Selection is
now set only by newTab and claimTab from the info they return.

Also: tabs.get is allowed while a dialog is open, matching tabs.list and
tabs.selected, which already return the dialog-safe TabInfo; the
auxiliary-button click moves Playwright's tracked cursor before sending
the CDP button events so a later wheel lands where the click did; the
dead providerTabId lookup is removed; openTabs applies orderOpenTabs.

Tests: the runtime test file mocks node:timers so vi.useFakeTimers()
actually controls the input drain bound (the 250ms test drops to ~0ms),
and new cases pin the drain clearTimeout, the registration probe's
session detach, promptText pass-through, tabs.get under a dialog, the
openTabs/claimTab fail-closed guards and ordering, popup adoption from
derivedTabTracked, the abandoned navigation waiter's swallowed rejection,
the uploadFiles path guards, and the screencast fallback and byte budget
on the unconstrained capture path.

* test(browser-use): pin the element bindings the runtime gives evaluate scripts

The SDK emits scripts that read the free variables `element` and
`elements`, and the runtime binds them when it compiles the script inside
the page; no test ran either page-side callback, so swapping the binding
name on one side kept the suite green while every locator.evaluate would
throw a ReferenceError in production. Run the real callbacks with fake
elements so the contract is pinned from both ends.

* fix(browser-use): fail closed on page-controlled strict-mode text and prefer contained media over anchor hrefs

* fix(browser-use): bound page title reads and download a located anchor's file over its icon

* fix(browser-use): correct media downloads and frame composition

* fix(browser-use): bound locator.count and allTextContents by the caller deadline

* fix(browser-use): preserve native snapshots for DOM CUA

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

* fix(browser-use): classify a closed tab from the tab's own state

The pinned playwright-core client renders a closed target as a plain Error:
only TimeoutError sets `name`, so the `name === 'TargetClosedError'` branch
in sanitizeOperationError never fired, and because evaluate-channel text
fails closed by design, a tab that closed or crashed while a page command
ran was reported as OPERATION_FAILED instead of the documented STALE_TAB.

Decide tab-gone in the dispatcher from `tab.stale` and `page.isClosed()`,
which the page observers keep current, before the text classifier runs.
Drop the dead name branch and replace the test that fabricated the name
with one built the way the client builds the error, plus dispatch-level
cases for both signals that fail without the dispatcher check.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(browser-use): close six fail-open paths in the playwright adapter

Round-22 review found six Critical defects in the new package; each is
fixed at its root and pinned by a test that goes red without the fix:

- locator.evaluateAll opened a second full timeout window after the
  attach wait had consumed the caller's budget; the wait and the read
  now share one deadline, and a wait that consumed the budget settles
  [] without evaluating.
- tab.goto passed any string to page.goto, so a file:// URL would have
  handed local file contents to the tab's evaluate and snapshot reads;
  goto is gated to http(s) before the navigation runs.
- Synthesized CDP error responses carried no code, so a reply orphaned
  by a target crash tripped Playwright's internal assert and tore down
  the whole transport; they now carry -32001, the orphan-swallow code.
- A dialog already open when a tab attaches is replayed by neither
  Chrome nor Playwright, so the DIALOG_OPEN gate stayed blind while the
  modal blocked the renderer; registration now probes the renderer and
  gates the tab on an unanswered probe, with getJsDialog reporting it
  under a stable handle whose accept/dismiss run over CDP.
- A dialog opening Playwright never delivered stayed in the trace
  forever and swallowed the charge of every later dialog on the tab; a
  closed, undelivered entry is retired once the dispatcher turn has
  passed.
- snapshotRefLocator validated only the ref's shape, so a ref from a
  pre-navigation snapshot could act on whichever element the new
  document happened to hand the same string; refs are bound to the
  snapshot that emitted them and cleared on main-frame navigation.

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

---------

Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: qwen-code-ci-bot <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-17 11:06:56 +00:00
ac505acbfc feat(omni): integrate multimodal media pipeline into main (#12019)
* ci: run PR CI for the omni-experiment base branch

PRs targeting omni-experiment need the same CI coverage as main.
Branch-local change only; main's ci.yml is untouched.

* feat(omni): S1 minimal path — local video via DashScope upload delivery (#8422)

* feat(omni): S1 minimal path — local video via DashScope upload delivery

Local @video files are recognized (magic-byte sniff + streaming SHA-256 +
ffprobe metadata), promoted into a content-addressed object store under
.qwen/omni/objects/, uploaded through the DashScope temporary upload
channel (getPolicy + OSS multipart form POST), and delivered to the model
as oss:// fileData parts resolved server-side via the
X-DashScope-OssResourceResolve header.

- omni pipeline gated on omni.enabled (or QWEN_CODE_ENABLE_OMNI=1) AND a
  DashScope-compatible endpoint AND an available API key; any other
  configuration falls back to the existing inline base64 behavior
- ffmpeg/ffprobe are hard runtime prerequisites when omni is enabled:
  Config.initialize fails fast with an actionable FatalConfigError
- upload failures fail closed with an explanatory error instead of
  silently downgrading to inline delivery
- per-file ceiling omni.upload.maxFileBytes (default 1 GiB) replaces the
  10MB inline cap for omni-delivered video
- object store writes are atomic (.tmp + rename) and content-deduped;
  .qwen/omni/ is self-gitignored

Part of the omni-experiment S1 slice (#8183).

* fix(omni): harden S1 gate, storage, and failure paths after review

Review round 1 findings addressed:

- gate: exclude Qwen OAuth (its ContentGeneratorConfig carries the
  QWEN_OAUTH_DYNAMIC_TOKEN placeholder, unusable for the uploads
  endpoint), require an explicit baseUrl so the configured credential is
  never sent to a default origin, and require a trusted workspace before
  writing .qwen/omni/ or uploading workspace bytes
- aborts: user cancellation propagates as the original abort error at
  every pipeline stage instead of being wrapped into a delivery failure
- size ceiling: enforced from a cheap stat before any hashing/probing,
  and invalid (<=0) configured limits fall back to the default
- object store: bytes are re-hashed while copying and verified against
  the object key (TOCTOU fail-closed); dedup hits re-hash the existing
  object and heal mismatched content; store directories refuse symlinks
- error hygiene: messages that can reach model-visible content carry
  basenames only and a structured upstream error summary (status +
  code/message) instead of raw response bodies
- getPolicy: validate the OSS ACL fields so a partial policy fails with
  a clear error instead of an opaque OSS 403

Part of the omni-experiment S1 slice (#8183).

* refactor(omni): apply simplification review round

- reuse the shared combineAbortSignals helper (with listener cleanup)
  instead of a local AbortSignal combinator
- deduplicate the stream-SHA-256 helper: storage now imports
  hashFileSha256 from recognition
- collapse probeBinary's callback plumbing into a Map-based
  availability cache
- drop the unreachable video/x-matroska extension branch (the sniffer
  never emits that MIME type)
- move the omni read-result shaping out of fileUtils into
  readVideoViaOmniDelivery so the file-reading hot path only carries
  the gate check and a single delegation call

Part of the omni-experiment S1 slice (#8183).

* fix(omni): keep abort/timeout coverage through response body reads

Regression review of the previous two commits found:

- upload.ts: cleanup() of the combined abort signal ran at
  headers-arrival time, leaving the subsequent response body read
  (getPolicy JSON / failure summary) unabortable — a stalled body would
  hang with neither the timeout nor ESC able to interrupt it. Body
  handling now happens inside the signal's lifetime.
- storage.ts: the rename-race fallback could convert a user abort into
  a dedup success after an unabortable full-file re-hash; aborts now
  rethrow before the fallback, and the verify hash carries the signal.
- storage.ts: healing a planted entry now removes directories too, not
  just files.

Part of the omni-experiment S1 slice (#8183).

* fix(cli): make Esc cancel @-preprocessing (media uploads) in the TUI

The @-resolution phase of a user query (file reads, and with omni
enabled potentially minutes of media hashing + upload) ran while
streamingState still reported Idle: no loading indicator appeared and
cancelOngoingRequest early-returned, so Esc was silently swallowed for
the whole preprocessing window.

Track an isPreparingQuery state around prepareQueryForGemini for
interactive user queries and feed it into streamingState. Preprocessing
now renders the spinner with the esc-to-cancel hint, and Esc aborts the
turn controller — the signal already flowed through handleAtCommand
into the omni upload pipeline, which cancels cleanly (verified: no
orphan temp files, no lingering sockets, session stays usable).

Scoped to SendMessageType.UserQuery: notification/cron/teammate drains
key off streamingState transitions and keep their existing behavior;
/btw side-questions leave the main stream state untouched.

Part of the omni-experiment S1 slice (#8183).

* docs(omni): add multimodal experiment architecture designs (#8110)

Four interlinked design docs for the omni multimodal experiment:

- file recognition & metadata: unified MediaRecognitionService, two
  normalization trigger points, URL localization, raw-resource token
  estimation, ffmpeg/ffprobe as hard dependency
- policy orchestration: MediaPolicyTool contract with per-invocation
  args + per-tool settings, fixedPolicies with condition DSL, transport
  guard on upload-channel limits, DashScope temporary-upload delivery
  (all-upload, no inline), lossy-output disclosure contract
- memory: two collection trigger points (FileRecognized /
  OmniPolicySucceeded), harness-exclusive writes, in-file graph,
  active/side-query recall sharing one protocol
- managed media storage: content-addressed object store, staging /
  quarantine lifecycle, upload cache (sha256+model -> oss URL, 48h),
  mark-and-sweep GC rooted at memory references

Verified against origin/main and validated end-to-end against the
DashScope uploads API (123KB / 21MB / 424MB video, image, audio).

* feat(omni): S2 input expansion — image/audio/URL sources and token-dimension transport guard (#8512)

* feat(omni): S2 input expansion — three modalities, URL sources, token guard

Extends the S1 video-only upload delivery to the full S2 input surface:

- recognition: generalized to image/audio/video with content sniffing
  (magic bytes for png/jpeg/webp/gif, mp3/wav/flac/ogg/m4a, plus the S1
  video set) and per-modality ffprobe metadata; modality mismatches
  between sniff and reference fail closed
- delivery: all modalities upload ORIGINAL bytes — no resizing or
  transcoding on the default path (lossy transforms are the job of omni
  policies, which must disclose); images skip renderImageOverview when
  omni is active and carry a dimensions+zoom-hint text part instead
- converter: new fileData→input_audio branch passing the bare oss:// URL
  (previously audio fileData silently textified as 'Unsupported file
  media type' after a successful upload); getAudioFormat widened to
  flac/ogg/m4a in lockstep with the recognizer
- URL inputs: @https://… is intercepted ahead of filesystem resolution
  (previously silently dropped via the ENOENT skip), streamed to
  downloads/ with SSRF/redirect policy mirroring fetchWithPolicy, hashed
  while writing, re-recognized from local bytes, and delivered through
  the same pipeline; per-URL failure isolation keeps the turn alive
- token estimation: versioned raw-resource estimator (method
  raw-resource-v1) attached to read results as the single tokenEstimate
  location; formula is a swappable slot pending provider confirmation
- transport guard: byte ceiling (S1) plus omni.transport.maxEstimatedTokens
  (default 0 = disabled — observability only until the formula is
  confirmed; positive values fail closed before any copy/upload)
- second normalization trigger point: processToolResultOmniMedia converts
  inline tool-result media to oss:// fileData at both physical funnels
  (CoreToolScheduler terminal sites and ACP Session.runTool), as a
  sibling of the vision bridge — converted parts are invisible to
  isImagePart so bridge behavior is untouched

Part of the omni-experiment S2 slice (#8184).

* fix(cli): deliver URL-only @ media instead of dropping it after upload

The no-file-paths early return in resolveAtCommandQuery did not count
urlMediaRefs, so a prompt whose only @-reference was a URL ran the full
download → recognize → store → upload pipeline and then discarded both
the delivered fileData parts and their display cards. Found by S2 E2E
verification (the model improvised around the missing image via its own
tools, masking the drop).

Part of the omni-experiment S2 slice (#8184).

* fix(omni): apply S2 adversarial review findings

Security lens:
- tool-result media funnel: per-result upload budget (8 parts /
  128 MiB aggregate — a malicious tool result must not fan out
  unbounded uploads from the user's account); excess parts stay inline
- modality gate on tool-result parts now keyed to the SNIFFED modality,
  not the declared MIME type (a part declared audio whose bytes are a
  video container no longer bypasses a video-disabled config)
- fs error messages are path-scrubbed before reaching model-visible
  error text (basenames survive, absolute paths do not)

Correctness lens:
- non-sniffable media formats (bmp/tiff/heic images, exotic containers)
  fall back to the baseline inline path via a cheap content pre-sniff
  instead of failing closed — fail-closed remains for genuine pipeline
  failures, not for formats the recognizer simply does not support
- user cancellation during URL media download ends the turn gracefully
  instead of rejecting through resolveAtCommandQuery (which has no
  throw contract) into an unhandled-rejection banner

Part of the omni-experiment S2 slice (#8184).

* fix(omni): keep the omni graph out of the ACP static bundle closure

The serve fast-path bundle-closure CI check failed: iconv-lite encoding
tables (553KB) became statically reachable from the acpAgent chunk.

Root cause: the CLI-side dynamic imports of the FULL core barrel
(`import('@qwen-code/qwen-code-core')` in atCommandProcessor) request
the whole namespace object, defeating tree-shaking of the barrel — which
statically re-exports sync-file-encoding → iconvHelper → iconv-lite
tables — and esbuild folds a dynamic import of an already-statically-
imported module into a static chunk edge.

Fixes:
- add a `./omni` subpath export to @qwen-code/qwen-code-core (pattern:
  existing ./transcriptRecords) and point every CLI-side dynamic import
  at it, so only the omni module graph is requested;
- re-export processToolResultOmniMedia from omni/index (circular-safe:
  both modules only bind functions) so the subpath serves the
  tool-result funnel;
- coreToolScheduler and ACP Session load the funnel via dynamic import
  (mirroring fileUtils' existing pattern for the omni module).

Verified: `npm run check:serve-fast-path-bundle` passes; bisected
against base in a clean worktree to isolate the offending edge.

Part of the omni-experiment S2 slice (#8184).

* fix(build): register the core ./omni subpath in test resolver configs

The previous fix added a `./omni` subpath export to
@qwen-code/qwen-code-core, but vitest/tsc in this monorepo resolve the
core package through explicit aliases to TS sources rather than the
package exports map — so every suite loading atCommandProcessor or the
ACP Session failed with "Failed to resolve import
'@qwen-code/qwen-code-core/omni'".

Add the subpath alias alongside the existing transcriptRecords/goalWire
precedents in cli/acp-bridge/sdk-typescript vitest configs and the
cli/acp-bridge tsconfig paths.

Part of the omni-experiment S2 slice (#8184).

* test: isolate runtime-path suites from the runner's QWEN_RUNTIME_DIR

The self-hosted CI runners export QWEN_RUNTIME_DIR for their own qwen
job tooling. Storage.getRuntimeBaseDir gives that env var top priority,
so logger.test.ts (HOME-derived path expectations) and the ACP Session
runtime-pinning tests (Storage.setRuntimeBaseDir-based expectations)
fail whenever a PR lands on such a runner — independent of the change
under test. Reproduced locally with QWEN_RUNTIME_DIR=/tmp/fake-spool.

Clear the variable inside the affected suites (restored after each
test), making them hermetic on any runner.

* fix(omni): gate the omni dynamic import behind isOmniEnabled

The `await import()` of the omni graph inside processSingleFileContent and
resolveAtCommandQuery performs a real filesystem read under vitest (the SSR
transform writes and reads the module under /tmp/RP_*/ssr/), so mock-fs
based suites — pathReader's image cases — failed with ENOENT on a path they
never mocked.

Gate both call sites on the cheap synchronous config.isOmniEnabled() before
reaching for the import. Sessions without omni configured (every existing
test suite, and every user who has not opted in) now never load the module,
which also avoids the wasted module load on the common path. Delivery
behavior when omni IS enabled is unchanged: isOmniDeliveryActive still
performs the full five-condition gate check afterwards.

* fix(omni): resolve DNS before each media fetch and close review gaps

SSRF (review-blocking): downloadMediaUrl's gate called only isPrivateHost,
which classifies hostname text and IP literals but never resolves. A
syntactically public name whose A/AAAA record points at 127.0.0.1,
169.254.169.254 or an RFC1918 address was therefore accepted, and because
this path uploads the fetched bytes to DashScope, an internal endpoint's
response would be exfiltrated.

Add findNonPublicAddress() to utils/fetch.ts: it resolves the hostname via
dns.lookup({all: true}) and rejects if ANY returned address is non-public,
since the connect may pick any of them. IP literals short-circuit without a
lookup. Extract isPrivateAddress() as the shared bare-address classifier so
isPrivateIp keeps one implementation.

download.ts calls it inside the redirect loop, immediately before each
connect, so every accepted hop is re-validated — a permitted same-host
redirect can still re-resolve to a private address between hops.

Two deliberate choices, both recorded in the doc comment: the helper is NOT
wired into fetchWithPolicy, because WebFetch intentionally supports intranet
FQDNs resolving to private addresses, so applying it there would be a silent
breaking change; and DNS failure returns null rather than failing closed,
because the connect that follows reports the real error and failing closed
would turn a resolver blip into something the user cannot act on. The
residual check-then-connect race is documented too: fetch resolves again, so
closing it fully needs an agent that pins the connect to a vetted address,
which undici does not expose portably.

Also fixed: sniffMediaType classified any file starting with 0xFF 0xFE (the
UTF-16 LE BOM) as audio/mpeg, because 0xFE passes the frame-sync mask. Real
MPEG frames never use 0xFE — the layer bits would be 11, reserved — so
excluding it costs no genuine detection and keeps the "null for non-media"
contract honest for callers without a secondary modality gate.

Tests for the previously uncovered branches reviewers flagged:
- download: public-looking host resolving to 127.0.0.1/169.254.169.254/
  10.1.2.3; mixed A records where only one is private; per-hop re-check
  asserting the redirect is refused before the second fetch; DNS failure;
  the ?? fetch fallback via a globalThis spy; and the production DNS path
  asserting dns.lookup receives {all: true, verbatim: true}
- readMediaViaOmniDelivery: image gets the resolution + zoom_image hint
  parts, audio gets a bare fileData, failure yields an error result and
  never inline base64
- probeMediaMetadata: the audio branch reads the audio stream (proven with a
  file carrying both h264 and aac), the image branch reports no duration
- sniffFileModality: recognized headers, non-media, and an absent path
- atCommandProcessor URL pipeline: a URL with omni active becomes a fileData
  part, the same URL with omni disabled falls through as text without
  downloading, and a URL-only prompt does not hit the no-content early
  return

* fix(omni): pin URL-media connections to the vetted address, fail closed

Validating the resolved address and then letting fetch re-resolve is
check-then-connect: a low-TTL record answering public once and private
the second time would still be connected to, and the fetched bytes are
uploaded to a third party. Each hop now resolves via
resolveNetworkTarget('public') — which vets every returned address and
returns a lookup pinned to the vetted one — and installs that lookup as
the request dispatcher, so the name is never resolved a second time.

- fail closed: unresolvable/unvettable hosts and targets without a
  pinned lookup are refused instead of fetched unpinned
- re-validated and re-pinned at every redirect hop; the previous hop's
  agent is replaced and closed
- fetch defaults to undici's own fetch so dispatcher and fetch come
  from one undici version (mixing majors rejects the agent)
- Node-only: Bun accepts dispatcher and silently ignores it (verified
  on 1.3.11), so non-Node runtimes are refused with a clear message
- https and credential-free URLs enforced with named errors
- utils/fetch.ts: remove findNonPublicAddress/HostResolver (a weaker
  duplicate of resolveNetworkTarget) and the doc paragraph wrongly
  claiming undici cannot pin connections

* fix(cli): stop the @-path parser truncating URLs at query strings

The @-path terminator set treats '?', '&', ',' and ';' as sentence
punctuation, which is right for filesystem paths but wrong for URL refs:
a presigned OSS/S3 link carries its signature in the query string, so
'@https://…/a.mkv?x-oss-signature=…' was silently cut at the '?' and the
download failed with HTTP 403 naming only the host.

URL refs now run to the first character RFC 3986 does not permit
unencoded (covering whitespace and unspaced CJK prose), with trailing
sentence punctuation trimmed afterwards so it can never cut a URL short
mid-query. Filesystem path parsing is unchanged.

* fix(cli): highlight the full URL ref in the input box

The input-box highlighter had its own @-ref charset that excluded '%',
'?', '&' and '=', so a presigned URL was painted file-colored only up to
the first percent-escape and looked truncated even after the parser was
fixed to consume it whole. URL refs now use the same RFC 3986 charset
and trailing-punctuation trim as parseAllAtCommands, keeping the painted
span identical to what the parser consumes.

* fix(omni): close review round-2 findings

Criticals:
- tool-result-media: staging-dir setup moved inside convertPart's try —
  a mkdir failure (ENOSPC, EACCES, ~/.qwen/omni as a regular file) now
  degrades that part to inline instead of rejecting the whole tool
  result and reporting a succeeded tool as failed
- sanitizeErrorMessage: exact split/join replacement of the known
  filePath before the pattern pass (immune to CJK segments, ~-prefixed
  and special-character basenames), with separator-based — not
  ASCII-word-based — fallback classes and a Windows-path pass
- the fail-closed delivery result now sanitizes BOTH llmContent and the
  error field (the scheduler sends 'error', not llmContent, to the model
  on READ_CONTENT_FAILURE) and names the file by displayName
- the 100 MB image-source cap only applies when the overview decoder
  will actually run: it protects the decoder, and the omni path uploads
  original bytes under its own 1 GiB ceiling without decoding

Suggestions:
- malformed redirect Location surfaces as a named OmniDownloadError
  instead of a raw TypeError (header value kept out of the message)
- header/idle watchdog timeouts are now identifiable in the surfaced
  error instead of a generic 'This operation was aborted'
- effectiveMaxDownloadFileBytes is clamped to the upload cap even when
  omni.download.maxFileBytes is configured higher
- estimation rejects degenerate present values (0/negative/non-finite)
  as 'unavailable' instead of emitting a 0-token 'ok' estimate
- corrected the 0xFE sniff comment: 0xFF 0xFE is a valid MPEG-1 Layer I
  header (reserved encodings are version 01 / layer 00); the exclusion
  is a deliberate trade against UTF-16 LE false positives
- SSRF tests now cover private IPv6 (loopback, link-local, ULA,
  IPv4-mapped) plus a public-IPv6 acceptance case

Regression tests are mutation-verified: reverting the mkdir placement
or the error-field sanitization makes exactly the new tests fail.

* fix(omni): close review round-3 criticals

- sanitizeErrorMessage: a path segment containing a space defeats the
  pattern pass (segment classes break at whitespace), so 'john doe'
  survived scrubbing. Call sites now pass every path they know about:
  the store wrap adds the store root (covers putFile throwing before
  objectPath is assigned), and the upload wrap - previously passing
  err.message through verbatim - sanitizes with the object path and
  store root. Function exported for direct shape tests.
- estimation: animated images (GIF/APNG/animated WebP) no longer
  estimate as a single frame. The image probe branch retains ffprobe's
  nb_frames as frameCount, and the estimator applies the design 6.4
  visual formula with the real count - a 480x480 300-frame GIF now
  estimates ~33,750 tokens instead of 113, so an enabled token guard
  actually engages. Static images and containers that do not report a
  frame count keep frameCount 1.
- tool-result media: a transport-guard rejection is a policy verdict,
  not a transfer failure - the part is now withheld with a text
  placeholder instead of being delivered inline as base64, which
  silently bypassed an enabled guard at greater request cost than the
  upload it was rejecting. Transfer failures keep the inline
  degradation; the batch never fails for one part.
- mid-turn URL media: resolveAtCommandQuery runs under a fixed 10s
  mid-turn budget that structurally killed every mid-turn @https
  reference (the download alone can exceed it, and inside the resolver
  the timeout abort is indistinguishable from a user cancel). URL-media
  turns are now exempt from that timer - the download path carries its
  own 30s header and 60s idle watchdogs - while filesystem resolution
  keeps the 10s cap unchanged.
- input-box highlighter: match the URL scheme case-insensitively, like
  the parser it mirrors; an uppercase @HTTPS ref no longer half-paints.

* fix(omni): close review round-3 findings

- download: resolve-phase watchdog, malformed-URL and proxy refusal,
  resolver-error translation, path scrubbing, cause-chain surfacing,
  DownloadedMedia slimmed to partPath; mutation-proven tests for
  redirect cap, both watchdogs, byte-cap streaming, abort wiring
- recognition: direct recognizeMediaFile tests, GIF 6-byte signature,
  guarded probe-handle close; sha256 moved out of RecognizedMedia
- pipeline: hash after guards (stat -> byte guard -> probe -> token
  guard -> hash); displayName override so URL-funnel errors name the
  remote file, not the staging path
- fileUtils: omni gating suite incl. 100 MB image-cap bypass pin
- dead S1 shims deleted (video-only probe/delivery/read wrappers)
- URL funnel: post-download modality gate mirroring the local-file gate

* feat(omni): S3 delivery reliability — upload cache, credential reuse, failure semantics, startup recovery (#8632)

* feat(omni): S3 delivery reliability — upload cache, credential reuse, recovery

Third omni slice: repeated deliveries stop re-uploading, transient
server-side failures self-heal, and crash leftovers get swept.

- upload cache: persistent (sha256, model) → oss:// URL map at
  .qwen/omni/upload-cache.json with a 47h TTL (48h official validity
  minus margin). Atomic writes; corrupt files are backed up and rebuilt;
  per-file op serialization prevents same-process load-modify-save
  races; TTL configurable via omni.upload.cacheTtlHours (0 disables).
  The cache file is the only place omni persists an oss URL — the URL
  remains a delivery cache, never an identity.
- credential reuse: module-level getPolicy cache keyed by
  origin|model|apiKey-hash, 240s TTL (300s official validity), shared
  in-flight fetches, failures never cached.
- failure semantics: provider errors that name the oss scheme or the
  media download step invalidate the matching cache entries (throttling
  is explicitly exempted), so the user's retry re-uploads instead of
  resending a dead reference — no automatic in-pipeline resend.
- startup recovery (lazy, once per process, never throws): expired
  downloads/*.part removed (48h resume window), promotion .tmp orphans
  older than a 1h grace window removed (younger ones may be another
  process's in-flight rename), and a day-rotating sample of ≤3 objects
  (≤64MiB each) is hash-verified with corrupt objects deleted and their
  cache entries cascaded.

Verified: 89 omni unit tests; 9/9 real-API E2E (cache hit 3.1× faster on
a 20MB image; fake-URL 403 → invalidate → self-heal on retry; corruption
cascade; recovery sweep), adversarial review round applied (6 findings
fixed: cross-process .tmp grace, sampling bias+size cap, throttle-safe
invalidation matching, apiKey-scoped credentials, NaN expiry, in-process
cache races).

Part of the omni-experiment S3 slice (#8185).

* fix(omni): harden S3 cache, credential, and invalidation paths from review

Upload cache (upload-cache.ts):
- reject entries:null/array shapes as corrupt (backup + rebuild) instead
  of throwing raw TypeErrors on every subsequent read
- treat only ENOENT/ENOTDIR as an empty cache; other read errors skip the
  operation rather than persisting a wiped file over valid entries
- key entries by (sha256, model, scope) where scope fingerprints the
  endpoint credential, so URLs never cross accounts or origins
- clamp positive TTLs to the 48h server-side URL validity
- cap .corrupt-* backups at 2; shrink the fileOps serializer map

Credential cache (upload.ts):
- getPolicy no longer bakes the first caller's AbortSignal into the
  shared in-flight fetch; each caller races the shared promise against
  its own signal, so one caller's abort cannot poison others

Failure semantics (pipeline.ts):
- invalidate oss cache entries on the streaming error path too (the CLI
  default) by threading the request into processStreamWithLogging
- tighten /oss/i to /oss:\/\//i so 'connection loss' never nukes
  healthy entries; await the invalidation instead of fire-and-forget

Pipeline integration (index.ts):
- check the upload cache BEFORE store promotion: a hit now skips the
  full-file copy as well as the upload
- pass the credential-scope fingerprint into the cache

Recovery (recovery.ts):
- key the one-shot latch by omni root; guard a synchronously throwing
  store; fix the stride-sampling tail blind spot; make sample budgets
  injectable for tests; correct the .part retention comment

Adds mutation-proof suites across all five areas: 89 tests in the omni
package plus a dedicated pipeline invalidation suite.

* fix(omni): keep startup recovery inside the managed omni root

Recovery readdir'd downloads/, the objects root, and each shard without
checking for symlinks, and hashed sampled candidates via lstat without an
isFile() check — so a symlinked shard (or a symlinked downloads/ or
objects/ directory) could redirect the scan outside .qwen/omni, where a
hash-mismatched name would get an external file deleted. The store's own
symlink guard only runs at putFile, after recovery.

- reject symlinked/non-directory managed dirs (lstat isDirectory) before
  traversing downloads/, the objects root, and every shard
- reject non-regular-file candidates before hashing in the sampler
- regression tests: symlinked shard (reviewer repro), symlinked
  downloads/, symlinked objects root, symlinked candidate — all four
  fail against the unfixed code and pin that external files survive
  with content intact

* fix(omni): close root-level symlink escape and add wholesale cache TTL sweep

- startup recovery now verifies the intermediate directory chain (omni
  root and objects/) with lstat before any sweep runs: the per-directory
  guards only check the final path component, so a symlink planted at
  .qwen/omni or .qwen/omni/objects redirected the entire scan outside
  the managed tree; two containment tests mirror the existing four at
  the new levels
- upload-cache put() now sweeps all expired entries while it holds the
  serialized write: get() only prunes the queried key, so entries never
  read again accumulated in upload-cache.json forever

* feat(omni): S4 policy pipeline — three-modality degradation policies, Stage B tools, modelAccess gating (#8815)

* refactor(omni): migrate settings to design namespace (processing/delivery/ingestion/storage)

- omni.upload.maxFileBytes -> omni.processing.transportGuard.maxUploadFileBytes (<=1GiB)
- omni.transport.maxEstimatedTokens -> omni.processing.transportGuard.maxEstimatedTokens
- omni.upload.cacheTtlHours -> omni.delivery.upload.urlTtlHours
- omni.download.maxFileBytes -> omni.ingestion.localization.url.maxFileBytes
- new: omni.processing.{limits,fixedPolicies,transportGuard.policies,policyTools},
  omni.storage.quarantine.{retentionDays,maxBytes}
- core getters renamed to match; error messages cite new key paths

Experimental branch: one-shot rename, no migration shim.

* feat(omni): surface bitRate/sampleRateHz/channels from ffprobe

ffprobe already reports these; the parse branches discarded them. The
policy condition DSL (resource.bitRate / resource.sampleRateHz /
resource.channels) and degradation disclosures both need the real
numbers. Format-level bit_rate is preferred over the stream's.

* feat(omni): add media-policy tool protocol (execution origin, descriptor, modelAccess gate)

Protocol core for the S4 policy pipeline:

- ToolExecutionOrigin (model | client | fixed_policy) on
  ToolCallRequestInfo; only settable by in-process callers, never
  deserialized from protocol payloads; missing origin fails closed as
  model.
- MediaPolicyToolDescriptor as a DeclarativeTool code-registration
  getter (default undefined) — config can never turn an ordinary tool
  into a policy tool.
- PolicyArtifactBatch channel on ToolCallResponseInfo, capturing raw
  successful media-policy artifacts before PostToolUse hook merging;
  error/timeout results promote nothing.
- Shared modelAccess resolver + call gate
  (omni/policy/model-access.ts): media-policy tools are
  fixed-policy-only unless
  omni.processing.policyTools.<name>.modelAccess.enabled; model calls
  get defaultArguments/lockedArguments merged and are rejected when
  they name a locked key; a forged fixed_policy origin on a
  non-media-policy tool is rejected.
- Gate enforced on every model surface: registry declaration lists
  (incl. includeDeferred for subagents), ToolSearch keyword + select:,
  CoreToolScheduler pre-build, and ACP Session.runTool.
- fixed_policy scheduler calls skip the interactive permission flow but
  keep PermissionManager tool-enablement and hook execution.
- omni.processing.policyTools threaded through ConfigParameters
  (core + cli).

* feat(omni): add staging/quarantine storage areas and recovery sweeps

Storage design §4.3/§4.4/§6.1 for the policy pipeline:

- OmniObjectStore gains staging/ and quarantine/ areas (0o700, symlink
  refusal in ensureLayout) plus the invocation lifecycle: exclusive
  createStagingDir (16-hex id validation, no silent reuse),
  removeStagingDir, and quarantineInvocation — which writes reason.json
  (policyId/toolName/reason/failedAt) into the staging directory before
  a single atomic rename into quarantine/<invocationId>/.
- Startup recovery deletes everything under staging/ (entries belong to
  invocations that never committed) and trims quarantine/ to a
  retention window (default 7 days) and size budget (default 5 GiB,
  oldest-first), following the existing isRealDirectory containment
  convention: symlinked roots and entries are never traversed, sized,
  or deleted through.

* feat(omni): add fixed-policy when-condition DSL evaluator and validator

Implement the restricted when-condition DSL from the policy design (§8.3):
recursive all/any combinators over gt|gte|lt|lte|eq comparisons, with
operands drawn from three read-only namespaces (resource.*, request.*,
session.*) or literals. No arbitrary code, no JSONPath.

Evaluation is three-valued. A comparison over an unresolvable field yields
`unavailable` with the missing fields recorded — never a silent false —
so the caller can apply the policy's onConditionUnavailable behavior and
surface the fields in the run record. Combinators use strong Kleene
logic: `all` with a false branch is a determinate no_match regardless of
unavailable siblings; `any` with a true branch is a determinate match.
Vacuous semantics: all [] → match, any [] → no_match.

The evaluator is a total function that never throws — structurally
malformed nodes degrade to `unavailable`, the fail-safe outcome. The
separate structural validator (for the startup config-normalization pass,
policy design §13 #5) rejects malformed conditions with path-annotated
errors: exactly-one-of comparison/all/any, non-empty combinator arrays,
exactly-one-of field/value per operand, known-field membership, and
finite numeric literals for ordering operators.

Condition types are re-exported from omni/policy/types.ts alongside the
rest of the policy protocol surface.

* feat(omni): add three-modality degradation media-policy tools

Commit 6 of the S4 policy pipeline: the three built-in degradation tools
(mapping doc §6), each a real BaseDeclarativeTool carrying a
media_policy descriptor so the orchestrator and the modelAccess gate can
key off code-level facts.

- omni/policy/tools/media-policy-tool.ts: shared base — validates
  against the NATIVE parameter schema (never the model-visible
  projection Stage B will narrow), async io assertions (lstat: symlink
  input refused, real output directory required), per-tool
  policyTools.<tool>.runtime.timeoutMs resolution (default 600s),
  uniform success/error ToolResults; success emits exactly one lossy
  artifact whose metadata.omniDisclosure carries the D8 disclosure and
  whose workspacePath is staging-relative.
- omni_downsample_image (sharp, lazy-loaded per D9; load failure is an
  execution failure): fit 1568px / JPEG q75, EXIF orientation baked in,
  animated inputs refused outright (sharp would silently keep only the
  first frame).
- omni_downscale_video (ffmpeg): scale to even target height computed
  in JS (no filtergraph expressions), fps 10, x264 crf 28 veryfast;
  audio stream-copy with AAC 64k fallback.
- omni_downsample_audio (ffmpeg): AAC 64kbps/16kHz/mono, -vn strips
  cover art.
- omni/ffmpeg.ts: runFfmpeg — never rejects, callers branch on the exit
  code and must check signal.aborted explicitly.
- Registered lazily behind isOmniEnabled(); the commit-3 modelAccess
  gate keeps them hidden from model surfaces by default.

* feat(omni): add degradation result cache keyed by policy fingerprint

Commit 7 of the S4 policy pipeline (decision D2): reusing a previous
transcode instead of re-paying it — a 424MB video downscale is
minutes-long and must not run once per delivery round.

- omni/json-cache-file.ts: the file mechanics extracted verbatim from
  upload-cache (per-file serialized load-modify-save, atomic tmp+rename
  0600 writes, corrupt backup+rebuild with capped .corrupt-* backups,
  unreadable-but-existing file = operation no-op) as a shared
  OmniJsonCacheFile — the design doc mandates the degradation cache
  mirror these exact semantics, so they now exist once.
- omni/upload-cache.ts: refactored onto the shared file; entry
  semantics (model/scope keys, TTL clamp, invalidation) unchanged — all
  22 existing tests pass untouched.
- omni/policy/degradation-cache.ts: (originalSha256, policyFingerprint)
  → { degradedSha256, extension, disclosure, mimeType } at
  .qwen/omni/policy-cache.json. policyFingerprint = sha256(toolName +
  key-sorted tunables + tool version); per-invocation io params
  (inputPath/outputDir) are excluded — they are plumbing, not policy
  identity. Entries carry no TTL (content-addressed identities never go
  stale); removeByOriginalSha256 / removeByDegradedSha256 serve the
  GC/corruption cascades. Object existence checks stay with the
  orchestrator.

* feat(omni): add fixed-policy orchestrator and reorder the delivery pipeline

Introduce runFixedPolicies: for each recognized media resource it matches
normalized fixed policies (modality, origins, when-conditions), executes the
policy's media tool through the real executeToolCall protocol (fixed_policy
execution origin, recordToolResult:false, isolated staging outputDir),
validates the returned policy artifacts against the tool's declared
descriptor (workspace containment, recognized kind/mime match, mandatory
disclosure for lossy outputs), promotes derivatives into objects/ and records
them in the degradation cache keyed by original sha + policy fingerprint.

Reorder processMediaForOmniDelivery so transport guards judge the FINAL
delivery set instead of the source: recognize -> recovery -> fixed policies
-> guards -> hash -> upload. Degraded deliveries carry a disclosure that is
emitted as a text Part immediately before the media Part everywhere media
surfaces (read tool results, tool-result inline media conversion), and the
OpenAI converter moves the disclosure together with its media part when
splitting tool media into a follow-up user message.

* feat(omni): normalize omni.processing config with system default policies

Startup normalization of the fixed-policy pipeline configuration
(policy design §13 applicable subset):

- omni/policy/config.ts: normalizeOmniProcessingConfig merges user
  settings over system defaults (id-merge, whole-entry replacement,
  null tombstones for fixedPolicies only) and validates structure,
  enums, when-conditions, tool references (registered + media_policy
  descriptor + required/lossy-disclosure outputs), fixed arguments
  against the io-stripped settingsSchema, reserved io keys, guard
  rules (no when, source=omit, mandatory three-modality coverage),
  limits (§12.2 defaults), the 1 GiB upload cap and the 48h URL TTL.
  Any violation throws OmniPolicyConfigError and aborts startup — a
  mis-configured guard must never degrade into sending over-limit
  media.
- System defaults (D7 dual registration): the three degradation tools
  registered as preprocessing fixedPolicies WITH when-thresholds and
  as transportGuard.policies WITHOUT when.
- core Config: thread fixedPolicies / transportGuard.policies /
  limits / quarantine settings; normalize in initialize() after tool
  warmup; expose getOmniProcessingConfig and quarantine getters.
- cli: thread the new omni.processing / omni.storage.quarantine keys.

* feat(omni): enforce policy budgets, transport-guard passes and quarantine (Stage B)

- runFixedPolicies enforces maxPolicyRunsPerRoot (checked before each
  execution), maxArtifactsPerRoot and maxDerivedBytesPerRoot (checked as
  derivatives land); exhaustion records outcome 'budget_exhausted' while
  committed deliveries stand; maxLineageDepth clamps reprocessing.
- Failed invocations quarantine their staging dir with a reason.json
  (policyId, toolName, reason, failedAt); user aborts and quarantine
  failures fall back to plain staging removal. Startup recovery receives
  quarantine retention/size settings from config.
- Transport-guard violations now run modality-matched guard policies for
  up to maxTransportPasses passes on the final delivery; a still-violating
  resource is explicitly omitted (【媒体省略】notice replaces the part in
  both the read path and tool-result funnel) instead of delivered
  oversized; guard-pass failures stay fail-closed. Configs without a
  normalized processing config keep the Stage A throw.
- Media-policy tools project a model-visible declaration (D6): locked
  arguments removed from properties/required, optional narrowing-only
  parameterSchema merge and description override; validation keeps the
  native schema.

* fix(omni): declare the disclosure text output in degradation tool descriptors

All three degradation tools emit a disclosure at runtime, but their
descriptors never declared the text output, so the system default
policies failed the lossy-requires-disclosure validation (config §13 #8)
at real startup. Stub-based unit tests missed the drift; config.test.ts
now normalizes the defaults against the real tool instances.

* fix(omni): harden policy pipeline per review (cache validation, permissions, settings, sweep grace, concurrency)

- validate degradation-cache entries and object-store path components
  (sha256/extension shape) so a poisoned policy-cache.json cannot traverse
  paths or serve malformed derivatives; malformed entries self-heal
- re-hash cache-hit objects before reuse: mismatched bytes trigger a fresh
  transcode and heal the object store (D2 integrity)
- default media policy tools to 'ask' permission via a shared
  BaseMediaPolicyToolInvocation (model-origin calls confirm outside yolo;
  fixed_policy runs are unaffected)
- consume policyTools settings from config: tool-level settings defaults
  merge under policy arguments and feed the cache fingerprint so settings
  edits invalidate cached derivatives
- deliver @url omission notices and degradation disclosures: omission
  replaces the fileData part with the notice text; disclosure text lands
  immediately before its fileData part (D8)
- give staging sweep a 1h multi-process grace window (only entries older
  than the window are deleted; symlink entries removed regardless of age)
- gate concurrent fixed-policy runs per omni root with a FIFO counting
  semaphore honoring maxConcurrentResources

* fix(omni): harden policy pipeline after self-review round

Orchestrator: exclude animated images from policy matching (D9), self-heal
unverifiable degradation-cache entries, key cache fingerprints with the
descriptor version, and warn when reprocess origins exclude 'policy'.

Scheduler: fixed_policy invocations no longer bounce PreToolUse 'ask' to an
unanswerable awaiting_approval (deny stays fail-closed), and
processToolResultImages short-circuits for fixed_policy results at the
method level so all three call sites skip the omni re-delivery / vision
bridge re-entrancy.

Delivery/CLI: chain preprocessing and guard disclosures instead of replacing
(D8), exclude hidden media-policy tools from the /context per-tool breakdown
to keep it aligned with getFunctionDeclarations(), annotate fixed-only
media-policy tools in /tools, and drop the stale maxConcurrency mention from
the policyTools settings description.

Tests: add negative coverage for artifact validation (no artifacts,
undeclared media type, kind mismatch, missing required output) and for the
fixed_policy hook ask/deny and image-funnel paths.

* fix(omni): drop system-default fixedPolicies; guard is the only default

The three degradation tools were registered twice: as transportGuard
policies (design-mandated, always-on, transport-limit triggered) and as
fixedPolicies with invented when-thresholds (1568px/480p/96kbps). The
upstream design gives fixedPolicies pure user-experiment semantics — a
zero-config setup must not trigger any preprocessing below transport
limits. Remove the fixedPolicies-side registration and its threshold
constants; keep the guard defaults. Threshold acceptance for #8186 moves
to explicitly-configured fixedPolicies in E2E.

* feat(omni): add four local stage B policy tools (keyframes/extract-audio/clip/convert)

- omni_extract_keyframes: ffmpeg scene detection (select+showinfo) with
  uniform-fps fallback, multi-artifact batch, per-frame disclosures
- omni_extract_audio: audio track extraction to WAV/MP3/M4A, 16kHz mono
  WAV default (ASR-recommended shape)
- omni_clip_video: frame-accurate time-axis cut (input-side -ss/-t,
  libx264 re-encode, faststart), rejects no-op invocations
- omni_convert_image: sharp re-encode to JPEG/PNG/WEBP with EXIF
  orientation baked in, animated inputs refused
- uniform lossy declaration per mapping doc §6.1: every output carries a
  disclosure since the representation change itself is lossy
- extract shared sharp loader (sharp-module.ts) and describeChannels
  helper; register the four tools lazily behind isOmniEnabled()

* feat(omni): add omni_transcribe_audio with transcript delivery protocol

* feat(omni): fill request/session condition namespaces and wire client origin

The when-condition evaluator has supported the request.* and session.*
namespaces since the initial S4 landing, but no caller populated them, so
every policy referencing those fields resolved to 'unavailable'. This
fills both per policy design section 8.3:

- request.totalEstimatedMediaTokens is computed inside the orchestrator
  from the pending-delivery work-item set at each pass start, so it is
  recomputed as derivatives enter later passes. If any pending resource
  is unestimable the whole namespace reads unavailable - a partial sum
  must never pass thresholds as a smaller total. Callers can override
  via options.conditionContext.request (future multi-root aggregation).
- session.* is snapshotted once per media delivery by the new
  policy/session-context.ts (contextWindowTokens from
  contentGeneratorConfig.contextWindowSize, promptTokenCount from the
  CURRENT chat's getLastPromptTokenCount, availableContextTokens as a
  clamped subtraction only when both inputs are known) and reused across
  preprocessing and every transport-guard pass of that delivery.

Also wires the client execution origin: slash commands scheduling a tool
(schedule_tool in useGeminiStream) now stamp executionOrigin
{kind:'client'}, putting the in-process client channel under the same
media-policy modelAccess gate as model calls.

* fix(omni): deliver every media deliverable of multi-output fixed policies

A fixed policy producing multiple media deliverables (omni_extract_keyframes
promoting N frames) previously failed delivery with "exactly one is
supported" after all frames were already atomically promoted to objects/ —
zero frames reached the model, violating the multi-output transaction
visibility acceptance.

Fixed-side contract now: deliveries[0] is the primary (unchanged path:
guard re-check, upload, disclosure); the rest ride along as
OmniMediaDelivery.additionalMedia, each independently judged against the
transport limits and uploaded through the same hash → upload-cache →
objects promotion → DashScope pipeline. Extras are processed lazily after
the primary settles, so the primary uploads first and a fail-closed
primary never wastes extra uploads. An over-limit extra becomes an
explicit omission entry (visible reason, no guard-policy re-derivation —
it is already a policy product). The guard side keeps its strict 1:1
semantics.

All three delivery consumers (readMediaViaOmniDelivery, tool-result
media conversion, the @-command URL funnel) materialize extras through a
shared buildAdditionalMediaParts helper: per-extra [disclosure?,
fileData | omission text] pairs after the primary slot, before
transcripts, preserving disclosure adjacency per pair.

* fix(core): detect mkv/avi/flac/aac media missing from mime/lite standard db

mime/lite's default database carries no mapping for .mkv/.avi/.flac/.aac,
so detectFileType fell through to the binary content sampler and a real
Matroska movie was rejected by the 10MB inline cap instead of entering
media delivery. Generalize the existing .m4v override map to cover these
containers (authoritative types from the full mime db), scoped to
extensions the omni recognizer can sniff-confirm so extension lies still
fall back to the legacy path.

* fix(omni): harden policy pipeline per self-review correctness findings

- Validate operator-configured defaultArguments/lockedArguments against a
  tool's NATIVE parameterSchema instead of the model-visible projection,
  which strips exactly those keys and rejected every legitimate locked or
  operator-only argument at startup; regression test uses real tools.
- Write cache files with noFollow so the atomic rename replaces a symlink
  planted at the cache path instead of writing (and chmod-ing) through it.
- Route every .part staging write through prepareOmniDownloadsDir, a
  fail-closed symlink guard (mkdir recursive succeeds silently on a link).
- Detect ADTS AAC in the MPEG-audio sniffer (layer bits 00) so .aac
  streams stop being labeled audio/mpeg.
- Preserve metadata.omniRole in degradation-cache entries so cached
  reruns keep the same artifact matching as fresh derivations.
- Fall back to the default quarantine retention when the configured value
  is zero/negative/NaN instead of expiring the whole quarantine.
- Propagate error causes through OmniTransportGuardError and document the
  operatorOnlyParams contract on MediaPolicyToolDescriptor.

* refactor(omni): consolidate media-policy tool plumbing

- Single-source tool names from ToolNames across all eight policy tools
  and the config validator.
- Share the config view on the base class (protected configView) instead
  of eight shadowed per-subclass fields; move the view type and the
  isPlainRecord helper to policy/types.
- Memoize the model-visible schema projection on the settings object's
  identity (one normalized settings object per initialize()).
- Extract sharpTimeoutSeconds for the duplicated libvips timeout
  conversion and route clip-video's io validation through the base
  validateToolParamValues.

* refactor(omni): share the transcript materializer across delivery consumers

Extract buildTranscriptParts (§6.2/D8 ordering: each transcript follows
its media part or omission notice, preceded by its own disclosure) and
use it from readMediaViaOmniDelivery, the tool-result funnel, and the
CLI @-command URL funnel instead of three hand-rolled loops. Also
extract the textOnlyDelivery helper, upload additional-media extras
concurrently (content-addressed store and per-file serialized upload
cache make them independent; map preserves deliverable order), and drop
the now-consumerless formatTranscriptText barrel export.

* perf(omni): parallelize orchestrator validation and promotion

Validate a batch's artifacts and promote them into the object store
concurrently — they are independent files in a shared staging dir and
the store is content-addressed with race-safe puts; map keeps the batch
order and a sequential partition preserves derived/derivedFiles order.
File artifacts hash the bytes already in memory for the UTF-8 check
instead of streaming the file a second time.

* fix(omni): add display-name translations for the media-policy tools

The eight omni media-policy tools landed without toolDisplayName locale
entries (cli zh/zh-TW/en) or web-shell wire-name display mappings and
toolName translations, tripping both display-name drift guards
(cli src/i18n/index.test.ts and web-shell toolFormatting tests).

* fix(omni): open the download .part fd before the pipeline starts

createWriteStream opens lazily, so a pipeline failure on an
all-synchronous body (the byte cap tripping on the first chunks) could
reject — and reach the outer .part cleanup — before the file was even
created, resurrecting the .part after its rm. Await the open event
before streaming so creation always precedes cleanup.

* feat(omni): switch when-condition DSL to expression arrays

Replace the {left, operator, right} object form with Mapbox-style
expression arrays: a comparison is [op, operand, operand] with op one of
> >= < <= == !=, an operand is a ["field", "<namespace.name>"]
reference or a bare literal, and combinators nest as ["all", ...],
["any", ...], and the new ["!", expr] negation. The array form is
shorter to write, nests without ceremony, and leaves room for future
operators.

Semantics are unchanged: three-valued evaluation with strong Kleene
combinators, unavailable fields never silently collapse to false, and
negation passes unavailable through rather than laundering unknowns.
Startup validation rejects the retired object form with a pointed
migration hint instead of a generic type error.

* feat(omni): degrade delivered media and retry when the server rejects input as over-limit

The server's real input ceiling is unknowable locally (frame-based video
billing is invisible to the byte/duration estimator), so a 400 like
DashScope's 'Range of input length should be [1, 196608]' previously
aborted the session even though history compression cannot shrink media
tokens.

Now, before reactive compression, the chat loop reverse-maps each
delivered oss:// URL through the upload cache to its stored object,
re-runs the configured transport-guard policy with an escalating
argument ladder (video fps 2 -> 0.5 -> 0.25 is the only lever that
reduces frame-billed tokens), re-uploads the derivative, swaps the
history parts in place with a fresh disclosure, and retries. Bounded by
limits.maxTransportPasses; best-effort throughout - any failure falls
through to the existing compression/error paths.

Also: parse the DashScope input-range upper bound as limitTokens, allow
fractional fps in omni_downscale_video, and add a scope-agnostic
findSha256ByUrl reverse lookup to the upload cache.

* feat(omni): inject progressive media understanding guidance into the system prompt

Disclosure parts state WHAT a delivery lost, but nothing told the model
WHY: degradation is a context-budget-driven progressive-understanding
strategy, the degraded delivery is an overview rather than the complete
content, and fuller evidence can be fetched on demand. Without that
contract the model treats a 600s clip + opening keyframes as the whole
film and extrapolates.

- omni/media-guidance.ts: builds a stable system-prompt section that
  explains the three disclosure markers, frames degraded deliveries as
  progressive-understanding overviews (never conclude undelivered
  content doesn't exist), and directs proactive targeted evidence
  fetching via exactly the modelAccess-enabled media policy tools; with
  none enabled it instructs stating missing evidence instead of
  extrapolating. Returns null when omni delivery is inactive.
- prompts.ts: new stable SystemPromptLayers slot mediaGuidance, placed
  right after base so it stays inside the cached static prefix.
- client.ts: wire the section into the main-session stable layers.
- omni/delivery-gate.ts: isOmniDeliveryActive moved out of omni/index.ts
  into a leaf module so prompt assembly doesn't statically pull the
  whole delivery pipeline; index.ts re-exports it unchanged.

* fix(omni): extract keyframes across the full video duration (bucketed sampling)

The scene-detection pass stopped at the first maxFrames scene changes,
so every keyframe of a long video landed in its opening minutes (an
81-minute film yielded 16 frames all within 0-130s). Split the timeline
into maxFrames equal buckets instead: each bucket contributes one frame
- a scene change from its opening window (input seek, 30s search cap)
when one exists, the bucket midpoint otherwise. Absolute timestamps are
reconstructed as bucketStart + showinfo pts_time. Individual bucket
failures are tolerated and all runs share one wall-clock budget. The
single-pass path remains for unknown duration or maxFrames = 1; the
now-unreachable uniform-sampling fallback is removed.

* fix(omni): chunk long-audio transcription and collapse repetition degeneration

A single ASR request over long audio truncates well before the end and
degenerates into repetition loops (an 81-min film came back as 2114
chars ending in 48 copies of "Hej!"). Audio longer than chunkSeconds
(default 180s) is now split into equal segments, re-encoded to 16kHz
mono AAC, transcribed with bounded concurrency under one shared
wall-clock budget, and assembled into [MM:SS-MM:SS]-labeled lines.
Per-segment failures become inline markers instead of failing the run;
the run only errors when every segment failed. Repetition degeneration
is detected and collapsed in every transcript (chunked and single-shot)
and reported in the disclosure.

* docs(omni): add media policy orchestration usage guide

* fix(omni): key pure-transcript guard resolution on this pass's file deliveries

A transport-guard pass that omitted the source without producing any
deliverable used to short-circuit into textOnlyDelivery whenever an
EARLIER pass had already collected a transcript, silently dropping the
zero-deliverable error. Key the branch on the current pass's
fileDeliveries instead of the cumulative transcript list.

* fix(omni): render the primary disclosure on pure-transcript deliveries

All three delivery consumers (readMediaViaOmniDelivery, tool-result
media, @-command processor) dropped delivery.disclosure whenever the
primary media degraded all the way to a transcript-only delivery. The
disclosure chains every prior lossy step (decision D8) and the
transcript was derived through those steps, so it must still render —
as the leading text part of the delivery.

* fix(omni): prune non-object entry values when loading a JSON cache file

The load-time shape check validated the top-level {version, entries}
envelope but trusted every entry VALUE, so a crafted workspace cache
holding e.g. `"key": null` surfaced as TypeErrors from field accessors
in whole-file scans (removeByDegradedSha256 touches every entry, not
just the requested key). Drop malformed values at load and log the
count; the next put() rebuilds them from verified data.

* fix(omni): harden degradation-cache hits against tampered entries

Two gaps in the workspace-shippable cache's trust boundary:

- disclosure had no length ceiling, leaving an unbounded
  prompt-stuffing channel that renders into the conversation on every
  cache hit. Entries with a disclosure over 2048 chars are now dropped
  like any other malformed field.
- the hit path verified the derivative's hash but not its TYPE: a
  swapped extension/mimeType could deliver a media kind the producing
  tool never declared. The orchestrator now cross-checks the recognized
  MIME type against the tool descriptor's declared media outputs and
  re-transcodes on mismatch, dropping the entry.

* fix(omni): include the hours field when a rounded duration reaches 3600s

withHours compared the raw duration while formatClock rounds each
boundary: a 3599.6s audio rounded to 3600s inside the label and, with
hours disabled, rendered as "00:00". Round the duration the same way
formatClock does before deciding the format.

* fix(omni): cap chunked transcription at 512 segments (fail closed)

The claimed duration comes from container metadata, which a crafted
file controls freely: an absurd duration used to fan out into hundreds
of thousands of outcome slots and queued ffmpeg cuts before the time
budget could intervene. Reject anything over 512 segments — at the 30s
chunkSeconds floor that is ~4h16m of audio, far past what the 10MiB
default input cap plausibly holds.

* fix(omni): disable the omni pipeline in bare mode

isOmniEnabled ignored bareMode, so --bare sessions still registered the
media-policy tools, ran content normalization and the ffmpeg startup
assert, and gated deliveries. Bare mode now wins over the env opt-in at
the single choke point every omni entry path already consults.

* fix(omni): degrade media nested in functionResponse.parts on reactive retry

The reactive over-limit fallback only walked top-level content parts,
so media delivered inside a tool result's functionResponse.parts (the
normal carrier for tool media) was invisible to it: the retry loop
resent an unchanged request and stalled. Collection and replacement now
descend one level into functionResponse.parts, and the swap rebuilds
the nested array in place — the disclosure must land immediately before
its media part (D8), which hoisting to the top level would break.

* fix(omni): detect animated images whose headers carry no frame count

Animated WebP/APNG containers report no nb_frames, so the D9 animation
exclusion silently passed them and the image tools kept only the first
frame with no disclosure. Two independent gates now close this:

- the ffprobe path re-probes animation-capable containers
  (gif/webp/png/apng) with -count_frames when nb_frames is absent
- both image tools additionally check sharp's metadata().pages before
  encoding and refuse multi-page inputs

* docs(omni): align tunable descriptions with the implemented semantics

- maxWidth/maxHeight are same-value aliases of width/height, not
  independent axis caps
- request.totalEstimatedMediaTokens is the per-resource estimate
  (resource plus its derivatives) within the current scheduling pass,
  not a cross-resource total

* fix(omni): keep a raw "__proto__" policy id as an ordinary own key

A JSON settings file can carry "__proto__" as an ordinary key; spreading
it into a plain object routes it through the prototype setter and the
entry silently vanishes. Merge policy maps onto a null-prototype object
so the id reaches normalizePolicy and is rejected by the id pattern.

* fix(omni): validate transportGuard.maxEstimatedTokens at startup

Settings load performs no runtime type checks and guard.ts compares the
threshold with <=/>: a string value makes both comparisons false and
silently disables the token guard (fail-open). Reject anything but a
finite number >= 0 at normalization, and thread the value through
Config so the check actually sees it.

* fix(omni): raise quarantine retention/budget schema minimums to 1

The runtime accessors treat non-positive values as absent and fall back
to the defaults, so a configured 0 never means "delete immediately" /
"no budget" — advertising minimum: 0 in the IDE schema promised a
semantic the code does not honor. Document the fallback in the
descriptions as well.

* docs(omni): correct the fixedPolicies merge/tombstone description

There are no built-in default fixed policies, so "null tombstones a
default policy" described a merge target that does not exist. Describe
what the merge actually does: entries merge by id across settings
scopes and a null entry tombstones a policy from a lower-priority
scope.

* fix(omni): expose io paths to the AUTO-mode permission classifier

Media policy tools override getDefaultPermission but left
toAutoClassifierInput at the empty-string sentinel, so the AUTO-mode
classifier saw "Arguments: {}" and its path-based block rules could
never fire on a model-origin call. Project exactly inputPath and
outputDir — the fields those rules key on, carrying no secrets.

* fix(omni): reject unknown keys in policyTools entries (§13 #1)

A typo like "settigns" or "modelaccess" read as an absent optional
section downstream, so the intended configuration silently never took
effect. Fail startup on unknown keys at the entry, runtime, and
modelAccess levels, and drop the stale maxConcurrency mention from the
runtime doc comment (timeoutMs is the only supported limit).

* fix(omni): reject parameterSchema overrides that loosen native constraints

Design §11.2: a projection may only narrow the tool's native schema —
类型、枚举、范围 included, not just the property set. The declaration
merges each projected property's keys over the native ones, so an
override raising maximum, lowering minimum, widening an enum, changing
the type, or relaxing min/max length/items promised the model a range
the native per-call validation then rejects. Validate the merged
property schema against the native effective bounds at startup
(minimum/exclusiveMinimum interplay included).

* fix(omni): clamp downsample-audio targets to the probed source

A source already below the target bit rate, sample rate, or channel
count re-encoded into a LARGER derivative that the transport guard
counted as progress, under a disclosure claiming 高频细节丢失/声道合并
losses that never happened. Clamp each target to the probed source
(the withoutEnlargement analogue of the image/video tools) and build
the loss clause from the drops that actually occurred (D8).

* fix(omni): reject full-span no-op clips instead of re-encoding

clip_video is a time-axis cut, not a degradation tool: a span covering
the entire video only burns a lossy re-encode while the disclosure
falsely claims content outside the span was discarded. Reject
startSec 0 with no durationSec at the parameter layer, and a
probed-duration-covering span before the transcode starts.

* fix(omni): disclose partial bucket coverage in keyframe extraction

When some buckets yield no frame (scene attempt and midpoint fallback
both empty), the blanket 全片分桶采样 note overstated coverage — the
un-sampled buckets' time ranges were silently invisible to the model.
Disclose 仅覆盖 N/M 个分桶,其余时段未采样 whenever fewer frames than
buckets survive (D8).

* fix(omni): only claim alpha loss for alpha-capable JPEG conversion sources

A JPEG→JPEG re-encode cannot drop a transparency channel that never
existed, yet the disclosure asserted 透明通道丢弃 unconditionally.
Derive the loss clause from the probed input codec: alpha-capable
formats (png/webp/gif/tiff) keep the claim, others get 元数据丢弃, and
an unknown codec hedges with 如有 (D8).

* fix(omni): build the downscale-video loss clause from actual drops

A 360p@8fps input downscaled against the 480p/10fps defaults lowers
neither dimension, yet the disclosure always claimed
分辨率与帧率下降 — contradicting the before/after figures shown right
next to it. Emit 分辨率下降/帧率下降 only for the parameters that
actually dropped, falling back to 重新编码压缩 for a pure re-encode
(D8).

* fix(omni): stop calling a degraded delivery "full resolution" in the zoom hint

On the degradation path delivery.recognized re-recognizes the
DERIVATIVE, so the hint's width/height are the downsampled dimensions.
Calling them "full resolution" contradicted the degradation disclosure
pushed immediately below and steered the model away from zoom_image —
the exact remedy for degradation-stripped detail, since it reads the
original from disk.

* fix(omni): localize the fixed-only tool description suffix

The '[fixed-only: runs via media policies, not the model]' suffix was
appended as a hard-coded English string, so zh/zh-TW tool listings
showed a mixed-language description. Route it through the i18n table
like the display names it accompanies.

* fix(omni): unify the downscale-video display name as 降采样视频

降清视频 was a coined term inconsistent with the 降采样图像/降采样音频
siblings; use 降采样视频 (zh-TW: 降採樣影片) across the CLI locales and
the web-shell tool-name table.

* test(omni): match the actual .tmp suffix in cache-file cleanup assertions

The write-temp files are named <name>.<random>.tmp; filtering on
'.tmp-' could never match anything, so the leak assertions were
vacuously green. Filter on the real endsWith('.tmp') suffix.

* test(omni): pin the repetition-collapse rep threshold independently of the span floor

The 好的, unit (3 chars × 7 reps = 21 chars) sat below the 24-char span
floor, so the test passed for the wrong reason and REPETITION_MIN_REPS
itself was unpinned. Use a 6-char unit so 7 reps already clear the span
floor, and assert the collapse output at 8 reps.

* fix(omni): cascade corrupt-object deletion into the degradation cache

Startup recovery's sample verification deleted a corrupt object and its
upload-cache entry but left policy-cache.json untouched, accumulating
entries that can never be served again. Cascade the deletion both ways
— the object may be a policy SOURCE (its derivatives can no longer be
re-verified) or a policy DERIVATIVE (entries pointing at it are dead) —
mirroring the storage design's upload-cache cascade. The orchestrator's
lazy self-heal only fires on a cache hit; the sweep side is proactive.

* refactor(omni): resolve role selectors via a role→output-spec map

The role existence check consulted a Set while the transcript shape
check re-scanned descriptor.outputs with a find, duplicating the
first-declaration-wins semantics across two structures. A single
Map<role, spec> now serves both.

* test(omni): pin the when-condition round-trip through normalization (D7)

when is preprocessing's ONLY trigger mechanism: a normalization
regression that drops or rewrites it would silently widen every user
condition to ALL matching resources, with no failing test. Pin that a
valid condition tree survives normalization verbatim.

* test(omni): pin the runtime.timeoutMs / staging-grace boundary (recovery §5)

The timeoutMs < STAGING_GRACE_MS cap is a cross-file invariant: a tool
allowed to run for the full grace window could have its live staging
directory swept as crash leftovers mid-run by another process's startup
recovery. Pin BOTH sides of the boundary against the exported constant
so removing, inverting, or relocating the cap fails a test.

* feat(omni): S5 multimodal media memory — two-trigger collection, root-bounded recall, active/sideQuery surfaces (#8949)

* feat(omni): collect multimodal media memory at recognition and policy-success points

Add the media-memory service (packages/core/src/services/media-memory):
a persistent, file-scoped lineage graph over .qwen/omni/memory.json that
records recognized files/versions and policy executions with their
derived media and text outputs.

Collection side (Stage A of the multimodal memory design):

- store.ts: single-JSON v1 backend with atomic writes, corrupt-document
  backup/rebuild, and read/transact serialization shared with the omni
  JSON cache chain.
- service.ts: recordFileRecognized (idempotent file+version upsert,
  CURRENT_VERSION follows disk both ways) and commitPolicySucceeded
  (one atomic commit per succeeded policy execution: execution record,
  derived versions with DERIVED_FROM/PRODUCED_BY lineage, and
  derived_media/policy_result entries with conservative channel and
  coverage derivations). executionId is a content-identity key
  (source sha256 + policy fingerprint), so replays and degradation-cache
  hits converge on the same execution node. findBindingBySha256 gives
  the reactive ladder a read-only identity lookup.
- config.ts: omni.memory normalization with fail-fast validation
  (unknown keys, budgets, kinds, cross-field ordering).

Wire both trigger points: processMediaForOmniDelivery records the
source file after recognition (hashing the source upfront and threading
sha256 through the policy pipeline), and executePolicy commits after
object promotion on both the success and degradation-cache-hit paths.
Persistence failures are logged and never block delivery; a policy
commit itself stays all-or-nothing.

* fix(omni): label keyframe and clip artifacts with their roles

omni_extract_keyframes and omni_clip_video emitted artifacts without
metadata.omniRole, so role consumers could not tell an excerpt from a
complete derivative: output routing selectors (role:keyframe /
role:clip) never matched, and media-memory coverage derivation fell
through to 'complete' for sampled keyframes and temporal clips.
Label them 'keyframe' and 'clip', matching the transcript labeling
convention.

* feat(omni): add session resource registry and memory recall service

Stage B read side of multimodal media memory (design M §9):

- MediaResourceRegistry: session-scoped opaque handle binder
  (fileVersionId <-> resourceId, M §5.2) so recall payloads never
  carry real paths or stable identifiers.
- MediaMemoryRecallService: current-version-first recall (§9.5) over
  the rootFileId-bounded derivation graph (§8), returning the minimal
  protocol shape (§9.4) with synthesized metadata/execution entries,
  honest gaps (not_processed / partial_coverage / artifact_unavailable,
  D5), and advisor-driven nextPolicyActions. Unknown or empty resource
  requests reject as a whole (§9.2); an unreadable store degrades to
  a plain miss.

* feat(omni): mount session media resource registry on delivery

Hang a lazy MediaResourceRegistry off Config and bind every
memory-known resource the delivery pipeline puts in front of the
model — the source at recognition, each preprocessing deliverable,
and the transport-guard replacement — so recall can rebind opaque
session handles back to persistent memory identities without ever
exposing a path.

* feat(omni): add active-mode media memory recall tool and session handle disclosure

Stage B recall surface #1 (memory design M §9, D10 mutual exclusion):

- omni_recall_media_memory tool, registered only when omni is enabled AND
  omni.memory.recall.mode === 'active'. Read-only (D11): consults the
  persistent store through MediaMemoryRecallService and binds session
  handles for returned derived artifacts; whole-request rejection maps to
  invalid_tool_params so the model can correct and retry.
- Session resource handles are now disclosed to the model: delivery
  carries the SOURCE binding's resourceId, and both consumers (file reads
  and the tool-result funnel) lead the part group with a 【媒体资源】
  annotation — the handle stands in for the path the model never sees
  (M §5.2), while disclosures keep their D8 adjacency to the media part.
- Advisor wiring: recall gaps suggest nextPolicyActions only for tools
  that are both registered and opened via modelAccess.enabled — recall
  never steers the model into calls the media-policy gate would reject.
- omni.memory normalization moved to first use (ensureOmniMemoryConfig):
  createToolRegistry needs recall.mode before initialize()'s omni block
  runs; invalid settings stay startup-fatal on both paths.

* feat(omni): resolve resourceId to inputPath at the media-policy call gate

Stage B evidence-gathering path (memory design M §5.2): a gated
model/client call of a media-policy tool may now name its source by the
opaque session resourceId announced at delivery or returned by recall,
instead of a filesystem path the model must never see.

- evaluateMediaPolicyToolCall resolves the handle through the session
  registry BEFORE the lockedArguments check (a resolved inputPath cannot
  sidestep an operator-pinned input); unknown/fabricated handles and
  inputPath+resourceId together reject as invalid_params; fixed_policy
  origins remain untouched (RESERVED_ARGUMENT_KEYS already bans the key
  in policy arguments).
- Shared io schema advertises resourceId as the alternative to
  inputPath; required drops inputPath (presence is enforced in
  validateMediaPolicyIoParams AFTER gate resolution, with an actionable
  either-or message).
- assertMediaPolicyIo input errors now name only the file's basename:
  those messages reach the model, and a handle-resolved call must not
  leak the locator the handle stands in for — basename matches the
  displayName the model already saw at delivery (D5: a deleted source
  errors without disclosing its path).

* feat(omni): passive sideQuery media-memory recall before the main request

Stage B recall surface #2 (memory design M §9.3, D10 sideQuery mode):

- MediaMemoryRecallService grows the two sideQuery faces on the same
  root-bounded walk recall() uses: candidateSummaries() builds the
  bounded selector manifest (structure + capped preview, never raw
  media/full text/paths; maxCandidateEntries cap, deterministic order),
  and recallSelection() materializes a selection through the unified
  protocol — an unknown/over-budget entryId rejects the WHOLE selection
  (invalid_selection), never a partial fulfilment.
- runOmniMemorySideQuery orchestrates one passive pass: handles are
  parsed from the 【媒体资源】 annotations the request itself carries
  (never a project-wide scan), the selector runs on the runSideQuery
  JSON face (entryIds only, validate enforces manifest membership and
  maxSelectedEntries), and every failure — timeout, selector error,
  rejected selection — degrades to an empty recall with a recorded
  reason while the main request proceeds.
- client.ts injects the materialized recall as a system reminder on
  UserQuery/Cron turns strictly BEFORE the main request is sent (M
  §9.3: never retrofitted into a later turn); latency is bounded by
  sideQuery.timeoutMs.
- disclosure.ts gains parseResourceHandleText, keying on the
  harness-minted handle grammar so displayNames containing the
  separator cannot confuse extraction.

* fix(omni): propagate the recall tool display name to every drift-guarded surface

A new core tool name must land in four places, not one: core
ToolNames/ToolDisplayNames, the cli i18n dictionaries, and the web-shell
display-name map plus its own zh dictionary. Two drift tests guard this
(cli i18n/index.test.ts and web-shell toolFormatting) and both were red.

* fix(omni): reject unknown omni.memory root keys, stop advising on deleted sources, keep the selector's view of the question

Three independent correctness fixes in the memory slice:

- Config normalization rejected unknown keys at every nested level but
  not at the root, so `{recalll: {...}}` silently discarded the entire
  configuration and ran default active mode while the operator believed
  sideQuery was configured — the exact silent fallback this module's
  startup-fatal stance exists to prevent.
- gapsForVersion emitted `artifact_unavailable` for a deleted source and
  then CONTINUED the channel scan, adding `not_processed` siblings. The
  advisor only filters `artifact_unavailable`, so recall steered the
  model into evidence-gathering calls on a file that no longer exists —
  the gate resolves the handle and the io assertion then fails. Nothing
  can be gathered from a deleted file: report the loss and stop.
- The sideQuery selector dropped any text part starting with
  `<system-reminder>`, but IDE context is PREPENDED INTO the user's own
  part before the passive pass runs — so in IDE mode the selector never
  saw the question and picked relevance-blind. Strip reminder blocks
  instead of discarding the part.

* fix(omni): bump clip/keyframe descriptor versions alongside their role annotations

The role labels these tools gained are what memory maps to honest
coverage (`clip` → partial, `keyframe` → sampled); role-less outputs
derive `complete`. The descriptor version feeds the policy fingerprint,
which keys both the degradation cache and the content-identity execution
id — so leaving it at '1' let pre-role cache entries and recorded
executions converge onto the post-change fingerprint two ways: a cache
hit recommits with the persisted `role: undefined`, and the replay
branch returns without upgrading recorded entries.

Either path makes recall report a temporal excerpt or a sampled frame
set as complete coverage: the model then answers about footage it never
saw and the advisor never proposes closing the gap. Bumping the version
partitions pre- and post-annotation state instead.

* fix(omni): prune malformed memory record values instead of blacking out every read

`load()` validated the document envelope but not the record values inside
it. A single non-object value — one hand edit, bad merge, or truncated
sync — made every read path throw (`indexSnapshot` dereferences
`version.parentVersionId`, `findBindingBySha256` reads `version.sha256`),
and those throws are caught into miss/empty by the never-fatal read
wrappers. The result was a PERMANENT global recall blackout for the whole
project, with no `.corrupt-*` backup and nothing named in the debug log,
because the envelope itself stayed valid.

Prune the bad values individually and name them, matching the sibling
`OmniJsonCacheFile.load()` defense. Dangling references to pruned records
already degrade gracefully — a missing version reads as an
`artifact_unavailable` gap.

* fix(omni): give every file its own policy execution and derivative rows

Two Files with identical bytes shared one execution node: the key was
`sha256 ⊕ configHash`, so the second file took the replay branch and got
ZERO records of its own, while the first file's derivative versions were
handed back stamped with the second file's rootFileId. Recall on the
second file then reported false `not_processed` gaps across every channel
and re-paid the work, and the derivative's lineage pointed into a
different root's tree. M §11.2/§11.3 prescribe the opposite: identical
bytes never merge Files, each File writes its own PolicyExecution and
provenance, and only the computation and the stored bytes are reused.

Three layers, cleanly separated:

- execution identity is now per-File (source VERSION ⊕ config), so a
  replay is a genuine same-file replay and stays idempotent;
- the content-identity reuse key (`sha256 ⊕ config`) is derived
  separately and spans Files — that is what makes cross-file reuse
  possible while provenance stays apart. A match records
  `reusedExecutionId` (declared since the schema landed, never assigned
  until now) pointing at the original;
- derivative File nodes are keyed by (root, object path) instead of the
  object path alone, so each root owns cheap metadata rows over the SAME
  content-addressed object rather than sharing a node whose root belongs
  to whoever derived it first — bounded traversal (M §8) holds for both.

* fix(omni): anchor tool-result media to the object store, not its staging file

Collection recorded the source's persistent identity as the path it was
handed — correct for a user file, whose bytes stay in place (S §4), but
wrong for tool-result media: that path is a staging `.part` the funnel
deletes in its `finally` the same turn, while this same delivery promotes
the identical bytes into the content-addressed object store.

So the disclosed handle resolved to a file that no longer existed: any
media-policy tool the model pointed at it failed with ENOENT, and recall
reported `artifact_unavailable` across every channel for an artifact that
actually persists. Tool-origin sources now record (and bind) the
content-addressed object location, which is derivable from the hash
before promotion runs. If promotion never happens — the transport guard
omitted the media — the ref dangles and recall says
`artifact_unavailable`: honest, because the bytes were not retained.

* feat(omni): commit model and client policy successes through the same memory boundary

Memory design M §7.1 requires fixed calls, model tool calls and direct
client calls to enter ONE `OmniPolicySucceeded` boundary, and §17 names
both capture sites — the core scheduler and ACP's own executor. Only the
fixed-policy orchestrator ever committed: the scheduler built the
`PolicyArtifactBatch` (with the real origin) and nothing consumed it, and
the "model-call artifact bridge" its comment referred to did not exist.

So evidence gathering never accumulated. Recall reported a gap, the
advisor suggested a tool, the model called it with a session handle, the
tool succeeded and wrote real artifacts — and the next recall reported the
identical gap, re-paying the work every session, forever. That is exactly
the loop the resourceId path in this PR was built to close.

The bridge walks the same gates in the same order as the orchestrator:
validate each artifact against the descriptor (contained in the output
directory the call declared), require every declared output, promote to
the content-addressed store, THEN commit (M §6.4 / S §5 — no record may
reference bytes that are not yet in `objects/`). A handle-driven call
recovers its source identity from the session registry instead of
re-hashing the input; a raw-path call falls back to an idempotent
recognition upsert. Fixed-policy batches are skipped: the orchestrator
already owns them.

`resourceId` also joins the fingerprint's excluded keys. Like
`inputPath`, it is per-invocation plumbing — and it is minted fresh every
session, so leaving it in would make each session re-derive identical
work.

* feat(omni): reuse recorded policy outputs instead of re-running the tool

The degradation cache maps one input to ONE media derivative, so its own
comment admits that multi-output tools and text products are "simply not
cached — re-run instead of guessing". That made #8189's «同文件同 settings
二次触发同一 policy:直接复用,无重复执行» unmet in the cases that cost the
most: re-delivering an 81-minute movie re-ran the whole ASR pass every
time, because a transcript is a text product.

Memory already records every output of every execution, keyed by content
identity — so it can answer the reuse question the cache cannot. The
orchestrator now consults it BEFORE the cache and, on a hit, rebuilds the
deliverables from the recorded objects and skips execution entirely,
covering media, text and multi-output alike.

Reuse is verified exactly like a cache hit, because memory.json is
project-local and hand-editable: every object must still be a regular
file, must still hash to its recorded identity, and a media object must
re-recognize as a type the tool DECLARES producing — otherwise a crafted
record could route arbitrary store content through a policy that never
made it. Text is re-read from the promoted object rather than from the
entry's `inlineText`, which is truncated to
`collection.maxInlineTextBytes` and must never be delivered as the whole
transcript. Any doubt falls through to a fresh derivation.

The reusing file still commits its own execution, stamped with
`reusedExecutionId` (M §11.3): the computation is shared, the provenance
is not.

* docs(omni): reconcile the memory design with the S5 issue acceptance wording

Two acceptance criteria in #8188 read as direct contradictions of this
document, and one of them contradicts #8189 as well. Both are resolved by
naming the layer each statement belongs to, rather than by changing the
model.

- §11.2.1 (new): «内容相同的两个文件不重复建节点» and «不合并不同文件»
  live on different layers. Content-keyed reuse covers the physical object
  and the derived computation; the File node stays locator-keyed because
  identity MUST be stable across content changes — key it on content and
  §11.1's version chain and #8189's «修改内容后旧衍生物不默认召回» both
  become inexpressible (an edit yields an unrelated node, and editing back
  makes history a cycle). Also records why derivatives are keyed by
  (root, object path).
- §9.2.1 (new): «按路径/内容 hash 查询» and «返回中永不暴露真实本地路径»
  are reconciled per caller — the model gets session handles only
  (unforgeable capability; a path parameter would let injected media steer
  it into probing other files, whose transcripts recall returns), while
  path/hash lookup stays a harness capability that already exists. The
  known cost is written down too: a deleted file's memory is currently
  unreachable, and the fix belongs in the authorization layer
  (@-reference re-anchoring), not in widening what the model may pass.

The issue bodies were updated to match.

* fix(omni): reuse text products too, and fail on unknown omni settings sections

Both found by running the real pipeline rather than the unit suites.

Text-product reuse never fired. `findReusableOutputs` only resolves an
object path for outputs that have a derived VERSION node, which text
products do not have — so `rebuildReusedOutputs` bailed and the tool
re-ran. The comment there even said the caller reconstructs the path from
the content hash; that half was never written. A real run made it obvious:
extract-audio and keyframes logged reuse hits while the transcript re-ran
the entire ASR pass — the single most expensive thing #8189 exists to
avoid, and the headline claim of the reuse commit. The object store is
content-addressed, so the path is derivable; text outputs now reuse like
media. The existing test passed because it used a media artifact, so a
transcript-shaped case was added alongside it.

Unknown sections under `omni` were silently ignored. The settings loader
scans TOP-LEVEL keys only, and only to a debug line, so a nested typo was
caught by nothing: `omni.memoryy.recall.mode = sideQuery` started
cleanly and registered the active-mode recall tool — the operator's whole
configuration discarded without a word. The same hole swallowed
`omni.processingg`, which would silently disable every degradation
policy. `omni`'s own normalizers are startup-fatal by design, so the
namespace now rejects unknown sections with the allowed set named. The
permitted keys are derived from the settings schema, not hardcoded, so
they cannot drift.

* fix(omni): stop policy artifacts colliding, guard duration, record real execution windows

Three defects a real multi-session run surfaced. The first destroyed work.

**Fixed output filenames collided.** Every policy tool wrote a constant
name (`clip.mp4`, `downsampled.jpg`, `transcript.txt`). Under
fixed-policy orchestration that is safe — each invocation gets its own
staging directory — but `modelAccess` lets a caller pick a PERSISTENT
`outputDir`, and there the second call silently destroyed the first
artifact. In the run: a clip cut on day one was overwritten by a different
clip on day three, and the commentary written against the first clip began
describing the wrong footage. Worse, the model noticed something was off,
could not diagnose it, and skipped re-watching — so the defect surfaced as
degraded output quality rather than an error.

Names now carry the axes that distinguish artifacts: the source stem, plus
a natural variant where the operation has one (a clip's span, a frame's
index). Same source and variant still resolve to one name, so re-running
an operation replaces its own output — idempotent, not destructive. The
keyframe lister derives its matcher from the same template, so it can
never pick up a sibling video's frames out of a shared directory.

**The transport guard had no duration dimension.** A 98-minute film
downscaled to 474 MB cleared the 1 GiB byte ceiling, uploaded, and was
then refused by the provider for being too long: the guard reported
success, the caller got an opaque 400, and a 97-second transcode was paid
for nothing. Duration is a transport limit like the others, so
`maxDurationSeconds` now sits beside the byte and token checks with the
same semantics (unset disables it; missing metadata never rejects) and a
message that names duration and says downscaling cannot fix it.

**Model-origin executions recorded the collection window, not the work.**
`startedAt` was taken after the tool had already run, so extracting audio
from a 98-minute film was recorded as 0.6s. The scheduler already tracks
`executionStartTime` (approval wait excluded); it is now threaded through.

* test(omni): keep '+' in generated artifact stems

A derived artifact of a clip re-uses the clip's filename as its source
stem, and the clip's own variant contains '+' (`123s+40s`). Stripping it
made the derived stem drift from its parent (`clip-123s-40s-keyframe-…`).
'+' is portable on every major filesystem, so keep it: names now round-trip
through a derivation chain.

* feat(omni): re-anchor remembered media, and say when recall was truncated

Two friction points the character-analysis run surfaced.

**A remembered file's memory was unreachable once its bytes were gone.** A
handle is minted only at delivery, and delivery needs the bytes — so a
transcript or keyframe set outlived the file it described with no way back
into a session. The same wall hits an audit that does not want to
re-deliver a 2.4 GB film just to ask what work was recorded: with no
handle, recall rejects everything, and the natural next move (passing the
filename as a resourceId) is correctly refused. Observed exactly that: the
model reached for `resourceIds: ["robot-dreams.mkv"]` and got
`not issued in this session`.

An `@`-reference to a missing-but-remembered path now mints a handle from
the recorded identity — the user's reference is the same authorization a
delivery carries, so the fix belongs at the authorization layer rather
than by widening what the model may pass (design M §9.2.1). The model gets
the handle plus an explicit 【媒体缺失】 note, so it recalls instead of
trying to read. Path/locator lookup stays a harness capability.

**A truncated recall page was indistinguishable from an exhaustive one.**
An audit read 6 clips under `limit: 12` and reported "no keyframes were
ever extracted" while the store held 72 of them. The reader was honest
about what it saw — it simply had no way to know it was looking at a page.
`matchedEntries` now states the full match count, and only when the budget
actually cut something, so an exhaustive page stays as small as before
(§9.4 minimal return).

* feat(omni): tell the model what a media handle is and to recall before reprocessing

The handle annotation ships with every memory-known delivery, but nothing
in the model's context explained it: the media-guidance section listed only
the three disclosure markers, and the tool that consumes handles is
deferred, so its description — which does explain them — is not in context
until ToolSearch surfaces it. Meanwhile the same section actively tells the
model to gather missing evidence with the policy tools, i.e. to reprocess
from scratch exactly what memory already holds. The active-recall surface's
whole purpose could silently never fire.

The section now names the handle marker, states that it is the only
identity the model will ever get for that file, and says to consult recall
BEFORE reprocessing. Gated on active recall mode: under sideQuery the
harness injects recalled memory itself and the tool is not registered, so
naming it would invite a guaranteed unknown-tool error (D10).

The tool-display-name drift test now runs over every translating locale
rather than zh alone. `t()` has no cross-locale fallback, so a tool added
to one locale renders a raw English badge beside translated siblings in the
other.

* fix(omni): close the honesty and hygiene gaps found reviewing S5 memory

Fourteen findings from the PR review, verified one at a time. The ones
that could mislead a caller:

- a truncated recall entry now says so. maxTextChars (24k chars) is
  smaller than the collection bound (64 KiB), so any long transcript is
  cut at READ time while coverage still legitimately reports `complete`
  — the model saw a prefix, no gap, and answered about audio it never
  read. `contentTruncated` marks the prefix.
- a resourceId handle whose modality the tool does not accept is refused
  as a parameter error instead of becoming a spawned ffmpeg that burns
  the tool timeout and returns an opaque stderr tail.
- the advisor no longer suggests audio extraction against a video's
  speech_text gap: that gap is still open in the very payload that
  RETURNS the extracted track, so the suggestion repeated itself. Nor
  does it suggest anything against `partial_coverage` — sampled evidence
  stays sampled by design, so that advice could never close the gap.
- a derived version keeps the execution that actually produced it. A
  second execution landing on byte-identical output was rewriting the
  pointer, leaving the version naming an execution whose outputs it is
  not.
- `maxAttempts` governs the client's retry loop only; a selection that
  parses but names entries outside the manifest is refused without a
  retry. The comment and the setting description said otherwise.
- `sideQuery.model: null` means the side-query default (the configured
  fast model, falling back to the session model), not "the session's
  active model" as three places claimed.

And the hygiene:

- duplicate resourceIds and repeated selector picks collapse instead of
  spending the budget twice
- an empty materialization records `materialized_nothing` rather than
  injecting a reminder that says nothing
- `candidateSummaries` lets a defect propagate (the caller already
  degrades with a recorded reason) instead of swallowing the stack
- `kinds`/`roles` reject `[]`, which read as "restrict to nothing"
- count and millisecond settings are `integer` in the generated schema
- dropped three never-written fields; corrected the `managedId` doc,
  which described an extension the key does not carry

* fix(omni): disclose the session handle for URL-delivered media too

The @-command/URL funnel records the file in memory and mints a registry
binding, then never tells the model the handle. Active recall rejects the
handle as never-issued, and the passive selector finds no handles to
consult — so a session could pay for a download, an upload, and a policy
run, and be structurally unable to recall any of it.

The withheld case is where it matters most: the model cannot see the
media, so the handle is its only remaining route to the content.

* docs(omni): record the two recall fields that keep a page honest

`contentTruncated` and `matchedEntries` both exist because a reader that
cannot tell a prefix from the whole, or a page from the set, draws
confident wrong conclusions — and both were shipped in code without the
design doc naming them.

* test(omni): pin the selector gate and the handle disclosure

Mutation probes showed both were free to break silently:

- inverting the selector's manifest-membership check kept the whole suite
  green while, with the default single attempt, every passive recall
  degrades to `selector_failed` — the feature dead in production. The
  mock now forwards responses through `validate` the way production does,
  so the happy path exercises the gate too; three tests fail on the
  inversion.
- dropping `...handleParts` at any of the five replacement sites left
  tool-produced or withheld media with no recall handle, and no test
  cared. Each site now fails exactly one case.

Also pinned: the 4000-char selector request cap, the abort/timeout signal
composition (a Ctrl-C in the selector window must cancel it, not leave
the main request waiting out the full timeout), promptId attribution, and
the basename-only input error — whose leak-prevention was satisfied by
the old full-path message too. The manifest no-path assertion moved out
of the mock: a leak used to fail as "result was null" instead of naming
the path.

* test(omni): pin the memory invariants that mutation probes walked through

Sixteen behaviors the design doc states and the code implements, none of
which any test defended. Each was verified by breaking the source, seeing
the new test fail, and restoring it. The ones that matter most:

- gaps must speak for the CURRENT version's processing state, always, and
  must be computed from the FULL subgraph. Two mutants — looping gaps over
  every consulted version, and threading the request's kinds/roles filter
  into the coverage scan — would have made recall report missing work that
  exists, purely because the caller narrowed what it asked to see. The
  existing history test could not catch either; the new fixtures invert
  its direction (unprocessed history, processed current) and kill both.
- the selector-visible manifest: the 200-char description cap was never
  binding (longest fixture was 184 chars), so removing it would have sent
  full transcripts to the selector; and validation against the CAPPED
  manifest let an entry past maxCandidateEntries be materialized though
  the selector was never shown it.
- registry rebinding now pins first-binding-wins explicitly. Updating in
  place would repoint a handle the model already holds; minting a second
  handle for one version would break cross-call correlation.
- the store's rename-based write and newest-wins backup retention, pinned
  black-box (inode replacement, no orphan .tmp) rather than by mocking.
- multi-output executions: every call site in the suite committed exactly
  one output, so truncating the output loop was invisible.
- the config normalizer's real contract: it throws rather than clamps, its
  bounds are non-strict, and it validates the MERGED values — lowering
  only maxEntries to 6 is startup-fatal against the default 12.

* fix(omni): keep a withheld resource reachable, and stop losing why recall degraded

Three findings that each end the same way — the session holds a memory it
cannot get back to, and nothing says so:

- A transport-guard rejection threw away the handle it had already minted.
  Every throw site sits downstream of the bind, and the omission branch
  with the identical "over-limit, withheld" verdict does disclose its
  handle — so the rejection was the one path that stranded a recorded
  resource: the model can neither see the bytes nor ask memory about them.
  The verdict now carries the handle, and the tool-result funnel emits it.

- Passive recall degrades silently by design (the turn proceeds without the
  memory it was meant to carry). With a pinned-but-unavailable selector
  model that is a permanent outage with nothing to see, so the reason is
  now recorded at the injection seam, and the manifest-failure branch —
  which had no log at all — gets one.

- Chinese queries never ranked. Splitting on separators yielded one token
  per phrase, scored by whole-substring containment: unless an entry
  repeated the caller's exact phrasing, everything scored zero and ordering
  collapsed to newest-first. Unsegmented scripts now index as overlapping
  character bigrams, split at script boundaries so `480p字幕` does not
  produce bigrams that straddle them.

Also: `PolicySucceededCommit.created` had no consumer outside tests. It
distinguishes a content-identity replay from a fresh record, which is
exactly what a reader of the debug log cannot otherwise tell — "no new
execution appeared" reads identically to "collection did nothing".

* test(omni): cover the four seams where memory reaches the pipeline

Each was free to disappear without a red test:

- the orchestrator's post-promotion commit, its degradation-cache-hit
  commit, and the memoryBinding threaded onto each WorkItem. Disabling the
  commit, or always threading the source binding instead of the derived
  one, left the suite green while every fixed-policy execution recorded
  wrong or no lineage. Covered with a real MediaMemoryService on a tmp
  store, reading memory.json back — including a two-stage chain, where
  stage two must be EXECUTED_ON stage one's version, not the root.
- the reactive-degradation ladder's memory wiring, which had no coverage
  at all beyond its pure helpers. One test pins that a rung's re-derivation
  lands on the binding found by content hash; the other that an unknown
  hash degrades normally and records NO execution, rather than inventing
  provenance on someone else's lineage.
- the client injection seam: moving it after the systemReminders splice —
  the exact regression that kills passive recall — now fails.
- the D10 exposure gate at registry-construction level: the recall tool
  appears in active mode, is absent in sideQuery mode, and an invalid
  omni.memory.recall.mode aborts instead of falling back to defaults.

* fix(omni): keep S5 off the root barrel, which was dragging 550 KB into ACP

`Check serve fast-path bundle closure` went red: the ACP agent's static
import closure had acquired iconv-lite's encoding tables (~553 KB).

The mechanism is worth writing down, because the obvious readings are all
wrong. Nothing imported iconv-lite. What S5 did was add five re-exports to
the ROOT barrel, which is statically imported by ~150 modules in the ACP
closure. esbuild assigns modules to chunks by which entry points reach
them, so every module added to the barrel's graph regroups the split — and
that regrouping moved `sync-file-encoding` (and through it `iconvHelper`,
whose top-level `import 'iconv-lite'` has side effects and cannot be shaken
out) into a chunk the ACP chunk statically imports.

So the fix is not "move the export somewhere else": my first two attempts
relocated it to the `omni` barrel, which is itself statically imported by
the root barrel and by tool-result-media, and stayed red. The barrel
surface itself has to come back to baseline.

None of the five needed to be there. Four had no consumer outside their own
module and tests; the rest are reached by dynamic import, which does not
care where the export lives:

- ACP's model-call collection gets a dedicated `./omniPolicyCollection`
  subpath, the pattern `./goalWire` and `./transcriptRecords` already
  establish for "the CLI needs one deep function, not a barrel".
- `reanchorRememberedMedia` moves to the `omni` door the `@`-reference
  funnel already loads for every other omni call it makes.
- `createMediaMemoryRecallService`, `buildMediaMemoryRecallAdvisor`,
  `extractRequestResourceIds`, `runOmniMemorySideQuery`,
  `formatOmniMemorySideQueryReminder`, `OmniRecallMediaMemoryTool` and
  `formatResourceHandleText` simply leave the root barrel.

Located by bisecting the 28 commits with the check itself, then diffing the
ACP closure module sets between a green and a red metafile: the delta was
exactly three modules, which is what named the mechanism.

* fix(omni): anchor URL media off its staging file, and record ACP calls as model calls

Two findings from the re-review, both real:

- The URL funnel hands its staging download to the delivery pipeline with
  no lifetime marker, so memory recorded the `.part` path as the file's
  persistent identity — the same defect C9 fixed for tool-result media,
  missed on this third funnel. The handle already shown to the model
  resolves to ENOENT the moment the funnel's `finally` runs: every later
  policy call on it fails, and cross-session recall reports
  `artifact_unavailable` for bytes the object store still holds. URL media
  now anchors to the content-addressed object path, and the version's
  source records the URL itself (`protocol: 'url'` existed in the schema,
  unused until now) — the durable identity of downloaded media is where it
  came from, not where it briefly landed.
- ACP committed its policy successes with `executionOrigin: 'client'`
  while the modelAccess gate forty screens up pins the very same call as
  `{ kind: 'model' }` ("Every ACP-originated call is a model call").
  Recall provenance contradicted the gate that admitted the call.

The URL-anchor test is mutation-verified: reverting the lifetime marker
fails it.

* docs(omni): reconcile storage lifecycle with the shipped recovery, stake out GC

Section 6.1 described the recovery scan as drafted; the implementation
learned things the draft could not know, and the doc should carry them:
"delete all of staging/" became a grace window because a second CLI
process's recovery must not delete a live invocation's transcode out from
under it (config validation caps tool timeouts below the window for the
same reason), and corrupt-object deletion cascades into the degradation
cache so a cache hit can never point at bytes that are gone.

Section 6.2 is S6's landing point, sharpened by what S5 settled: the GC
root set has TWO sources in the memory snapshot (artifactRef.managedId
AND managed-protocol source locators — tool/URL media anchor their
identity there), the snapshot read is fail-closed (no roots readable, no
deletion at all), and object deletion reuses the recovery scan's existing
cache-cascade rather than growing a second one.

* test(omni): stop asserting an exact timeout the wall clock can undercut

The bucket loop's budget guard calls remainingTimeoutMs() right before
the ffmpeg call reads it again; on a slow runner a millisecond elapses
between the two reads and the exact-equality assertions on the first
call's timeoutMs turn flaky (observed on ubuntu CI: 599999 != 600000).

Freeze Date.now in the full-duration spread test (ffmpeg is mocked,
nothing there needs real time) and tolerate the guard-to-use drift in
the shared-budget test, which does need the real clock for its 50ms
burn. Restore spies in afterEach so the frozen clock never leaks into
neighbouring tests.

* feat(omni): S6 governance — object-store GC with byte budget, JSONL trajectory export (#9081)

* feat(omni): mark-and-sweep GC over the object store, with a byte budget

The store only ever grew: recovery cleans staging, downloads and
quarantine, but a committed object had no way out — one film-commentary
session leaves 38 objects behind, forever. S6 (storage design §6.2).

Two hard rules shape the implementation:

- An unreadable ledger deletes nothing. The root set is the memory
  snapshot; if it cannot be read there is no telling "unreferenced" from
  "unknown", so the run skips. This interacts with the store's own
  corruption recovery (C8) in a way the first test caught: a corrupt
  memory.json is backed up and REPLACED WITH AN EMPTY LEDGER — right for
  recall, catastrophic for a GC that trusts it, because empty reads as
  "nothing is referenced" and one corruption event would erase the whole
  store. A fresh .corrupt-* backup therefore blocks the run until it ages
  past the retention window.
- A referenced object is never deleted, even over budget. Roots come from
  BOTH reference kinds in the snapshot — entries[].artifactRef.managedId
  and managed-protocol source locators (tool/URL media anchor their file
  identity in the store; missing these would delete exactly the objects
  whose store copy is the only copy) — plus the live session registry,
  which may hold a binding whose memory commit has not landed yet. Budget
  pressure with only referenced objects left suspends NEW derivations
  (policy design §8.4) instead: the orchestrator refuses to execute a
  policy tool, while reuse and cache hits — which produce no new bytes —
  stay allowed.

Sweep: unreferenced and older than omni.storage.retentionDays (14d
default); over omni.storage.maxTotalBytes (20 GiB default), oldest
unreferenced first regardless of age. Deletion cascades into the upload
cache and the degradation cache, same invariant recovery's corrupt-object
path enforces. Symlinked shards are never descended into. Runs once per
store root per process, asynchronously after startup recovery.

* feat(omni): export a session's trajectory as training-consumable JSONL

S6's second deliverable (issue #8190, absorbing #8196): the experiment's
whole point is trajectories — what media came in, what the pipeline did
to it, what the model actually saw, what it answered — and until now they
lived split across the chat transcript and memory.json in shapes no
training script can consume.

The exporter is a pure reader over those two artifacts. No collection
points are added: it cannot affect runtime behavior and works on sessions
recorded before it existed. Three record kinds per line — `turn` (request
text with reminders stripped and any injected passive recall extracted,
the media annotations the model saw, the response and its tool calls),
`execution` (full provenance: arguments, config hash, reuse marker), and
`file` (the durable identity: fileVersionId, sha256, origin, source).

The join is explicit rather than fabricated: model/client executions join
turns on callId ↔ invocationId (both sides record the scheduler's id);
fixed-policy executions join through file identities. No turn↔execution
edge is invented from timestamps — a wrong edge in training data is worse
than an explicit join key.

Privacy carries over from recall: reserved runtime keys (inputPath /
outputDir) are stripped from exported tool args, media bytes are never
embedded (sha256 is the pointer, valid while GC retention lasts), and
every execution line carries omniConfigHash so downstream can split
trajectories by processing configuration instead of mixing distributions.

Deterministic ordering makes re-exports byte-identical. An unreadable
memory store degrades to transcript-only output; an unreadable transcript
is an error — there is no trajectory without it. Wrapper script at
scripts/export-omni-trajectory.js; the function is exported through the
`omni` door (never the root barrel — see e60efd81fc for why).

* docs(omni): draw the exporter's scrub boundary where the model's sight ends

* fix(omni): settle the startup GC before the budget gate reads the flag

The startup GC is fire-and-forget so delivery never blocks on a sweep,
but the orchestrator's budget gate consulted isOmniDerivationSuspended
immediately - the first derivation of every fresh process raced past
the budget before the sweep could raise the suspension flag (observed
in a real-API E2E run: a new extract-audio derivation landed ~900ms
before the over-budget verdict, leaking two objects).

Add settleOmniGc(root), which awaits the process's in-flight GC for a
store root if one was started (no-op otherwise, never rejects), and
await it in the orchestrator right before the gate. Covered by a new
gc test that fires the GC without awaiting, exactly like the index.ts
wiring.

* fix(omni): close the GC's stale-root races and root URL-origin objects

Review round 1 (R1-2, R1-15, R1-16) plus an external audit found three
ways the sweep could delete bytes the ledger still vouches for:

- URL-origin media were never in the root set: their versions keep the
  original URL as source.locator, but the staging download dies the
  turn it lands and the store copy (the file's fileRef) is the only
  persistent bytes. Root every version of a store-anchored file by its
  content hash.

- The root snapshot and object listing go stale while the sweep runs,
  and a concurrent commit can reference OLD bytes in that gap — the
  retention-window argument only covers newly-promoted bytes. Three
  layers now close it: putFile's dedup hit refreshes the object's
  mtime (every reference is preceded by a promotion of the same bytes,
  so the touch always lands before the commit), both passes re-stat a
  candidate at delete time, and the budget pass — where age protects
  nothing — re-reads the ledger for a fresh root set and skips objects
  touched since the sweep began. The residual window shrinks from the
  whole sweep duration to the moment between re-stat and rm.

- The suspension messages told the operator to raise the budget or
  delete records, but neither can take effect inside the session that
  shows them (the GC runs once per process) — say so.

The gc.ts header's multi-process claim now describes the layered story
instead of overstating the retention argument.

* fix(omni): make the trajectory export faithful to what the model saw

Review round 1 (R1-1, R1-3..R1-9, R1-17..R1-19) plus an external audit
found the exporter unfaithful in both directions — content the model
never saw leaked in, content it did see was lost or mangled:

- Dead branches exported as genuine turns: records carry uuid/parentUuid
  and /rewind re-roots the chain, so the exporter now replays only the
  active chain (uuid-less fixtures fall back to append order).
- Thought parts merged into response.text: internal chain-of-thought is
  now excluded — it is not the response the user saw.
- "Single-session" export was project-wide: memory records are filtered
  to the closure reachable from THIS transcript (callId joins + media
  annotation names + the identity graph between them), so a text-only
  session exports zero unrelated history.
- The recall extraction targeted a transcript shape no writer produces:
  the sideQuery injection now records its payload as a system record
  (subtype omni_recall) and the exporter reads that — older transcripts
  simply have no recall field instead of a silent lie.
- Annotation parsing was line-based and first-colon-split: multi-line
  transcripts truncated, colon-bearing display names mis-split and
  cross-contaminated, look-alike prose was harvested, reminder-quoted
  grammar was consumed before stripping. Writers now escape the
  separator in display names, the exporter matches whole parts (writers
  emit one annotation per part), payload newlines survive, and reminder
  stripping runs first. tool_result records are harvested too — the
  dominant agentic media path was invisible.
- The inputPath/outputDir strip on transcript tool calls was vacuous
  for its stated purpose (the gate injects those AFTER the assistant
  turn is recorded) and destructive in practice (a model-authored
  inputPath is a legal call the gate accepts) — arguments are now
  preserved verbatim, and the false equivalence comment is gone.
- Exporting was not read-only: a corrupt memory.json went through the
  store's self-heal, renaming the live ledger and silently blocking the
  GC for a whole retention window. The exporter now reads the file raw
  and degrades to transcript-only.
- Writing the trajectory over the source transcript destroyed it with
  exit 0 — identical resolved paths are rejected, and the out dir is
  created when missing.

The test suite grew from 4 to 14 cases and now pins response.text, the
lineage fields, the fixed-policy join, multi-turn isolation, the
subtype turn-boundary guard, and the memory-degrades/transcript-rejects
asymmetry.

* fix(omni): validate the GC knobs where a typo would silently disable them

Review round 1 (R1-11, R1-13, R1-14) plus an external audit, on the
configuration surface and the test gaps around the sweep:

- omni.storage.maxTotalBytes accepted any positive value; the storage
  design requires at least 10× the transport guard's single-media
  ceiling (a budget that cannot hold a handful of normal uploads reads
  as permanent derivation suspension, not configuration). The GC wiring
  now clamps through effectiveOmniStorageMaxTotalBytes with a warn.
- retentionDays accepted sub-day values while the schema promises
  minimum 1 — the accessor now enforces what the schema says.
- Unknown keys nested under omni (omni.storage.retentionDay, …) were
  silently ignored, leaving the GC on defaults; the settings validator
  now walks nested object schemas, stopping at free-form map nodes
  (fixedPolicies/policyTools names are user-defined).

GC test gaps closed: the suspension-release assertion now proves the
relaxed sweep clears a STANDING flag (reset keeps it by request); the
corrupt-ledger test is named for the recovery-backup guard it actually
exercises and a new test pins the refs===null hard rule; entry
artifactRefs are rooted independently of version locators; a registry
fileRef outside the store shields nothing; the cache cascade asserts
its rm-first ordering; a failed rm keeps its bytes in the budget
arithmetic; and two roots in one process get independent latches and
suspension verdicts.

* test(omni): give the sweep-touch fixture a margin over coarse mtime clocks

The touched-during-sweep test stamped the second candidate with "now";
on filesystems that truncate mtime to whole seconds (the ubuntu CI
runner) the stored time floored below the sweep start and the object
was deleted anyway. Stamp +10s instead — what the test pins is "touched
AFTER the sweep began", and a ten-second margin says that regardless of
timestamp granularity. Production is unaffected: the budget pass's
primary protection is the fresh ledger re-read; the mtime check is the
belt to that suspender.

* test(omni): stub the ffprobe subprocess out of model-call collection

Twice now an overloaded CI runner killed the REAL ffprobe this suite
spawns (its 15s subprocess timer), the failure was swallowed by design
(D12: collection never affects the tool call), and the tests read "0
executions committed" — a false red that blocks unrelated PRs. The
content sniff stays real (in-process magic bytes); only the probe
subprocess is stubbed. Nothing this suite asserts depends on ffprobe
output — the subprocess only added a machine-load dependency.

* feat(omni): separate upload and inference channels (#9569)

* feat(omni): policy-tool expansion, look-closer pipeline fixes and fixedPolicy preset (#10351)

* feat(omni): S7 policy-tool expansion — 6 new tools, memory.* conditions, token-budget resize

Design-doc gaps ❶–❻ (omni-harness-gap-analysis, priorities 1–5):

- omni_clip_image: pixel-rectangle crop (sharp, EXIF baked first), the
  policy form complementing model-facing zoom_image
- omni_caption_image / omni_ocr_image: VL caption and OCR text products
  (roles caption/ocr, both pre-reserved in the memory role enum); ocr
  unlocks the §4.5 document-image fixedPolicy
- omni_clip_audio: startMs/durationMs (≥1000ms) audio cut, symmetric
  with clip_video
- omni_caption_audio: omni semantic audio description (timbre, events,
  mood) reusing the transcribe chunk+concurrency skeleton
- omni_understand_video_segments: fixed-length segments (default 30s,
  ≤10MB each) → parallel omni captions (cap 8) → time-labeled summary
- shared omni-chat-request.ts for the four model-call tools (SSE
  assembly, HTTP-status-only errors, baseUrl/apiKeyEnv operator-only)

Gap ❼ — memory.* condition namespace (unlocks configs 4.1/4.4):
hasTranscript/hasOcr/hasCaption/hasSummary/hasKeyframes/hasClip flags
collected over the item's DERIVED_FROM subgraph (root-bounded), wired
into the orchestrator's per-pass condition snapshot; absent namespace
evaluates unavailable, never silently false; lazy store read only when
a policy's `when` references memory.*.

Gaps ❿⓫ — billing-token levers (ported from the plugins):
- omni/smart-resize.ts: 28px patch-grid smart_resize + token budget
  tiers (image 256/1024/2048, video-frame 80/256/1024)
- omni_downsample_image tokenBudget param (exact grid-snapped dims,
  EXIF-aware, small-image upsampling)
- omni_extract_keyframes strategy:'uniform' — duration-adaptive dynamic
  fps (clamp(window×fps, 1, maxFrames)) with parallel per-timestamp
  seek extraction, startSec/endSec windowing, frameTokenBudget sizing

Tests: 45 files / 1017 tests pass across src/omni + media-memory
(per-tool suites, smart-resize math, memory subgraph role collection,
orchestrator end-to-end memory gating); eslint clean; tsc no new errors.

Docs: design doc 2026-08-18-omni-policy-tools-expansion.md; user doc
tool table + memory.* field table; E2E plan in .qwen/e2e-tests/.

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

* docs(omni): fixedPolicy preset covering design-doc 4.1–4.9 with priority ordering

Ships docs/users/features/omni-fixed-policies-preset.json — a copy-ready
settings template implementing all nine fixedPolicy configurations from
the design doc (long-video ASR chain, large-image downsample, tight-
context keyframes, long-audio transcribe, document-image OCR, large-
video downscale, and the three >10MB oversize fallbacks), ordered by
priority (chain starters → chain followers → independent degradations →
oversize fallbacks) with the ASR chain's timeout/budget companions.

A vitest guard (preset-validation.test.ts) normalizes the preset against
the real tool registry so the shipped template cannot drift from the
tools and DSL it references. User doc gains a pointer to the preset.

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

* fix(omni): qwen omni models resolve to full multimodal; preset opens modelAccess

Two gaps surfaced by real harness runs with qwen3.5-omni-plus:

1. modalityDefaults had no omni entry — 'qwen3.5-omni-plus' fell through
   to the generic /^qwen/ text-only pattern, so @-referenced video/audio
   was skipped at the delivery gate ('the active model does not accept
   video input') even though the omni pipeline itself was healthy. Adds
   qwen*-omni patterns as FULL_MULTIMODAL before the plus/qwen fallbacks.

2. The fixedPolicy preset kept every policy tool model-invisible, so the
   model could not progressively re-derive higher-fidelity evidence
   (clip/transcribe/keyframes/OCR) from delivered media. The preset's
   policyTools now sets modelAccess.enabled for the evidence-gathering
   tools; pure degradation tools stay operator-only. Preset validation
   test registers the newly referenced tools.

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

* feat(omni): look-closer pipeline fixes + policy-tool updates

Clip / keyframe budget & token-cost fixes measured on Video-MME-v2:
- clip-video: encode video-only (-an) so a clip read never re-runs the
  extract-audio -> transcribe chain (the full transcript already covers
  the span); append a soft clip-budget brake past 3 clips steering the
  model to densify-locate instead of unbounded trial clipping.
- image-budget: cap assembled requests at 250 image parts, evicting the
  oldest keyframes to a text placeholder, so a long look-closer
  trajectory never trips DashScope's hard 256-image 400.
- extract-keyframes / media-guidance: densify-before-clip guidance.

- usage-log policy tool; disclosure / media-guidance / read-file wording
  tightened; fixed-policy config / types updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

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

* fix(omni): harden policy tool contracts (#10364)

* fix(omni): harden policy tool contracts

* fix(omni): qualify clip audio disclosure

* fix(i18n): localize omni policy tools

* feat(omni): reference model-visible local media by absolute path instead of resourceId (#10690)

* feat(omni): reference model-visible local media by absolute path, not resourceId

When media is delivered from a model-visible local source (user-origin
read_file, whose registry binding fileRef equals the path the model read),
the 【媒体资源】annotation now shows that absolute path instead of an opaque
media-<n>-<hex> handle: the model already holds the path and can re-read it
or point tools at it, so the handle was redundant noise. Path-less sources
(tool/URL/recall media, whose fileRef is an internal object-store locator)
keep the handle — no usable path exists to show.

Passive media-memory recall is preserved: extractRequestResourceIds now
recovers the session handle from a path-form annotation via
registry.resolveByFileRef, so recall keys on both annotation forms.

- disclosure.ts: add formatResourcePathText / parseResourcePathText
- index.ts: choose path-form vs handle-form by binding.fileRef === filePath
- memory-side-query.ts: resolve path-form annotations back to handles

* docs(omni): note outputDir must exist and is not auto-created in the tool schema

The media-policy tools assert the output directory exists (assertMediaPolicyIo)
rather than creating it; state that in the shared outputDir parameter
description so callers pass a directory that already exists.

* fix(omni): migrate every 【媒体资源】 consumer to the path form

PR #10690 replaced the opaque handle with the absolute path for
model-visible local media in EVERY recall mode, but only the passive
sideQuery selector understood the new form. Review flagged three
regressions plus follow-ups; migrate the remaining consumers so the
handle and path forms are interchangeable everywhere.

- recall (active tool): resolveBindings resolves an identifier as a
  handle, then falls back to registry.resolveByFileRef, so the displayed
  path recalls exactly as the handle would; an identifier that is neither
  still rejects the whole request. Dedup is per resolved binding, so a
  handle and the path of one resource collapse to a single gap. Recall
  tool + active-mode guidance now describe both forms.
- trajectory export: consumeAnnotationPart learns the path form, keying
  the media entry by the path basename (the local source locator) so the
  turn keeps its media and the session filter seeds the file's closure.
- per-version identity: resolveByFileRef returns the LATEST binding at a
  locator (the version the model currently sees), not the first.
- escape-aware handle parse: parseResourceHandleText splits at the first
  UNESCAPED separator and only accepts an exact media-<n>-<hex> payload,
  so a path ending in a handle-shaped suffix is not misread as a handle.
- whitespace filenames: passive extraction prefers the untrimmed line and
  probes both candidates against the registry, so a filename ending in
  whitespace still resolves.
- refresh the stale "handle is the only identity / never a path"
  invariant comments; pin the outputDir schema wording and its
  reject-and-never-create behavior with tests.

All omni + media-memory suites pass (1081 tests); typecheck clean on the
changed files.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(omni): close the round-2 review gaps on path-form media references

Follow-up to the path-form annotation change, addressing the second
review round on #10690 (two fix-induced Criticals + suggestions).

Criticals
- recall/model-access accepted the ESCAPED path the annotation displays
  but compared it byte-exact against the unescaped fileRef, so every
  native Windows path (and any :-bearing name) failed to recall and
  poisoned any handles batched with it. Add resolveMediaReference
  (registry.ts): resolve as handle, then as fileRef with the displayed
  form unescaped. Shared by active recall and the media-policy call gate.
- resolveByFileRef claimed latest-wins recency, but bind() returned an
  existing version without re-inserting it, so a revert to previously-seen
  bytes resolved to the superseded version and persisted a wrong reuseKey.
  bind() now refreshes a re-delivered version's position (delivery order).

Suggestions
- Exporter: guard parseResourcePathText on an absolute-path shape so it no
  longer consumes ordinary 【媒体资源】-prefixed prose; stop the full trim
  from truncating a trailing-whitespace filename (dropped the file's
  closure from the export).
- sideQuery selector: strip 【媒体资源】 lines from the selector payload so
  a local absolute path never reaches the selector model call (§17.5).
- Guidance/contracts: emit the path/handle reference guidance in BOTH
  recall modes (sideQuery no longer left with stale schema text); teach the
  media-policy gate to accept a displayed path via resourceId; raise the
  recall resourceIds maxLength so a long path is not rejected pre-resolution;
  add the "must already exist" outputDir contract to the clip-video override;
  correct the misleading "not issued this session" rejection message; update
  the stale handle-is-the-only-identity invariant comments.

Tests: registry revert + resolveMediaReference (POSIX/Windows-escaped);
recall path-form gaps/advice dedup; disclosure path-shape guard; exporter
phantom-prose + trailing-whitespace; side-query selector path-strip,
two-version latest-wins, trimmed-fallback; index round-trip consumability;
model-access path-via-resourceId; clip-video outputDir wording.
1095 omni+media-memory tests pass; typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(omni): close the round-3 review gaps on the media path form

Round-3 review traced four findings (R1-1, R3-2, R2-6, R3-10) to one
root cause: the path form shared the 【媒体资源】 marker and ran through
escapeAnnotationName, so the string the model saw was not the path on
disk. Split it — the path form now rides its own 【媒体路径】 marker with
the absolute path VERBATIM (never escaped); the handle form keeps
【媒体资源】 + escaping for its displayName.

- R1-1: resolveMediaReference drops the unescape-before-exact branch
  that resolved `/tmp/proj/a\b` to `/tmp/proj/ab`'s binding; exact
  lookup only (recall.ts, model-access.ts inherit the fix).
- R3-2: verbatim output makes index.test.ts `toContain(filePath)` hold
  on every OS (escapeAnnotationName doubled `\` under windows-2022).
- R2-6: the displayed path IS the on-disk path, so the guidance's
  re-readable promise holds and the policy gate's lstat passes.
- R3-10: a `:`-bearing filename resolves verbatim; the unescape rule
  (and its zero coverage) is gone.
- R3-4: the writer tags its path Parts `omniHarnessAnnotation`; the
  exporter consumes a path-shaped part only when that provenance
  survives the transcript (the OpenAI converter picks known fields, so
  the tag never reaches the model API). A byte-identical user paste
  stays prose.
- R3-1: the exporter keys the media entry by a cross-OS basename split,
  not host `path.basename`.
- R3-3: the writer falls back to the single-line handle form when the
  path contains a newline, so a multi-line annotation can't slip past
  the selector's line-based strip.
- R3-8: the selector strip is parse-gated, not prefix-gated, so
  marker-prefixed prose (`【媒体资源】清单`) survives to the question.
- R3-9: the recall cap counts DISTINCT resolved files, so one file
  named by both path and handle isn't charged twice.
- R3-5/R3-6/R3-7/R3-11: witness tests + guidance/invariant corrections.

Each finding carries a witness test that goes red if its fix is removed.
D3-1 left deferred per the reviewer. Verified: build, tsc, eslint,
prettier, and the full omni + media-memory suite (1109 tests).

---------

Co-authored-by: 王越 <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>

* fix(omni): align source mappings for named exports

---------

Co-authored-by: wangyuenlp <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: 王越 <[email protected]>
2026-09-16 12:15:45 +00:00
callmeYe 8aaaaf2524 feat(web-shell): add footnote previews and per-turn sources (#11480)
* feat(web-shell): add grouped footnote previews

* fix(web-shell): synchronize footnote type dependency lockfile

* feat(web-shell): complete source footnote previews

* fix(web-shell): stabilize citation footer previews

* fix(web-shell): align citation controls

* fix(web-shell): optically align citation icon

* feat(web-shell): generalize footnotes and expose host icon resolvers

* fix(web-shell): align demo citation artwork with footer icons

* feat(web-shell): add framework-neutral footnote preview content slot

* feat(web-shell): show turn sources independently of footnotes

* test(web-shell): use same-origin footnote thumbnail fixture

* fix(web-shell): preserve source focus and nested footnotes

* test(web-shell): isolate footnote model-list fixtures

* chore: regenerate pnpm lockfile for citation types

* fix(web-shell): reuse committed turn sources while streaming

* docs(web-shell): clarify panel-only source visibility

* fix(web-shell): isolate history sources and restore hover dismissal

* fix(web-shell): preserve single formula text in footnote previews

* fix(web-shell): restore declined footnote pages on return
2026-09-16 03:05:46 +00:00
易良andQwen-Coder c666ec1a0a fix(ci): restore Windows test baseline (#11787)
* fix(live): restore Windows filesystem compatibility

* fix(ci): restore Windows test baseline

* fix(ci): keep release-age excludes internal and re-arm two witnesses

- pnpm-workspace.yaml: drop '@tybys/[email protected].4' from
  minimumReleaseAgeExclude. scripts/tests/package-scripts.test.js pins
  that list to internal, version-independent entries. The exclusion is
  also inert: minimumReleaseAge is not configured anywhere in the repo,
  and pnpm-lock.yaml records `overrides` but not minimumReleaseAgeExclude
  -- so the '@tybys/wasm-util': '0.10.4' override stays, because dropping
  it would desync the lockfile and fail --frozen-lockfile.
- web-shell-brand.test.ts: build the swapped-file fake as BigIntStats.
  Production compares bigint fields with a bare `!==`, so a number-backed
  fake tripped `dev` first and the perturbed inode was never reached;
  removing the perturbation now turns the test red again.
- run-qwen-serve.test.ts: '0' is unresolvable on Windows (getaddrinfo
  ENOTFOUND 0), so the wildcard case keeps '0.0.0.0' and witnesses the
  socket rather than a spelling: the operator typed port 0, so the report
  must carry the ephemeral port the listener actually got. Spelling
  normalisation stays pinned platform-independently in
  remote-quickstart.test.ts.
- artifact-snapshots.ts: revert the readdir hoist to main. It is
  behaviourally inert (the catch rethrows ENOENT on the operationId path
  either way), has no test and no Windows link.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu07e78o2w

* fix(ci): restore the load-bearing release-age exclude, keep the witness fixes

The previous commit dropped '@tybys/[email protected].4' from
minimumReleaseAgeExclude to satisfy scripts/tests/package-scripts.test.js:390,
on the reasoning that the entry was inert because minimumReleaseAge is not
configured in this repo. That reasoning was wrong and CI measured it: the gate
is enforced by pnpm itself, not by repo config, so removing the exclude failed
'Install frozen pnpm worktree dependencies' on all three platforms with

  Lockfile failed supply-chain policy check (1975 entries)
  @tybys/[email protected].4 was published at 2026-09-13T08:00:13.464Z,
  within the minimumReleaseAge cutoff (2026-09-12T20:03:50.286Z)

(run 34779568628; Install was green at 6099768ffb, which carries the entry).
Restoring the line makes pnpm-workspace.yaml byte-identical to that head again.

The three other changes from that commit are kept, since they are independent
of the exclude and were green in run 34779568625 (Test 18m58s, Lint & Static
12m36s, Integration no-AK 7m8s): the web-shell-brand.test.ts BigIntStats fake
that re-arms the inode witness, the run-qwen-serve.test.ts socket witness
replacing the tautological '0.0.0.0' assertion, and the artifact-snapshots.ts
revert to main.

This leaves the package-scripts.test.js assertion red exactly as it was before
this round. That is now a measured conflict rather than an open question: the
repo test pins the exclude list to internal, version-independent entries, while
pnpm needs a version-pinned exemption for a package published the same day.
Resolving it means either dropping the '@tybys/wasm-util': '0.10.4' override and
regenerating both lockfiles so an older, age-compliant version resolves, or
relaxing the invariant to admit external version-pinned excludes. Both are
supply-chain policy calls for the author/maintainer, so neither is made here.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu07e78o2w

* fix(ci): pin @tybys/wasm-util to the age-compliant 0.10.3 instead of exempting it

Test (ubuntu-latest, Node 22.x) failed at scripts/tests/package-scripts.test.js:390
(run 34781999892), which pins minimumReleaseAgeExclude to internal,
version-independent entries, while the previous head carried
'@tybys/[email protected].4' there to satisfy pnpm's minimumReleaseAge gate. The two
looked mutually exclusive, but only because the pin chose the newest release.

@node-rs/jieba-wasm32-wasi pulls in @napi-rs/[email protected], which accepts
^0.10.3, and 0.10.3 was published 2026-06-23 -- well outside the 24h window that
rejected 0.10.4 (published 2026-09-13T08:00:13Z). So the pnpm override now pins
0.10.3, the exclude entry is dropped, and both lockfiles follow: package-lock.json
must lock the same version or scripts/check-lockfile.js's "pnpm lockfile against
package-lock.json" agreement gate fails, which also reddens the two check-lockfile
fixture branches in package-scripts.test.js because they copy the real lockfiles.

The pin lives on the pnpm side only, mirroring how the typescript pins already
diverge; npm has no release-age gate, so no package.json override is added and the
nested @tailwindcss/oxide-wasm32-wasi copy of @tybys/wasm-util is left alone. Once
0.10.4 ages out of the window, a maintainer can drop the override from both sides if
nothing else needs the pin.

Verified locally: pnpm 11.24.0 `install --lockfile-only` reports "Lockfile passes
supply-chain policies (1975 entries)"; scripts/tests/package-scripts.test.js 49
passed / 1 skipped (was 3 failed); scripts/tests/check-lockfile.test.js 14 passed;
prettier --check clean.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmu0ce8qb32

* fix(ci): handle expected Windows socket reset

* fix(ci): make Windows regression tests portable

* fix(ci): make script tests portable on Windows

* test: re-arm four witnesses the review round found vacuous or over-gated

Each of these was measured, not argued; the mutation named in every case is
the one that used to leave the suite green.

- no-follow-open.test.ts: add the >2^53 identity case. Removing
  `{ bigint: true }` from all four production stat call sites left 17/17
  green, so the Windows-precision conversion this PR exists for was
  unwitnessed. The fixture uses 2^60 offsets, where the double spacing is
  256 and both ids collapse to one value; 2^53+1 / 2^53+2 do NOT collapse
  (2^53+2 is exactly representable), so those constants cannot pin it.
  Two-arm measured: intact 18/18 pass, mutated exactly 1 fails.
- web-shell-brand.test.ts: stat the planted regular file with
  `{ bigint: true }`. Production compares BigIntStats with a bare `!==`, so
  the number-backed fake tripped `dev` on a type mismatch alone and would
  have refused the correct file too. Probed both arms: pre-fix
  `typeof expectedStat.dev === 'number'` with the `dev` clause true,
  post-fix both sides bigint with `dev` false and the refusal driven by a
  real inode mismatch.
- conversation-workspace.test.ts: move the `inodeVerifiable` gate below the
  two `toBe(identity)` assertions. They are inode-independent, so gating
  them deleted a witness the Windows lane was green on. Modelled an
  unverifiable-inode host: the test reports skipped, not failed, which is
  only reachable once both assertions have passed.
- worktree.test.ts: split `localFilterCommands` in two. The fail-closed
  branch is platform-free by construction and the base Windows log shows it
  passing there; only the `a\nb` fixture is Windows-impossible, so only that
  half keeps the gate.

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

* ci: say why the post-redirect autocrlf line is not a duplicate

Both workflows now run `git config --global core.autocrlf false` twice: once
before the checkout and once inside the test step, where HOME/USERPROFILE are
redirected to a freshly created empty directory. The second line therefore
writes a different global config than its twin, and on ci.yml's hosted
windows-2022 fallback the pre-checkout step never runs at all because it is
gated on `runner.environment == 'self-hosted'`.

It was the only one of the three occurrences with no comment, so deduping it
looks free and silently returns CRLF conversion to every git operation the
suites perform. Replaying no-ak-integration-ci.test.js's own assertions with
the second occurrence deleted gives identical indices and still passes, so no
test notices the removal.

Comments only. The ordering assertions search for the FIRST occurrence and
none of the added text matches a needle they look for; the 14 assertions in
no-ak-integration-ci.test.js still pass.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-14 11:17:19 +00:00
易良 f71a4e797e chore(pnpm): scope build approvals, pin the ACP zod peer, add a freshness lane (#11797)
Follow-ups left open when #11625 merged: its three unresolved review threads,
the deferred finding in #11681, and #10524.

- allowBuilds decisions now honour the whole key the way pnpm does: a bare
  name covers every version, an exact `name@version` (or a `||` union) covers
  only those, and a range or source-like key decides nothing. Reducing a key
  to its name let an approval scoped to one version cover the next (R1-10).
- Pin `@agentclientprotocol/sdk>zod` to 3.25.76 so a pnpm worktree runs the
  zod the npm tree installs. Under zod 4 the SDK's own `instanceof z.ZodError`
  test stops matching and a malformed request answers -32602 instead of
  -32603; both versions are npm-locked, so the agreement gate cannot see the
  split (R1-1).
- Cover web-shell's shipped client sources with the undeclared-import rule;
  it is the one published package whose sources are not under src/. Measured
  cost today: zero violations (R2-6).
- Add a path-filtered workflow that regenerates pnpm-lock.yaml with
  `corepack pnpm import` and fails when the committed file differs, which is
  the freshness half set membership cannot cover (#11681).
- setup-worktree: accept only an executable file on PATH, so a corepack left
  without its exec bit reports the actionable error instead of EACCES
  (#10524).
- Name the esbuild divergence an accepted parity gap, and record what
  `fsevents: false` costs a macOS worktree.
2026-09-14 04:45:19 +00:00
1e768979c5 chore(pnpm): gate pnpm-lock on package-lock and declare hoisted imports (#11625)
* chore(pnpm): gate pnpm-lock on package-lock and declare hoisted imports

- Lockfile check: every pnpm-lock version must be locked in package-lock
  (one documented npm-lock gap, failing if it goes stale), and every
  dependency npm runs an install script for needs an explicit allowBuilds
  decision (fsevents: false records current behaviour).
- Regenerate pnpm-lock with `pnpm import`, which is deterministic and drops
  18 versions npm never locked; document it as the regeneration step.
- Enforce import/no-extraneous-dependencies on shipped sources and declare
  the 15 imports that only resolved through hoisting, at already-locked
  versions.
- Run the pnpm smoke workflow when package-lock.json changes.
- Add the Stage 2 verification brief.

Refs #10444

* chore(pnpm): regenerate vscode NOTICES and pin the new smoke path

- NOTICES.txt: core's new OpenTelemetry declarations make the generator
  visit six Apache-2.0 entries in a different order. Same 655 entries,
  same text; only their order changes (6 header lines).
- package-scripts test: expect package-lock.json in the pnpm smoke paths.

* fix(pnpm): close lockfile-gate gaps found in review

- fail closed when pnpm-lock.yaml has no packages section instead of
  printing the strongest success line over an unread graph
- match git/file: dependency keys against npm's resolved field, whose
  pnpm key is the source rather than a version
- count an allowBuilds entry only when its value is boolean, so pnpm's
  own 'set this to true or false' placeholder no longer passes the gate
- drop the inert package-lock.json trigger from the pnpm smoke workflow;
  no step in that job reads it, and npm/pnpm agreement is already gated
  by ci.yml's Check lockfile step on the full profile
- stage-2 evidence recipe: cover nested workspace roots in the reflink
  arm, sample peak worktree count at most 5 minutes apart, run the
  parity matrix in a throwaway workflow, and give the npm baseline the
  same skip guards the pnpm arm has
- drive every gate branch with fixture-based negative tests

* docs(verification): correct pnpm stage-2 measurement recipe

Quiesce the df window, name the worktree sampler's working directory and forbid host cron, and register the throwaway workflow's size baseline so the parity run's test:ci does not fail under both installers.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmty6iqs0zv

* docs(verification): fix stage-2 recipe's dispatch and macOS fs probe

Step 2 now pushes the scratch branch before gh workflow run --ref (the ref is
resolved server-side), notes the workflow token scope, and gives the merge-to-
default / pull_request fallback when a branch-only dispatch is refused. Step 1
probes the checkout's own volume with diskutil info "$(pwd)" instead of the
sealed read-only / system volume. Pushing, dispatching, and posting the results
comment are marked operator actions.

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

Patrol-Run: qwen-pr-closeout/jmty8nwk9zy

* test(lockfile): cover stale-gap and alias arms of check-lockfile

Add two failure-branch cases: a knownNpmLockGaps entry that leaves the pnpm
graph (the !pnpmVersions.includes arm), and an aliased install script reported
under its real package name (the npmPackageName details.name arm). Both were
untested and stayed green if the arm was deleted.

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

Patrol-Run: qwen-pr-closeout/jmty8nwk9zy

* fix(lockfile): close the empty-env seam and name the overrides remedy

- `CHECK_LOCKFILE_ROOT` was read with `??`, so an exported-but-empty variable made `root` the empty string and every path in this gate cwd-relative: it would then report on whatever lockfiles sit in that directory as if they were the repository's, and pass quietly when the cwd happens to be the repo root. Truthiness keeps the fixture seam and closes the empty case.
- The version-agreement failure named exactly one remedy, `corepack pnpm import`, while pnpm-workspace.yaml carries eight `overrides` (three of them pin typescript) that no npm-side regeneration can reproduce. The message now names that layer, so a still-red gate points at what actually decides the value.

Verified: `node scripts/check-lockfile.js` exits 0 on the committed tree, prettier and eslint are clean on the file, and both suites that execute the gate pass.

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

---------

Co-authored-by: qwen-code-ci-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
2026-09-12 14:47:44 +00:00
易良andShaojin Wen 8a21551599 chore: enforce Playwright version parity and close the deferred #11101 follow-ups (#11371)
* chore: enforce Playwright parity and close deferred #11101 follow-ups

Works the four items tracked in #11336:

- Pin Web Shell's @playwright/test to the same exact version as the root
  playwright dependency, and fail `npm run check:lockfile` when the two
  direct manifests drift apart or their resolved root trees stop matching
  the pin. The check deliberately tolerates the packages/mobile-mcp tree:
  mobilewright depends on playwright and playwright-core at an exact
  older revision, so no manifest edit in this repository can dedupe it.
- Correct the "Install evidence browser" rationale in qwen-triage.yml and
  its restatement in the workflow test. The two-trees premise no longer
  holds now that both manifests are pinned; the resolution behavior is
  unchanged, since resolving from the harness's own directory still keeps
  the binary tied to the package it imports.
- Cover the ENOENT fallback of the exported isMainModule helper. The
  existing runner.py assertion takes the realpath success path because
  that file exists, so the fallback stayed unpinned.
- Separate the CLI-only from the daemon-only followup suggestion gates in
  the documentation instead of pointing daemon clients at the blended
  CLI-oriented list, keeping the headless/SDK opt-out guidance.

* chore: pin the Playwright parity check and correct its scope notes

Follow-up to the round-1 review on this PR.

The parity block had no test in either direction: the only suite that runs the
script asserted the two pre-existing banners, so deleting the block, inverting a
comparison, or breaking the importer derivation left both `npm run test:scripts`
and CI's `check:lockfile` gate green. That is the defect class this PR exists to
remove — the same diff rewrites two rationale comments to lean on the check, so
an unpinned guard rots into a comment that lies. Add the third banner to the
existing suite and a fixture suite that runs the script against perturbed copies
of the real manifests and lockfiles, covering both drift arms and the two shapes
that must stay green.

Also correct three properties of the block the review found:

- The inventory comment claimed two Playwright declarations sit outside the
  invariant; `packages/mobile-mcp` declares `@playwright/test` as a range
  directly, in a workspace member, and was not named. It is the only in-workspace
  range on a name this check pins, so it is what the resolved-version message
  should point an operator at.
- The agreement check destructured exactly two entries while the comment invited
  maintainers to extend the list, so a third entry would have received the
  exactness check and silently skipped the comparison the block exists for.
  Compare every entry against the first and derive the resolved-tree locations
  from the same list instead of repeating them as a second literal.
- The pnpm arm prescribed "regenerate it" for every divergence, but
  `pnpm-workspace.yaml`'s `overrides:` and `.pnpmfile.mjs` both rewrite the
  recorded specifier, so regeneration is a no-op for those. Name the deciding
  layer in the messages and the importer list's source for a missing entry.

* docs: stop counting the followup suggestion code paths

Merging main brought in the OpenTUI port of ink's AppContainer effect
(packages/cli/src/ui/opentui/followup-generation.ts), which is a third
non-test `generatePromptSuggestion` call site. The section opener claimed
"Two code paths decide this", which a grep now contradicts even though the
substance still holds: the OpenTUI hook applies the same CLI gate set as
AppContainer — interactive, not SDK mode, the Responding to Idle edge, no
error in the last item, no waiting calls, approval mode not plan — and
collapses ink's five explicit dialog checks into `waitingCalls.length === 0`
because the composer is unmounted while a shell dialog is open.

Name the two decision-makers instead of counting implementations, so the
sentence stays true when a renderer is added or retired. The CLI/daemon
split below is unchanged and was re-verified against the merged tree: the
ink gate is byte-identical at AppContainer.tsx:3612-3627, the daemon gate
moved only by line number (Session.ts:5080-5095), and the shared
MIN_ASSISTANT_TURNS gate is still at suggestionGenerator.ts:83,103.

---------

Co-authored-by: Shaojin Wen <[email protected]>
2026-09-12 11:40:46 +00:00
Dragon 4ec9f63d0a feat(core): add CodeModeOnly tool execution (#10607)
* feat(core): add CodeModeOnly tool execution

* fix(core): preserve CodeModeOnly execution semantics

* fix(core): drop the tool_search reminder in CodeModeOnly

CodeModeOnly hides `tool_search` and binds every deferred tool — schema
included — into the `exec` description, so progressive discovery has
nothing left to do. The session prelude still announced those tools as
"reachable via `tool_search`", pointing the model at a tool that is
never declared and re-billing the text on every cached prefix. Worse,
tools already callable as `tools.web_fetch(...)` looked gated behind a
lookup step that does not exist.

The subagent and fork-resume callers already opted out, but the
main-session prelude sites did not, so the gate now lives where the
prelude is built rather than in each caller.

* chore: refresh settings schema

* chore: refresh companion notices

* fix: register exec tool metadata

* fix(cli): localize exec tool display name

* fix(core): stop offering ToolSearch lookups in CodeModeOnly

CodeModeOnly hides tool_search and binds every deferred schema into the
exec description, so text that tells the model to look a tool up
describes a surface it cannot reach. Return an empty deferred summary
from the registry itself - this also covers the fork-resume reminder
that bypassed the earlier call-site gate - and point the image zoom
hint at tools.zoom_image.

* feat(core): teach the system prompt CodeModeOnly's tool surface

In CodeModeOnly the model can only call exec, so the tool guidance and the
worked examples described a surface it does not have: bare tool names it
cannot call, an instruction to issue several tool calls in one response, and
examples in a direct tool-call syntax that does not exist there.

Give "Using Your Tools" a code-mode branch that routes every tool through
tools.<name>, states which tools are direct controls instead, and replaces
multi-call parallelism with batching inside one exec program. Swap the four
model-family example sets for one shared exec set, since the syntax
differences they exist for do not apply. The mechanics of exec itself stay in
its tool description; the prompt carries only policy.

* test: include code mode host in standalone fixture

* fix(core): return code mode media as multimodal output

* feat(core): support image tool results in code mode

* fix(core): carry deferred tool schemas in the exec description

CodeModeOnly hides tool_search and never surfaces a nested call as a history
functionCall, so a signature collapsed to Record<string, unknown> could never
be filled in later: parameter names were reachable only by guessing, or by
reading them off validation errors one at a time. Deferred tools keep their
registry state; only the generated signature gains the schema.

* fix(node-repl): restore the bin exec bit after rebuild

npm chmods dist/index.js when it links the bin at install time, but the
build deletes that exact file and tsc re-emits it as 0644. After any
rebuild node_modules/.bin/node-repl-mcp therefore points at a
non-executable target and spawning it fails with EACCES (exit 126)
before the shebang is ever read.

OR the exec bits into the emitted mode instead of setting 0o755, so a
restrictive umask is preserved rather than widened.

* fix(cli): pin patched Ink version

* feat(cli): add collapsible tool call details

* fix(dev): use CLI tsconfig outside repository

* fix(cli): preserve expanded tool details after completion

* fix(cli): fail headless runs on model API errors

* fix(core): expose shell timeout bounds in code mode

* feat(core): add code mode timeout helpers

* feat(cli): expose code mode in settings

* fix(mcp): preserve structured results for model and exec consumers

* fix(core): preserve code mode output and truncate inline

* feat(core): port scoped context tools to code mode

* fix(build): synchronize pnpm lockfile and capability docs

* fix(core): recover from rejected Responses encrypted reasoning
2026-09-11 07:40:35 +00:00
f649d65d1f feat(live): add visual input, proactive assistance and memory (#11369)
* feat(live): add visual input, proactive memory and desktop controls

* fix(live): preserve upstream checks and finalize migration validation

* feat(live): open active configuration from host settings

* fix(live): address lifecycle and proactive review findings

* feat(live): add subagent controls and diagnostics with review fixes

---------

Co-authored-by: 菲兹 <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-09-11 03:13:13 +00:00
Shaojin WenandQwen-Coder 13b69b3313 feat(web-shell): make the product name and logo configurable (#11244)
* feat(web-shell): make the product name and logo configurable

White-labelling the Web Shell meant editing six source files and rebuilding: the
product name was a literal in the sidebar, the welcome header, the About panel
and the document title, and the logo existed as two hand-duplicated copies of the
same inline SVG.

Brand is now configuration. A name and a logo path are read from the operator
settings scopes, and a new process-global daemon route resolves the logo into a
data URI for the client. Embedding hosts get the same control through a prop that
replaces the resolved value outright. The terminal banner already had its own
white-label settings, so this follows their sanitization and path-resolution
conventions rather than inventing parallel ones.

Two decisions carry the design. The workspace settings layer is never read, so a
repository's own settings file cannot rename the product or name a file for the
daemon to inline into every connected browser. And a custom logo is only ever
rendered as an image, never injected as markup, which is what makes it safe for
the daemon to skip sanitizing the SVG it read.

An untouched installation renders exactly what it did before: the resolved brand
is empty, every consumer falls back to its own built-in literal, and the served
document keeps its original title and favicon bytes, so the existing visual
baselines stay valid.

* fix(docs): sync the daemon capability tag count with the new brand tag

The daemon index states how many capability tags the registry holds, and a
contract test fails the build when the two disagree. Adding the brand tag made
the registry 158 while the index still said 157.

* chore: re-trigger CI on the current head

The previous push arrived while a re-run of the prior commit was still holding
the workflow's concurrency slot, so GitHub suppressed the new run instead of
queueing it and the event does not replay once the slot frees. This leaves the
branch head with no CI at all. An empty commit re-fires the synchronize event;
the repository squash-merges, so it does not survive into main.

* fix(web-shell): Address brand review feedback across resolver, client and docs

- Expose a brandSettled flag from the workspace provider so a
  settled-with-no-brand outcome (older daemon, withdrawn host prop) is
  reported and invalidates stale cached chrome, while the in-flight
  state still fires nothing
- Fall back to the built-in sidebar mark when a logo data URI fails to
  decode, and when a host logo node is falsy; remount the logo image
  per data URI so a decode failure cannot stick to the next logo
- Report an empty host brand name as unset in onBrandResolved, and type
  the callback payload as the document-actionable subset
- Exclude the favicon from the boot watchdog's fatal-resource
  classification and keep its content-free error out of the panel list
- Require a namespaced <svg> root in the resolver, cover the post-decode
  size cap, and pin the exact stderr prefix the protocol doc publishes
- Assert GET /brand answers JSON on the real app for any Accept header
- Sync the capability index count (159), settings docs and the design
  doc with the above

* fix(web-shell): Address second brand review round — settle semantics, SVG scanner, test fixtures

- Settle the brand fetch only on a definitive outcome: an answer, or a
  404 from a route-less daemon. A retryable failure (503 bootstrap, 429,
  transport, old SDK) is unknown rather than absent — settling it
  cleared the pre-paint cache and reset the tab title mid-session over
  a blip nothing retries (review Critical)
- Rewrite the SVG root scanner quote-aware: a quoted `>`, `[` or `]` in
  DOCTYPE literals or the root tag no longer rejects valid documents,
  and an xmlns-shaped substring inside another attribute's value no
  longer certifies a namespace-less document as SVG
- Warn (not reject) when the root svg carries no viewBox and no
  width/height: the browser loads such an image successfully but may
  paint a blank mark at the sidebar's fixed size, and only the daemon's
  stderr can say so
- Treat a nullish host brand prop as absent in the resolution gate,
  matching the `??` one line above
- Pin previously unwitnessed guards: the fd identity re-check and both
  fs soft-fail branches, the logo key remount, the favicon error-list
  exclusion, the unsettled-to-settled transition, whole-object host
  takeover against a daemon logo, and the superseded-client reset and
  catch-leg disposed guards
- Drive GET /brand on a real app with the SPA fallback mounted and a
  token configured, and pin the System settings layer to empty files so
  a maintainer's machine-wide brand cannot leak into the assertions
- Round-trip the pre-paint cache through the real inline script instead
  of pinning each side against its own copy of the key
- Document the hard-link and decoded-size rules, the namespace
  requirement, the viewBox advisory, and the corrected 503 semantics
  (bootstrap catch-all, retryable; a draining daemon does not reject
  this route); the README's onBrandResolved row now names the narrower
  payload type

* fix(web-shell): Close third brand review round — placeholder exclusion, scanner correctness, retry path

- Read brand keys from the pre-substitution snapshot and refuse
  environment placeholders with a warning: placeholder substitution
  draws from the process-wide environment, which a workspace populates
  first at boot, so resolving brand placeholders would let a repository
  supply the value — the workspace exclusion through a side door
  (review Critical)
- Blank quoted SVG attribute spans per UTF-16 code unit, not per code
  point: an astral character collapsed to one space and shifted every
  later index, refusing valid namespaced documents (review Critical)
- Accept prefix-bound <svg:svg> roots that bind xmlns:svg to the SVG
  namespace (browsers render them), keep refusing <svgfoo> near-misses
  and prefix-only bindings on an unprefixed root, and pin the
  whitespace-prefixed fake-xmlns shape that actually exercises the
  quote-blanking
- Make the scaling-geometry advisory value-aware: an empty viewBox,
  zero dimensions, or percentage width/height now warns (loads
  successfully but cannot scale into the sidebar box)
- Add refreshBrand beside refreshCapabilities — same 404-only settle
  rule, generation-guarded, gated to the genuinely-missing state and
  no in-flight fetch — and call it from the connection recovery path,
  so a retryable brand failure no longer leaves in-app chrome and tab
  chrome disagreeing for the page's lifetime
- Warn on console when the sidebar logo fails to decode (the one logo
  failure the daemon's root-tag validation cannot see)
- Pin the O_NONBLOCK FIFO defence with a real mkfifo fixture, the
  published 429/drain transport states on the real app, and make the
  ordering test's body discriminating with a fixture brand
- Document the placeholder carve-out, prefix-bound roots and the
  value-aware advisory across settings docs, schema, protocol reference
  and the design record

* fix(web-shell): Close fourth brand review round — env-var side door, retry machinery, scanner class fix

- Add QWEN_CODE_SYSTEM_SETTINGS_PATH / QWEN_CODE_SYSTEM_DEFAULTS_PATH to
  the hardcoded project-.env exclusions: they select which file becomes
  the System settings layer, so a repository could otherwise promote
  its own file above the operator's User layer and rebrand the shell
  with literal values (review Critical)
- Refuse brand placeholders on substitution, not syntax: run the real
  resolveEnvVarsInString and refuse only when it would change the
  value, so a literal name containing `$` survives while anything a
  workspace-tainted environment could have supplied is refused
- Emit one stderr line per brand warning instead of a joined line, so
  a log rule keyed on one key's prefix keeps firing when another
  misconfiguration exists
- Make the SVG root matcher prefix-agnostic and entity-aware: any
  prefix bound to the SVG namespace qualifies, character references in
  attribute values are decoded before comparison, a degenerate viewBox
  no longer suppresses the scaling advisory, and a prefix-bound root
  with unprefixed children gets its own advisory
- Retry a retryable brand failure once after a short delay and warn
  only when the retry is also refused (404 stays silent); wire
  refreshBrand into the recovery path with tests; gate it on
  in-flight; attribute retries per client instance; reset the brand in
  the render that observes a client change so no committed frame mixes
  two connections
- Pin the O_NONBLOCK flag with a flags-capture witness (the FIFO case
  alone cannot fail fast: a blocked open wedges the worker's event
  loop), skip the FIFO fixture on Windows where the defence does not
  exist, and mock the not-stattable path instead of relying on POSIX
  ENOTDIR
- Document the startup hold vs terminal 503 semantics correctly and
  retire the design record's pre-retry narrative

* fix(web-shell): Close fifth brand review round — real XML parser, docs alignment, hold test

- Parse the logo with the vendored fast-xml-parser instead of the
  hand-rolled root-tag scanner: well-formedness (duplicate attributes,
  misplaced declarations) is now rejected by a real validator, the
  namespace check reads parsed attributes with entity references
  decoded, any prefix bound to the SVG namespace qualifies, and the
  prefix-children advisory walks the parsed tree — construct-aware by
  construction. The DOCTYPE is stripped for the validator with a
  quote- and subset-aware scan because the validator's own DOCTYPE
  handling is quote-blind (review Suggestion, filed as the class-level
  finding)
- Bound entity decoding at Unicode's ceiling: an out-of-range
  character reference no longer throws a RangeError out of the
  resolver — which the route's catch-all turned into a 200 {} that
  dropped the validly configured brand NAME along with the logo
  (review Critical)
- Refuse brand placeholders by comparing the layer's substituted value
  against its pre-substitution snapshot — the authoritative engine
  WITH the home-.env fallback — and keep an unresolvable placeholder
  verbatim so a typo'd variable shows as literal text; say so in all
  five published artifacts, and hint when a missing logo's path still
  contains a placeholder token (review Critical)
- Gate the prefix-children advisory on the default namespace being
  absent: with both bindings, unprefixed children render fine
- Split the scaling advisory's message by cause (zero-area viewport,
  malformed viewBox, no usable geometry) and document both advisories
- Pin the startup-hold guarantee with a real deferred-runtime test:
  GET /brand issued before the runtime is ready is held and answered
  200 once it settles
- Drive the two retry tests on fake timers (BRAND_RETRY_DELAY_MS is
  exported for them) instead of sleeping through the production
  constant, and pin the route's one-stderr-line-per-cause invariant
  with a two-misconfiguration case

* fix(web-shell): Close sixth brand review round — saxes parser, lockfiles

Swap fast-xml-parser (subset validator plus pre-decoded attributes) for
saxes, the streaming XML parser jsdom uses: malformed documents are now
refused instead of shipped, attribute values are entity-decoded exactly
once so the xmlns gate cannot be faked by a second decode, and the parse
stream is closed after write so an unclosed root fails at EOF. Regenerate
both lockfiles for the dependency swap — the round-5 fast-xml-parser edge
was never recorded, which is what turned the frozen pnpm installs red —
and pin the refused well-formedness classes and the double-decode fixture
in the resolver tests.

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

* fix(web-shell): Enable namespace processing in the brand logo parser

saxes was constructed with default options, so an undeclared namespace
prefix on a child (the classic legacy-export xlink:href with no
xmlns:xlink) parsed clean here while browsers fatal on it — the daemon
shipped a data URI no browser renders, with nothing on stderr. Enable
xmlns: true, matching jsdom (browsers always have namespace support),
flatten the object-shaped attribute values that mode returns, and pin
both sides: undeclared child prefixes refuse, implicitly-bound xml: and
properly declared bindings still accept. Mutation-checked: reverting the
option and the flattening turns the new refusal test red.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-10 04:54:45 +00:00
易良andQwen-Coder b661a2cdb2 fix(deps): Bump sharp to 0.35.4 to resolve high-severity libheif CVE (#11435)
* fix(deps): Bump sharp to 0.35.4 to resolve high-severity libheif CVE

The dependency CVE audit gates on high-severity findings. sharp
0.35.3 pulls in a vulnerable libheif (GHSA-rgj7-g3m4-5g8c), which
fails the audit. Bump sharp (and its @img platform/libvips binaries)
to 0.35.4, which ships the fixed libvips 1.3.3.

* fix(deps): regenerate pnpm-lock.yaml for sharp 0.35.4

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmttlhdvjsx

* chore(deps): Refresh VS Code companion notices for sharp 0.35.4

The sharp bump changes the generated NOTICES.txt version string;
Lint & Static gates on the file being up-to-date.

* chore(deps): Clear transitive sharp 0.35.3 from pnpm lockfile

@mobilewright/core (mobile-mcp) still resolved sharp 0.35.3 through
its ^0.35.3 range, leaving the vulnerable libheif build reachable
from the worktree bootstrap. Refresh the resolution so every sharp
and @img platform entry in the pnpm lockfile is 0.35.4 / 1.3.3.

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-09 06:37:26 +00:00
易良andQwen-Coder 32f51a330b chore: restore regression coverage after WebUI retirement (#11101)
* chore(web-shell): follow up #9812 review findings and deferred suggestions

Address the remaining review suggestions from the @qwen-code/webui
retirement (#9812) and the deferred items tracked in #11076:

- Fix the 01-architecture.md adapter diagram path (missing session/).
- Correct the followup-suggestions doc: generation fires on end_turn, and
  headless/SDK daemon clients should opt out to avoid the per-turn LLM cost.
- Pin playwright to 1.61.1 to restore version parity with @playwright/test.
- Restore file: scheme rejection coverage in Markdown.test.ts.
- Pin the historical sessionStorage key prefix with a literal assertion.
- Cover the legacy exported JSONL rejection and ChatRecord happy path.
- Restore the shell-substring classifier trap with a live path.
- Point useDaemonFollowupSuggestion docs at ChatEditor, not the deleted InputForm.
- Replace the theme-toggle timing-race assertion and exercise the
  unhandledrejection fail-closed path in the document browser gate.

Closes #11076

* fix(test): poll the theme class instead of a matcher vitest lacks

The browser gate imports expect from vitest and drives playwright's
Locator directly, so toHaveClass does not exist on the assertion and
typecheck:integration failed with TS2339 on this line. Poll the class
attribute through the same expect.poll idiom the neighbouring
assertions use, which keeps the retry the assertion was switched to
and needs no @playwright/test dependency.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmtooas8sln

* fix(test): assert the exported theme class exactly

`document-main.tsx` toggles `dark` and `light` on <html> mutually
exclusively and nothing else writes `documentElement` classes, so the
attribute is always exactly one of them. Matching `/light/` as an
unanchored substring would also accept `dark light`, which is the
theme-toggle breakage this poll exists to catch.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtow5pgslz

* fix(test): realpath the exporter main check and pin its JSONL guard

Node realpath-resolves `import.meta.url` for the ESM main module but
leaves `process.argv[1]` as invoked, so comparing the raw spellings made
a symlinked invocation skip `main()` and exit 0 having written nothing —
a silent no-op the harness reads as success. Compare realpaths instead
and export the predicate so both directions are pinned.

Also extract the input gate into `assertRenderableJsonl` so the legacy
exported-JSONL rejection can be asserted directly: the previous tests
only pinned the two predicates feeding it, so deleting the guard and
losing the remediation hint kept every test green.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtow5pgslz

* docs(followup): name the real ACP stop reasons, drop "unconditional"

The parenthetical this PR added named `interrupted` as a daemon stop
reason. The ACP `StopReason` union is
`end_turn | max_tokens | max_turn_requests | refusal | cancelled`, so
there is no `interrupted`; an interrupted turn reports `cancelled`. Name
the four suppressing values the union actually defines so an integrator
writing `stopReason === '...'` gets a branch that can fire.

"unconditional" was wider than the code and contradicted the condition
list on the same page: `Session.ts#maybeEmitFollowupSuggestion` returns
early on the stop reason, the todo stop guard,
`ui.enableFollowupSuggestions`, PLAN approval mode, a missing chat, and a
non-model last history entry. Point at those conditions instead; the cost
advice still holds because the `enableFollowupSuggestions === false`
return sits before `generatePromptSuggestion` is reached.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtp6vibomd

* test(web-shell): drop the duplicate historical-key assertion

`persists under the literal historical key prefix`, added on this branch,
became a byte-equivalent twin of `writes under the historical WebUI key`,
which reached main with #11107 and arrived here through the merge at this
PR's head: same `persistStableClientId('client-a', 'session-a')`, same
literal `qwen-code-webui-client-id:session:session-a`, same expectation.
One fact was reported under two names, so a prefix rename reddened two
tests and an auditor could not tell which copy was load-bearing.

The surviving test's comment already carries the rename rationale, so
dropping the copy loses no coverage.

Verified with `cd packages/web-shell && npx vitest run
client/daemon/session/clientLifecycle.test.ts`: 19 passed before, 18
passed after. Mutating SESSION_CLIENT_ID_STORAGE_PREFIX to
`qwen-code-webshell-client-id:session:` after the dedupe still reddens
3 tests (15 passed), including the surviving `writes under the historical
WebUI key`; reverting the mutation returns to 18 passed.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtp6vibomd

* test(integration): drop the unreachable unhandledrejection arm

The block this PR added after the CDN-failure loop could not reach the
`window.addEventListener('unhandledrejection', showLoadError)` handler its
comment claimed to exercise. `page.route('**/*', route =>
route.abort('blockedbyclient'))` fails the `<script id="transcript-renderer">`
load, the template's capture-phase `error` listener (document-index.html:45-56,
matching `event.target.id === 'transcript-renderer'`) calls `showLoadError()`
first, and `setContent(..., { waitUntil: 'load' })` only resolves after that
subresource has failed - so `data-render-complete='error'` and the alert text
were already in place before `page.evaluate` dispatched anything, and
showLoadError's one-shot guard (`if (document.body.dataset.renderComplete)
return;`, document-index.html:30) made the synthetic rejection a no-op. The
block was a strict duplicate of the loop's own `rendererBody === null`
iteration: identical all-abort route, identical two assertions.

Exercising the handler for real needs a renderer body that passes the
document's pinned SRI integrity and then rejects during boot; no body this gate
can serve passes SRI, so the arm cannot be made honest here. Deleting it
returns the file to origin/main's state for this test - the handler and its
missing coverage are both pre-existing on main (packages/web-templates is not
in this PR's diff), so no coverage this PR was responsible for is lost.

Verified: `npm run typecheck:integration` reports 0 errors in this file (6
remain, all in packages/** against generated/dist paths absent from an unbuilt
worktree: channels/feishu, cli/src/generated/git-commit.js,
web-templates/src/generated/*); `npx prettier --check` and `npx eslint` clean
on the file. The browser gate itself was not executed locally - it needs the
full build chain plus Chromium.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtpfg5ewmp

* ci(review-context): route concurrent-runner changes to the helper-tests lane

This PR added
`integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs`,
the first `node:test` file under `integration-tests/`, and wired it into CI
through the `HELPER_TESTS` env list in `.github/workflows/ci.yml`. The tracked
review-context manifest still mapped the whole `integration-tests/**` path to
`recommendedTests: ['integration-tests']`, whose vitest lane collects
`**/*.test.ts` only (`integration-tests/vitest.config.ts:21`), so a future PR
touching only the exporter and its `.mjs` test is told to run a lane that
cannot collect the file - the same silent drop `HELPER_TESTS` exists to
prevent.

Add a narrower rule for `integration-tests/concurrent-runner/**` recommending
`helper-tests`. Matching rules merge rather than first-match, so the lane list
for that directory becomes the union and every other `integration-tests/` path
is untouched. The pinned `expectedManifest` in
`manifest-repository-context.committed.test.ts` moves with it, which is what
that pin is for.

Verified through the real provider (tsx importing
`packages/cli/src/commands/review/lib/manifest-repository-context.ts`) because
the packages/cli vitest globalSetup gate refuses to run on this unbuilt
worktree:

  concurrent-runner .mjs           -> ["helper-tests","integration-tests"]
  concurrent-runner .js            -> ["helper-tests","integration-tests"]
  chat-transcript-document.test.ts -> ["integration-tests"] (unchanged)
  all 10 rules co-matching         -> non-null, 80 relatedPaths, and every
                                      `paths` probe matched some rule

Also checked the committed manifest deep-equals the test's pinned literal (10
rules on both sides), and that dropping the new rule breaks that equality, so
the pin is not tautological. `npx prettier --check` and `npx eslint` clean on
both files.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtpfg5ewmp

* fix(deps): sync pnpm lockfile for Playwright pin

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-08 08:15:03 +00:00
3ecfaffdf8 perf(dev): add pnpm worktree bootstrap foundation (#10449)
* perf(dev): add pnpm worktree bootstrap foundation

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

* fix(dev): harden worktree bootstrap process handling

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

* perf(dev): prefer cache-only worktree installs

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

* fix(dev): preserve cached install cancellation

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

* fix(build): harden pnpm worktree bootstrap

Validate pnpm lock updates in releases and exercise real installs and builds across supported hosts. Preserve npm release compatibility and keep dependency-only setup from rewriting npm-layout notices.

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

* chore: record merge attribution

Record the required attribution for the Stage 1 merge without rewriting published history.

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

* fix(dev): keep worktree bootstrap clean

Use a bootstrap-scoped notice guard because nested npm lifecycle commands replace npm_lifecycle_event. This preserves explicit notice generation while preventing dependency setup from rewriting tracked output.

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

* ci: track pnpm smoke workflow size

Register the new workflow in the repository size ratchet as required by the main CI gate.

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

* fix(ci): drop stale importers from the new pnpm lockfile

The lockfile was generated before the WebShell cutover (#9811) removed
@qwen-code/webui from web-shell and the tailwind tooling plus
@qwen-code/webui from vscode-ide-companion, so frozen-lockfile
installs fail on all three smoke platforms with
ERR_PNPM_OUTDATED_LOCKFILE. Drop the five stale importer entries so
the lockfile matches the current package.json manifests; verified with
pnpm 11.24.0 install --frozen-lockfile --lockfile-only.

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

* fix(ci): refresh pnpm lockfile after merging main

The merge of origin/main added remend@^1.3.1 to packages/cli/package.json
without updating pnpm-lock.yaml, breaking the pnpm Worktree Smoke workflow
frozen-lockfile install. Regenerated with pnpm install.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmtid3rse9h

* fix(scripts): resolve the path variable case-insensitively on Windows

A spread of process.env is an ordinary object, and native Windows shells
expose the path variable as `Path`, so `env.PATH` was undefined there and
findOnPath never located corepack — the offline-first Corepack bootstrap
silently degraded to npx on exactly the hosts it exists for.

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

* test(scripts): pin the worktree bootstrap guards against mutation

- Split the fallback log on /\r?\n/ like the sibling cmd.exe mocks, so the
  assertion holds on the Windows lane's CRLF output.
- Add a win32 variant bootstrapping with the native `Path` casing to pin
  the case-insensitive lookup.
- Assert the smoke workflow's fail-fast flag, the install-before-clean
  step order, and the no-build guard via a substring on the raw job text.
- Extend the pnpmfile rewrite fixture to devDependencies and
  optionalDependencies, which the committed lockfile already uses.
- Assert the notice-skip guard by effect (writeFile never called) and add
  the flag-absent companion test with I/O stubbed.

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

* chore(deps): sync pnpm-lock.yaml with qwen-live ACP backend deps

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmtjyz3tdco

* fix(dev): stop patch-package from corrupting the pnpm store

The root postinstall runs patch-package, which rewrites files under
node_modules in place. With pnpm's default import method those files are
hard links into the content-addressable store, so the patch rewrites the
store entry too and its contents stop matching the sha512 it is filed
under. Every subsequent fresh worktree then fails its --offline stage
with ERR_PNPM_NO_OFFLINE_TARBALL, silently falls back to the registry
(exit 0), and the fallback's postinstall corrupts the store again, so the
offline path never hits. The advertised offline timing was only reachable
on a same-tree reinstall, which needs no relinking.

Set packageImportMethod to clone-or-copy: a copy-on-write clone where the
filesystem supports it, a plain copy elsewhere. Either way patch-package
edits only the worktree's own copy and the store entry stays intact, so
later worktrees resolve entirely from the store.

Verified on an isolated fixture store: under the default method the
node_modules file and its store entry share an inode, and an in-place
edit moves the store file's sha512 off its address, after which a fresh
offline install fails with ERR_PNPM_NO_OFFLINE_TARBALL; under
clone-or-copy the same edit leaves the store hash unchanged and the fresh
offline install reports reused 1, downloaded 0.

Also add pnpm-lock.yaml to .prettierignore. prettier reflows it into a
shape pnpm does not emit, so `npm run format` and `pnpm install` fight
over the file; .yamllint.yml already ignores it for the same reason.

Both facts are pinned by tests in scripts/tests/package-scripts.test.js.

Claude-Session: https://claude.ai/code/session_012797rgiteWJxLT9TLkKq8G

* fix(ci): refresh pnpm-lock.yaml for qwen-live prompts deps

Main added prompts/@types/prompts (and the ACP sdk) to
packages/qwen-live/package.json after this branch generated its
lockfile, so the pnpm Worktree Smoke frozen-lockfile install failed
with ERR_PNPM_OUTDATED_LOCKFILE. Regenerated with pnpm 11.24.0
(--lockfile-only); frozen-lockfile and supply-chain policy checks pass.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmtlc9sozfg

* fix(pnpm): close review findings on worktree bootstrap

- Declare @qwen-code/qwen-code-core in vscode-ide-companion so the pnpm
  linker materializes it; regenerate both lockfiles and assert the link in
  the smoke workflow (R5-1)
- Pin @types/node 20.19.1 in packages/core so the pnpm layout compiles
  against the same types npm hoists (D6-1)
- Accept and pin the corepack +sha512 integrity suffix on packageManager;
  strip it for the npx fallback spec (D6-2)
- Cover every workspace member in the pnpmfile rewrite set, mirror the npm
  channel list in pnpm-workspace.yaml, and cross-check both in tests
- Run setup-worktree.js installs with cwd pinned to the checkout, gate the
  smoke clean-check on git status --porcelain, stop post-merge runs from
  cancelling each other, and extend check-lockfile.js to pnpm-lock.yaml

* merge(main): realign pnpm workspace set and lockfiles

Main removed the webui and cua-driver packages; drop both from the pnpm
rewrite set and regenerate the pnpm lockfile against the merged manifests
(picks up playwright, react-markdown, and the other main-side dependency
changes) so the frozen bootstrap matches what the PR merge ref will run.

* style(ci): quote smoke workflow scalars per yamllint

* fix(dev): require Corepack for pnpm bootstrap

* fix(dev): close pnpm bootstrap review blockers

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
2026-09-08 03:07:40 +00:00