Commit graph

17 commits

Author SHA1 Message Date
顾盼
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
callmeYe
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 <ethanye@U-463KXYWL-1938.local>
Co-authored-by: claudeethan <claudecodeethan@gmail.com>
2026-10-20 19:41:48 +08:00
易良
8c914ebe03
feat(agents): add local workspace-agent collaboration (#11206)
* test(agents): kill a writer mid-transaction, and say what that does not prove

A daemon can die holding the workspace lock. Two things must be true
afterwards and now are, from a real SIGKILL rather than an argument: the store
stays readable with nothing written before the crash lost, and writes come
back without anyone clearing the lock — a lock held by a dead process that
nothing reclaims is a workspace wedged forever.

How long recovery takes is reported rather than asserted, and that is a
correction. An early probe measured a 10.5-second outage and I wrote that into
both the script and a code comment as a measured fact. Four repeat runs
recover in about 25ms and the outlier never reproduced, so the assertion that
a write in the gap is refused was dropped: it depends on where in the
staleness cycle the crash lands, and stating it as an invariant was wrong.
Both comments now say what I can actually support.

The route maps that refusal to 503 with a Retry-After instead of a 500
quoting a lock file at whoever clicked. It is a wait, not a fault, whether it
lasts 25ms or ten seconds.

The script also records what it does not cover, because I checked: replacing
atomicWriteJSON with a plain truncating write leaves this and the concurrency
script green. The file lock means two writers never touch one file at once, so
atomicity only matters for a crash *during* a write, which killing an idle
holder never reaches. store.test.ts covers that with fault injection. A script
that passes under a mutation which should break it does not cover that
property, and saying so is the difference between coverage and the appearance
of it.

* test(agents): fuzz the store against its invariants

The other scripts check scenarios someone thought of. This one draws
operations at random — create, post as a person or an agent, claim, finish,
toggle, retire — and after every step asserts what must hold regardless of
order: every thread readable, message sequences unique and increasing with the
watermark ahead, run ids unique, queueSequence unique across the workspace,
parent and root ids resolving, terminal runs carrying an end time, no agent
running more than its maxConcurrentRuns, and no run booked for an agent after
it retired. The status resolver is called on every thread, since it has to
answer for any shape the store can reach.

A refusal is a legal outcome and is recorded as one — the store simply has to
stay consistent through it. The seed is printed and accepted back, so a
failure is reproducible instead of a story about a run nobody can repeat.

Clean over seven seeds, up to 800 steps. What makes that worth anything is
what it does when the invariants are broken. Removing the concurrency check
from claimRun is caught by step 42 on one seed and step 20 on another, named
as "ag_1: 2 running over a limit of 1". Removing the retirement guard from
decideDispatch — the exact bug fixed in 521b212cd0 — is caught at step 6 on
seed 42, named as "1 run(s) booked after retirement".

That second one is the argument for this whole script. The bug it replays sat
through eight ticks of orphan sweeps, type checks and re-reading, and was
found only because I happened to notice `isAgentEnabled` where
`isAgentAddressable` belonged. The fuzzer finds it in seconds without anyone
suspecting anything.

* fix(audit): drop an unused binding the fuzzer left behind

The loop destructured a live-run count it never read; it iterates keys now.
Committed with lint red, which the previous commit should have caught before
pushing.

* test(agents): widen the fuzzer, after finding it was barely exploring

The alphabet now includes the thread tools under real run frames, sub-thread
creation, assignment, and hiring, and two invariants come with them: a
`finishing` run must carry a close kind, and every run must be booked for an
agent still on the roster. A child's root thread must equal its parent's, so
splitting work cannot mint a second budget.

Two defects in the harness itself, both of which made a green result mean less
than it looked.

The draw degenerated. Retiring was as likely as anything else, so within a
hundred steps every agent was retired and most of the alphabet became no-ops:
400 steps executed exactly one thread tool. The weights now favour claiming,
keep retiring rare, and balance it with hiring. An earlier weights array was
also shorter than `ops` and silently never drew the last two entries.

Worse, `finishRunInTransaction` was never exported to the bundle, so every
finish threw "is not a function" — and the catch-all that treats a refusal as
a legal outcome swallowed it as one. Runs never reached a terminal state for a
whole session behind a passing result. The entry exports run-lifecycle now,
and a TypeError is reported as a harness bug and exits 2 rather than being
counted as the store refusing something.

With that fixed the draw is healthy: 400 steps give 41 finishes and 34 tool
executions across all five tools against 22 no-ops. Clean over six seeds at
300 steps. Both mutations still bite — removing the retirement guard is caught
on seed 42 at step 130, and dropping closeKind from a close is caught at step
12 — and the docstring says plainly that seed 1 does not catch the first one
at all, because a fuzzer that is sold as a proof is worse than none.

* test(agents): check a sub-thread cannot buy a fresh budget

Found by auditing my own coverage rather than the code: I walked the design
doc's twenty-two decisions and asked which had an executable check. Decision
17 — a child inherits the parent's turn count and charges tokens to the root —
had none, and it is a budget-evasion guard. Without inheritance an agent could
reset the loop breaker by delegating, and a tree could run forever a
sub-thread at a time while every individual thread looked well behaved.

It is implemented and now checked: a child split from a parent at seven
unattended turns starts at seven, not zero, and shares the parent's root.
Verified to bite — starting the child at zero turns the assertion red.

Two decisions still have no executable check and are recorded rather than
quietly left: an agent may not create agents (12), and agents are scoped to
one workspace (4). Neither has an obvious evasion story the way 17 did, but
neither is tested here either.

* docs(agents): record how this branch was verified, and what that misses

Fifteen thousand random operations across 25 seeds held every invariant, so
the plan now carries the verification table rather than leaving it in commit
messages nobody will assemble.

The table says what each check covers and the doc says plainly what none of
them do: the vitest suites are larger and still need CI, partial-write
recovery is store.test.ts's job because the file lock means two writers never
touch one file and killing an idle holder never interrupts a write, and two
of the twenty-two decisions have no executable check here.

Every entry was calibrated by breaking what it checks. That is the only
reason the green is worth writing down.

* test(agents): close the last two unchecked decisions, one of them properly

Decision 12 — an agent may post, mention and delegate but may not make more
agents — is checked at both layers: no agent-creating tool is in the config an
agent runs with, it is named in disallowedTools rather than merely absent, and
the invocation guard refuses it even if something asks anyway. Flipping it to
`allow` turns three assertions red.

Decision 4 — agents are scoped to one workspace — took two attempts, and the
first one is the interesting part. It reused a run frame from earlier in the
script and asserted that changing its workspaceId made a post fail. It did
fail, and the assertion passed, and it proved nothing: by that point the run
had already been closed, so the refusal came from the closed-run check and the
workspace comparison was never reached. Removing that comparison from
requireLiveRunInTransaction left the suite green.

It runs on a fresh live run now, with the frame first proven to work as
itself, so the only thing wrong in the second call is the workspace. Removing
the comparison turns both assertions red.

That near miss is the same failure this whole review keeps finding: a green
assertion that passes for a reason other than the one it claims. Mutating the
thing under test is the only way I have found to tell the difference.

* test(agents): cover what a mutation campaign proved was untested

Disabling each of 86 single-line guards in the subsystem one at a time and
re-running the harness: 8 caught, 72 survived, 6 would not build. That number
is the honest measure of what 146 assertions were worth, and it is much lower
than the count suggested.

Most survivors are fair. Turning off `if (!thread) throw` changes nothing when
every test supplies a thread, and store.ts's forty-odd validators guard
malformed records this script never writes — store.test.ts covers those with
fixtures I cannot easily reproduce here. A survivor means "this script never
builds the case that guard exists for", not "nothing checks it".

Some were not fair, and are covered now:

- `classifyAgentTool` fails closed for a name that is not in the table at all.
  Section 17c tested tools that *are* in it, so the allow-list could have
  become a fall-through and nothing would have noticed. This is the boundary
  I claimed was enforced two commits ago.
- The capability guard does not override an upstream refusal.
- An empty sub-thread title is refused rather than creating something nameless.
- Close obligations: a live run owes nothing, a blocked close does, a
  completed run with no close kind owes nothing — `unclosed` is a kind an
  agent records, not the absence of one, and my first assertion conflated
  them — a run that ended without a hand-off does owe, and a failed run
  outranks whatever it recorded first.
- A `finishing` run owes nothing until it is terminal. This one exists purely
  because the liveness guard survived at first: every live fixture also
  lacked a close kind, so disabling liveness was neutralised by the next
  guard. Only a run that is both live and carrying a close kind tells them
  apart.

Re-running the campaign on those guards: five of five now caught. A vacuous
`ok(..., true)` I wrote as a placeholder was deleted rather than left to pad
the count.

* test(agents): charge tokens for real, which nothing here had ever done

The mutation campaign showed the whole charging path unreached: the fake port
in the crash-recovery section has no `totalTokens`, so `chargeRunUsage`
returned at its first line every time. Commit f663f779a1 moved accounting onto
the session's own counter and claimed the per-tree gate still worked; nothing
in this script had executed a single line of it.

Now it does. A session reporting 1,750 spent against a run whose baseline is
1,000 charges the run 750 — the difference, not the whole session — and the
tree total reflects it. A run whose baseline already equals the session total
records nothing. A runtime that cannot report usage charges nothing rather
than guessing, because charging a number nobody reported would spend a
person's budget on a hunch.

The fixture failed first for a reason worth keeping: without `consumedOnStart`
the trigger message counts as undrained, so the dispatcher requeues instead of
closing and charging never happens. That is the code being right and the test
being wrong, again.

All three guards in the path are now caught when disabled, including
`spent === 0` and the unreadable-meter branch, and replacing the baseline
subtraction with the raw total turns two assertions red with 1750 where 750
belongs.

* test(agents): cover every way an assignment can be refused

A focused campaign over thread-actions and dispatcher caught 3 of 24 guards.
The assign path accounted for five of the survivors, and two of those are the
retirement work from 521b212cd0 — that commit fixed admission and the assign
path in one go, and only the admission half had ever been exercised. The guard
I wrote to stop a thread being handed to a retired agent had never run.

All six are covered now and all six are caught when disabled: a thread that
does not exist, a finished thread, a name nobody has, a disabled agent, a
retired agent refused as retired rather than as disabled, and claimRun
noticing an agent that stopped being addressable after its run was booked.

That last fixture is worth keeping for what it taught. Retiring cannot produce
that state at all: 8d6e199cc4 refuses to retire an agent with queued work, so
my first attempt retired nothing and the claim succeeded — the test failed
because two of my own guards interact, not because either is wrong. Disabling
has no such restriction, reaches the state, and the assertion now says so and
pins the retirement refusal beside it.

* test(agents): cover the quiet no-ops, and say which survivors are not gaps

Re-running the full campaign after the last two commits: 8 caught out of 86 at
the start of this, 23 now. This commit takes it further and, more usefully,
draws a line under what is left.

Covered here, each because disabling its guard changed nothing before: a
notification pass with no sender and with no destination sends nothing; once a
destination exists a blocker queues a notification, a real sender delivers
every pending one, and a second pass sends nothing; whitespace-only
instructions leave the definition prompt exactly as it was; clearing an
assignee nobody set is accepted and changes nothing; and acknowledging
discharges only what a selector matches, so a scoped discharge is not a
blanket clear.

The missing-sender guard needed a destination to be observable at all — with
none configured the loop never reaches it — which is the difference between
"unobservable" and "I never built the state". It is caught now.

Three survivors are staying, and not because I gave up. Disabling
`pending.length === 0` makes the code walk an empty list to the same end;
disabling the unset-assignee check re-assigns undefined over undefined. Their
removal is indistinguishable from their presence, so no assertion can catch
them and writing one would be theatre. Most of store.ts's remaining survivors
are per-field validators: store.test.ts covers fail-closed at the record
level, which I checked rather than assumed, but a single field check can be
disabled with both suites still green. That is a real if minor gap, stated
rather than papered over.

Two of my own assertions were wrong on the way. "Delivers it exactly once"
assumed a clean slate when the script had already queued 27 notifications, and
a minimal dispatch port lacked `start`. Both were mine; the idempotency claim
that survives is the one that was worth making.

* test(agents): finish the mutation triage, and record what the score means

28 of 86 guards now caught, from 8 when the campaign started. Four files are
clean: capability, dispatch-policy, thread-status and persona.

The last two additions came from taking the remaining survivors seriously
rather than declaring them acceptable. A malformed parent-report payload is
reachable — the payload is a record of unknown fields and nothing validates
it — so the delivery pass is now shown to survive one, and the guard is caught
when removed.

The orphaned-parent branch beside it turned out to be unreachable, and finding
that out was the useful part: `deleteThread` refuses to delete a thread that
has sub-threads, so no supported operation can produce a report whose parent
is gone. The refusal is what gets asserted. Faking the state to make the guard
look covered would have been the wrong answer.

The plan now records the score and, more importantly, the three kinds of
survivor: unobservable, where removing the guard changes nothing anyone can
see; unreachable, where the store forbids the state; and somebody else's,
where store.test.ts covers the behaviour at record level while individual
field validators can each be disabled with every suite green. Counting
survivors without that split would make the number look worse than it is, and
counting them as covered would make it look better.

* fix(agents): release task dispatch before model completion

* fix(agents): connect collaboration tools and task panel routes

* fix(agents): start and resume real collaboration sessions

* fix(agents): enforce capability policy in agent sessions

* fix(agents): apply model selection when sessions initialize

* feat(web-shell): Show persisted task routing outcomes

* fix(agents): Preserve unread inputs across run close

* feat(agents): Deliver live input through ACP with consumption receipts

* fix(web-shell): Describe peer waits without assuming child threads

* fix(agents): Confirm runtime stop before completing cancellation

* docs(agents): Record human unblock acceptance and current delivery workflow

* fix(agents): Recover durable work after runtime readiness

* fix(agents): Persist budget gate notifications on admission

* fix(agents): Confirm initial input after transcript flush

* feat(agents): expose persistent agent runtime state

* feat(agents): reuse the existing agent builder

* fix(agents): avoid spurious collaboration wakes

* feat(agents): show agent sessions in the sidebar

* fix(agents): isolate task sessions by thread

* feat(agents): create persistent identities directly

* fix(agents): bind identities to the local runtime

* feat(agents): separate roster tasks and runtime views

* fix(agents): apply persistent persona to task sessions

* feat(agents): connect identities tasks and sessions

* 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/sdk@1.1.0`
(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 <qwen-coder@alibabacloud.com>

* 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:
`jose@6.1.3` → `jose@6.2.12`, 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 <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmuiw1qih83

* 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): 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 <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>
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(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): 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 <qwen-coder@alibabacloud.com>

* 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

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

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

* 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 <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>

* 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).

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: yiliang114 <jinjing.zzj@gmail.com>
2026-09-29 16:04:14 +00:00
tanzhenxin
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 <qwen-coder@alibabacloud.com>

* 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 vitest@3.2.7 snapshot keyed with @vitest/browser and
supports-color@8.1.1. 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 <qwen-coder@alibabacloud.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <qwen-coder@alibabacloud.com>

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWe8PKNMpsKdQruTWkvFFc

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 05:50:24 +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
易良
703678136a
perf(cli): import core modules directly instead of the package root (#10957)
* perf(cli): let tests resolve core modules individually, and stop two files importing the whole package

Importing from the core package root pulls in its entire export graph — a bit
over six hundred modules — however little of it a file actually uses. In a
release run the cli workspace spent 2223s collecting modules against 1372s
running tests, and a file that imports the package root costs about 11.5s
before its first assertion where one importing a single module costs about 2s.

cli's tsconfig already maps a wildcard subpath onto core's sources, so esbuild
resolves per-module imports when it bundles. Vitest does not read tsconfig
paths, and the alias list that stands in for them named only four subpaths, so
those imports did not resolve under test at all. This adds the wildcard there.

Expressing the alias list as an ordered array is what allows a pattern entry.
The package root has to become an exact match in the process: as a string it
would also match everything beneath it and rewrite each subpath into a path
under index.ts.

Two files move to per-module imports as a first check that the mapping holds
end to end. Both were picked because nothing that depends on them replaces the
core package with a mock factory — where a test does that, the mock stops
intercepting once the code under test imports the module directly, so those
call sites need their mocks moved in the same change and are left alone here.

* fix(cli): restore the named core subpaths the previous commit dropped

The previous commit was assembled from a working copy that predated main by
several weeks, so it silently reverted this file to that older state. Four
named core subpaths added since — envVarResolver, noFollowOpen,
subSessionConstants and toolWriteOrigin — disappeared with it, and the new
wildcard then claimed those specifiers and pointed them at files that do not
exist. 257 test files failed to load as a result.

All eight named subpaths are restored and kept ahead of the wildcard, with a
comment saying why that order matters and what a contributor adding a ninth
has to do. None of the eight can be derived from its specifier, so none of
them can be folded into the pattern.

The two migrated source files are rebuilt on their current contents for the
same reason; one of them had also been reverted by a line.

* perf(cli): import core modules directly where no test mocks the package

Importing from the core package root evaluates its whole export graph — a bit
over six hundred modules — however little of it a file uses. On the release
lane the cli workspace spends more time collecting modules than running tests,
and on the main lane it now takes 84 minutes on its own, most of it collection.

These 130 files ask for named modules instead. They were chosen by checking,
for every test whose module graph reaches them, whether that test replaces the
core package with a mock: a test that swaps the package wholesale stops
intercepting once the code under test imports a module directly, and a test
that spreads the real package and overrides a few names only matters if one of
those names is what the file imports. Files with either kind of coupling are
left for a later change that moves the mocks at the same time.

Only import statements move; every other line is byte-identical.

* fix(cli): point ProviderModelConfig at the module that declares it, and wrap long imports

Two problems with the previous commit, both found by CI.

The symbol map resolved a re-exported name to the module that re-exports it
rather than the one that declares it, so `ProviderModelConfig` was asked of
`models/types` when it is declared in `providers/types`, and the build failed
to typecheck. A checker now confirms, for every generated specifier, that the
named module really does export that symbol — following its own re-exports —
and it reports one bad pair out of 492.

The formatting pass that was supposed to run over these files had silently
done nothing: invoked from the repository root against paths outside it,
Prettier skips the files and still reports success, so long import statements
went out unwrapped. Rerunning it properly reflows 47 files.

Two files are left with an over-long line Prettier would wrap, because that
line is over-long on the base commit too and the lint gate does not flag it;
reformatting it here would be unrelated noise. Every other line outside an
import statement stays byte-identical.

* perf(cli): import core modules directly in another 114 files

The same mechanical change as the previous commit, over the files a corrected
reading of the test suite showed were always safe to move.

The earlier pass classified a test as replacing the core package if the text
of such a call appeared anywhere in it, including inside a comment. One file
only mentions the pattern in a doc comment explaining why it deliberately
avoids it, and being counted as a blocker there ruled out 217 modules that
nothing actually blocks. Ignoring comments when detecting the call raises the
number of files movable without touching a single test from 141 to 260.

Every generated specifier is checked against the exports its named module
really has, following that module's own re-exports — 865 pairs here, none
wrong. Outside import statements every line is byte-identical.

* test(cli): move three barrel mocks onto the modules they actually stub

Where a test replaces the whole core package with a factory, the code under
test cannot move to per-module imports on its own: the mock would stop
intercepting and the real implementation would load instead, quietly changing
what the test exercises while leaving it green. The mock has to move in the
same commit.

These three are the cases where that is unambiguous — every name the factory
stubs is declared in one module, and the code under test imports exactly those
names. Each pair moves together onto that module.

The pattern generalises: about sixty tests each hold back one or two modules
this way, and roughly ninety more modules are held by several tests at once
and need them changed together. Establishing the shape on the clean cases
first keeps the ambiguous ones honest.

* Revert the migrated modules that any failing suite depends on

Retargeting this PR at main got the unit suite to run for the first time, and
it failed: 16 files, 127 tests, none of them failing on the jsdom PR that
shares the same base. So they are this stack's doing.

Every one is the same shape — a stub the suite installs no longer intercepts
once the code under test names a module instead of the package root. The
static analysis that picked these files models three ways of installing such a
stub and misses at least two more: a spy planted on a namespace import of the
package, and whatever six of the sixteen suites do, which it cannot parse at
all. Sharpening the heuristic further is not the answer; it was already wrong
in a way no amount of local reading would have caught.

So this restores every migrated module that any failing suite reaches, 146 of
them, and keeps the 110 that nothing failing depends on. That is blunt — some
of the 146 are certainly fine — but it is the version that can be shown to
pass, and picking the survivors apart is work for a run that is green to begin
with.

* fix(core): let consumers outside the cli package resolve a core module by path

The integration gate failed to compile against the migrated files:

  error TS2307: Cannot find module '@qwen-code/qwen-code-core/utils/debugLogger.js'
  error TS2307: Cannot find module '@qwen-code/qwen-code-core/utils/editor.js'

Only packages/cli maps these specifiers, through a wildcard in its own
tsconfig. The integration suite lists the eight named subpaths and no
wildcard, and the package's exports map has entries for those same eight plus
the dist and src trees — so anything resolving the normal way, this suite and
any consumer of the published package alike, cannot name a core module.

Both gaps close here: the wildcard is added to the integration suite's path
mappings, and a catch-all maps a bare module path onto the build output. The
catch-all exposes nothing new; `./dist/*` already reaches the same files.

This is the part of the change with consequences beyond the test run. The
shipped CLI is a single bundle and never resolves these specifiers at runtime,
but the package is published, and until now a migrated import was only
resolvable from inside the one workspace that happens to map it.

* fix(cli): repair the mock pairs the revert split, and keep dev on source

Three findings from review, all of them consequences of earlier steps here.

Two mock pairs were left half-migrated. In one, the mock and the code under
test both moved to the module, but the suite's own import of the same two
functions still read the package root — so the suite held the real functions
while the code held the stubs. In the other, the revert restored the code to
the package root and left the mock pointing at a module nothing imports any
more, which stubs nothing at all. The first is completed, the second put back.

The exports catch-all also changed what `npm run dev` runs. Its loader
intercepts the exact package root and nothing else, so subpath imports fall
through — and where they used to fail to resolve, they now quietly reach
compiled output from whenever the tree was last built. The loader now redirects
them to the source tree when the source file is there, leaving the named
subpath exports, whose file names do not mirror their specifiers, to resolve
as before.

* Cover the exports catch-all, and stop it hiding from the architecture rule

Three review suggestions, all about the `./*` entry this branch added to core's
exports and the things that quietly depend on it.

Nothing exercised that entry. In the repo, cli's subpath imports resolve
through tsconfig paths or vitest aliases; in the published package neither
exists and Node resolves them against `exports`. So removing the entry, or
renaming the dist root, would leave every suite green and break `qwen` on its
first core subpath import. A new check runs Node's own resolver against the
built package — deleting the entry turns it red, which is the property that
makes it worth having.

The entry also widened what the utils-layer rule has to police. It resolved
self-references by exact key only, so a deep specifier like
`.../config/storage.js` matched nothing, returned early, and reported nothing —
while resolving perfectly at runtime. It now follows Node's own order: exact
keys first, then the longest literal prefix among patterns. Fixtures cover the
wildcard idiom, exact-key precedence, and a sibling utils import that must
still be allowed.

Finally the integration suite's path mappings. The guidance comment above them
said a bare wildcard falls through to dist, which stopped being true when the
wildcard was added — nodenext substitutes `.js` for `.ts`, and the program
resolves core subpaths through it to source today. Left alone, a maintainer
following that comment would delete the wildcard as a violation and silently
re-route live imports to dist declarations. The comment now describes what the
block does, and says why the six alias-style entries cannot be folded into the
pattern. Two entries that had drifted out of sync with core's exports map,
toolWriteOrigin and envVarResolver, are restored.

* fix(cli): point the session-picker branch mock at the module it imports

Review finding R1-4b. StandaloneSessionPicker now takes getGitBranch from
@qwen-code/qwen-code-core/utils/gitUtils.js, but the colocated suite kept
mocking the package root, so the override no longer intercepted anything the
picker resolves and the factory's importActual still pulled core's whole
index into the suite's module graph. The mock now names the same specifier
the component imports, and a small pin test goes red if the pair splits
again.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtme4yqxhl

* fix(dev): preserve source-backed core entry paths

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

* fix(scripts): close core subpath verification gaps

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

* fix(scripts): gate dep-only core subpath exports in the check

The exports check collected specifiers only from packages/cli/src, but
acp-bridge and sdk-typescript are runtime dependencies of the cli whose
compiled dist keeps core subpath specifiers verbatim. Entries named only
by those packages (./goalWire, ./transcriptRecords, ./subSessionConstants)
were therefore ungated: deleting one kept every gate green while
`node packages/cli` died with ERR_MODULE_NOT_FOUND on the serve/replay
path. Extend the source scan to packages/acp-bridge/src and
packages/sdk-typescript/src so all 95 collected specifiers resolve
through the exports map. Verified: removing ./goalWire or
./transcriptRecords from packages/core/package.json now makes the check
exit 1 (entries restored after the mutation probe).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtmxfessij

* docs(harness): note terminal-capture loader is manually exercised

No CI test executes the terminal-capture harness loader's subpath
redirect; it runs manually only via `npm run test:terminal-bench`.
Record that at the redirect site and point at
scripts/check-core-subpath-exports.mjs as the CI gate for the real
resolution path, so the `named` map stays consciously in sync with the
named entries in packages/core/package.json.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtmxfessij

* fix(scripts): reject core subpath targets outside the published dist

The exports check validated resolved targets against the repo tree, but
core's exports map also carries "./src/*": "./src/*" while package.json
publishes only dist, vendor and scripts/postinstall.js. A specifier
routed through that entry resolved to a real in-repo packages/core/src
file, passed the bare existsSync and exited 0 even though the published
artifact ships nothing — the installed CLI would die at startup with
ERR_MODULE_NOT_FOUND while the gate stayed green. Require the resolved
target to lie inside packages/core/dist/, where every legitimate
runtime specifier lands.

Add scripts/tests/check-core-subpath-exports.test.js: a fixture-tree
suite (per the scripts/tests convention) that runs a copy of the real
script in a temp workspace. It pins the dep-only scan from a18ffebb8
(goalWire named by no cli source resolves; removing its exports entry
exits 1 — the mutation witness requested in review, made hermetic) and
pins this guard (a ./src/*-routed specifier is reported as not
published; removing the guard turns that test red).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtn1pqbuir

* fix(scripts): allow published core package.json in subpath gate, cover probe stages

The round-4 dist-containment guard rejected
@qwen-code/qwen-code-core/package.json, which core's exports map
deliberately publishes ("./package.json": "./package.json") and npm ships
regardless of "files". Allow that one target; ./src/* targets stay
rejected (existing test remains red without the guard).

Also extend the fixture suite to the two failure stages it did not
witness: the named-export probe stage (target exists but lacks the probed
export) and the resolve-failure branch (exports map without the ./*
catch-all). Mutant-verified: removing the probe import/export-name check
or the resolve-catch failed++ turns the matching new test red and nothing
else.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtngpuscjl

* fix(terminal-capture): add conversationsRuntimeMarker to harness loader map

The loader's named map listed 8 of the 9 named entries in core's exports
map: conversationsRuntimeMarker (source at utils/conversations-runtime-marker.ts,
a path the specifier does not mirror) was missing. The specifier is
reachable in the harness graph today via shared-env-keys.ts, so resolution
fell through to the exports map — ERR_MODULE_NOT_FOUND mid-capture on a
fresh clone, or a stale dist silently mixed into a source-backed capture
otherwise. Add the entry, matching packages/core/package.json and the cli
vitest alias.

Also add a sync guard (scripts/tests) asserting every named key of core's
exports map has a loader entry pointing at an existing core source file,
so the next omitted entry goes red in CI.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtngpuscjl

* fix(integration-tests): map conversationsRuntimeMarker to core source in tsconfig paths

The ninth named exports entry was missing from the paths block, so
`@qwen-code/qwen-code-core/conversationsRuntimeMarker` (imported by
packages/cli/src/config/shared-env-keys.ts) fell through the wildcard to
a nonexistent substitution and resolved against packages/core/dist — or
failed with TS2307 on an unbuilt tree — the exact stale-dist mode this
block's comment warns about. Add a sync gate asserting every named
exports key whose target stem differs from the key has a paths entry
pointing at the matching source file.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtntktgsk8

* test(scripts): assert loader map values and reverse key-set against core exports

The harness loader sync gate only checked key coverage (exports key ->
loader entry) and file existence, so two drifts passed it green:
a retargeted exports entry left the loader serving the old module (the
named map short-circuits ahead of the stem probe), and a stale
loader-only entry short-circuits captures on a module graph the shipped
package can no longer resolve. Assert each loader entry equals the
exports import target mapped into source space, and every loader entry
has a matching exports key. Mutating either direction now fails the
suite.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtntktgsk8

* test(scripts): make the exact-key-precedence fixture verdict-sensitive

The rule verdicts by directory layer and never checks file existence, so
the goalWire fixture reported one violation whether the exact exports
key or the ./* wildcard resolved it — removing the exact-key branch of
resolveExportTarget left the test green. Switch the fixture to
transcriptRecords, whose exact resolution lands inside utils/ (allowed)
while the wildcard resolution lands outside (violation), so dropping the
exact-key branch now flips the assertion from 0 to 1.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtntktgsk8

* docs(cli): correct the subpath-alias and mock-wiring comments

packages/cli/vitest.config.ts named the cli tsconfig `paths` block as the
trigger for adding a named core subpath alias, but only three of the nine
entries (noFollowOpen, subSessionConstants, transcriptRecords) appear there;
the source of truth is the named keys of the `exports` map in
packages/core/package.json. The comment now says so, and records that this
map — unlike the skill-review-harness loader's, which
scripts/tests/text-capture-core-loader-sync.test.js checks against core's
exports — has no gate.

StandaloneSessionPicker.test.tsx's wiring test claimed to pin the wrapper's
import, but StandaloneSessionPicker.tsx is not in that suite's module graph:
the tests render SessionPicker, which takes `currentBranch` as a prop, so the
stub's only consumer is the test file's own import. Renamed the test and
corrected both comments to state what it pins and what it does not cover.

Comment and test-name only; no behavior change.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmto4amd1ku

* ci: pin the new core subpath exports step in the lint lane payload

`Check core subpath exports resolve` was added to the lint_and_static job
without being added to the pin that asserts that job's full-gated payload by
name and order, so the guard failed with 19 received against 18 expected.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtosl3ualt

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
2026-09-06 11:21:50 +00:00
易良
49e3ef6269
perf(cli): let tests resolve core modules individually (#10917)
* perf(cli): let tests resolve core modules individually, and stop two files importing the whole package

Importing from the core package root pulls in its entire export graph — a bit
over six hundred modules — however little of it a file actually uses. In a
release run the cli workspace spent 2223s collecting modules against 1372s
running tests, and a file that imports the package root costs about 11.5s
before its first assertion where one importing a single module costs about 2s.

cli's tsconfig already maps a wildcard subpath onto core's sources, so esbuild
resolves per-module imports when it bundles. Vitest does not read tsconfig
paths, and the alias list that stands in for them named only four subpaths, so
those imports did not resolve under test at all. This adds the wildcard there.

Expressing the alias list as an ordered array is what allows a pattern entry.
The package root has to become an exact match in the process: as a string it
would also match everything beneath it and rewrite each subpath into a path
under index.ts.

Two files move to per-module imports as a first check that the mapping holds
end to end. Both were picked because nothing that depends on them replaces the
core package with a mock factory — where a test does that, the mock stops
intercepting once the code under test imports the module directly, so those
call sites need their mocks moved in the same change and are left alone here.

* fix(cli): restore the named core subpaths the previous commit dropped

The previous commit was assembled from a working copy that predated main by
several weeks, so it silently reverted this file to that older state. Four
named core subpaths added since — envVarResolver, noFollowOpen,
subSessionConstants and toolWriteOrigin — disappeared with it, and the new
wildcard then claimed those specifiers and pointed them at files that do not
exist. 257 test files failed to load as a result.

All eight named subpaths are restored and kept ahead of the wildcard, with a
comment saying why that order matters and what a contributor adding a ninth
has to do. None of the eight can be derived from its specifier, so none of
them can be folded into the pattern.

The two migrated source files are rebuilt on their current contents for the
same reason; one of them had also been reverted by a line.

* fix(cli): name the migrated core imports so plain Node resolves them

The tipHistory / RemoteInputWatcher migration used .js-suffixed subpath
specifiers that match no entry in packages/core/package.json exports, so
the built-but-unbundled CLI (npm start / build-and-start, whose tsc dist
keeps specifiers) crashed at module load with ERR_PACKAGE_PATH_NOT_EXPORTED
while typecheck (tsconfig paths), unit tests (vitest wildcard alias) and the
bundle (esbuild paths) all bypassed exports and stayed green. Switch the two
files to named subpaths (storage, atomicFileWrite, debugLogger), following
the convention of the eight existing entries, and add the matching exports
entries and vitest aliases.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq

* test(scripts): guard core subpath exports resolution under plain Node

Every core subpath specifier statically imported from packages/cli/src must
resolve through packages/core/package.json exports in a real child node
process — no vitest aliases, no tsconfig paths. Without the matching exports
entries the built-but-unbundled CLI dies with ERR_PACKAGE_PATH_NOT_EXPORTED
while every gate that bypasses exports stays green; this guard goes red the
moment an entry is removed.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq

* fix(cli): name every core subpath import in cli tsconfig paths

The named subpaths cli sources import from @qwen-code/qwen-code-core had
no named `paths` entries, so the wildcard composed nonexistent files and
esbuild (bundle) plus the dev loader chain fell back to the exports map,
loading packages/core/dist copies while every package-root import loads
packages/core/src — two instances of barrel-exported, stateful modules
(debugLogger, storage, atomicFileWrite, envVarResolver, toolWriteOrigin,
memoryScopes) in one process. Add the six missing named entries beside the
existing ones so bundle and dev resolve all subpaths into the core src
tree, consistent with the vitest alias list.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtme4yqxhl

* test(scripts): pin subpath exports targets and guard bundle resolution

Complete the resolution guard and harden its probe:

- Cover every core subpath statically imported from packages/cli/src
  (adds toolWriteOrigin and memoryScopes) plus the subpaths npm start
  reaches through @qwen-code/acp-bridge (subSessionConstants, goalWire,
  transcriptRecords), instead of the previous five specifiers.
- Pin each specifier to its expected dist target: assert the resolved
  URL equals the pinned path and the target file exists, so a typo'd or
  redirected exports target fails the guard (import.meta.resolve alone
  accepts both). This makes a built core dist a prerequisite, which
  vitest-global-setup already fail-fasts on.
- Add a bundle-resolution guard: esbuild-bundle every cli/src core
  subpath under packages/cli/tsconfig.json and assert no input comes
  from packages/core/dist, pinning the tsconfig paths entries.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtme4yqxhl

* fix: cover goalWire in the core-subpath guard and make it OS-independent

- Add goalWire to the bundle arm and named tsconfig paths entries in
  packages/cli and packages/acp-bridge: acp-bridge's transcript-replay
  imports @qwen-code/qwen-code-core/goalWire, and without a named entry
  the wildcard resolved to a nonexistent ../core/src/goalWire and fell
  back to the exports map, bundling packages/core/dist/src/goals/
  goal-wire.js next to src-resolved core modules — the module-identity
  split #10908's Known risks name. Verified with an esbuild metafile
  probe: dist input before the paths entry, src input after.
- Normalize esbuild metafile input keys to forward slashes before the
  dist-leak filter and src-target assertions so the guard behaves the
  same on Windows runners, where esbuild emits backslash separators.
- Correct the header: this lane's vitest config does not wire
  scripts/vitest-global-setup.js (it is a globalSetup only in the
  packages/core and packages/cli configs, and its DIST_PREREQUISITES
  has no key covering this lane), so a missing dist surfaces as the
  existence assertion naming the absent file.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtmourmvi4

* test: guard acp-bridge-routed core subpaths in the bundle arm

The bundle arm seeded goalWire into the cli-routed buildSync case, which
passes an explicit packages/cli/tsconfig.json — but the shipped bundle
resolves that import under packages/acp-bridge/tsconfig.json (mainBuild in
esbuild.config.js carries no tsconfig option, so esbuild discovers the
nearest tsconfig per importing file, and goalWire is imported only from
packages/acp-bridge/src/transcript-replay.ts). The acp-bridge goalWire
paths entry was therefore guarded by no arm, and transcriptRecords /
subSessionConstants were probed by none at all: removing the acp-bridge
goalWire entry left the test green while a production-shaped build pulled
packages/core/dist inputs.

Add an acp-bridge-routed buildSync case — no tsconfig option, resolveDir
packages/acp-bridge/src, seeding goalWire, transcriptRecords,
subSessionConstants and noFollowOpen — asserting no input lands under
packages/core/dist and each expected core src target is present. With the
new arm, removing the acp-bridge goalWire entry goes red (4 dist leaks)
where previously nothing did.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtmxfessij

* fix(cli): guard the conversationsRuntimeMarker subpath route

R6-1: @qwen-code/qwen-code-core/conversationsRuntimeMarker is
statically imported from packages/cli/src (config/shared-env-keys.ts,
serve/run-qwen-serve.ts) but carried no named entry in
packages/cli/tsconfig.json paths and was seeded into neither guard
map. The wildcard composed a nonexistent ../core/src target and fell
back to the exports map, so the cli-routed bundle loaded a
packages/core/dist copy next to the core src copy while every guard
arm stayed green. Add the named paths entry beside the ones this PR
already adds and seed both guard maps with the specifier.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtngpuscjl

* fix(sdk): guard the sdk-routed core subpath resolution

R6-2: the cli bundle reaches sdk sources through the cli tsconfig
@qwen-code/sdk/* mapping (ui/utils/export/export-transcript-document.ts
imports @qwen-code/sdk/daemon/transcript, which re-exports from
daemon/ui/chat-record-transcript.ts), and chat-record-transcript.ts
imports @qwen-code/qwen-code-core/transcriptRecords. With no paths in
packages/sdk-typescript/tsconfig.json, mainBuild's per-importing-file
tsconfig discovery found no mapping and fell back to the exports map,
bundling a packages/core/dist copy while the guard suite stayed green.
Add the named entry to the sdk tsconfig, mirroring acp-bridge, and a
third guard arm probing the sdk route.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtngpuscjl

* fix(sdk): reference core from the sdk composite reference project

The paths entry added for the sdk-routed guard resolves
@qwen-code/qwen-code-core/transcriptRecords to the core source file,
which in the composite tsc --build graph belongs to the core project.
Without a project reference the cli build failed with TS6059/TS6307;
declare the core reference, mirroring packages/acp-bridge/tsconfig.json.
Verified with npm run build in packages/cli and npm run typecheck in
packages/sdk-typescript.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtngpuscjl

* fix(sdk): keep the declaration build on the exports map

The paths entry added to tsconfig.json for the bundle's esbuild
discovery is inherited by tsconfig.build.json; in that plain
declaration build it pulled core sources into the program, re-rooted
the inferred rootDir above the package, and nested every emitted .d.ts
under dist/sdk-typescript/src/, dropping dist/daemon/index.d.ts that
web-shell imports (TS7016). Reset paths in tsconfig.build.json so the
declaration build resolves core through its exports map. Verified with
npm run build in packages/sdk-typescript: dist/daemon/index.d.ts
restored and the daemon browser bundle byte-identical (236252 bytes,
its pre-existing warning threshold breach is unchanged).

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtngpuscjl

* fix(scripts): route core subpaths to src in the dev loader

The dev loader intercepted only the exact package root, so a named core
subpath fell through to the package's exports map and loaded
packages/core/dist while every package-root import loaded
packages/core/src. One dev process then held two instances of the same
module: Config binds the debug session on the src copy
(setDebugLogSession(this)), so RemoteInputWatcher's dist-copy
REMOTE_INPUT logger read an empty session and every debugLogger(...) call
there silently no-oped with QWEN_DEBUG_LOG_FILE enabled. Storage split
its static state the same way.

Derive the interception map from the core exports map so every named
subpath short-circuits to its packages/core/src file and stays covered as
subpaths are added. The exports entries (built-but-unbundled CLI) and the
tsconfig paths entries (bundle lane) are untouched.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtow5pgslz

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
2026-09-06 10:20:05 +00:00
Shaojin Wen
25bef232e1
fix(review): report what the transcripts prove; build the roster in one call (#7033)
* fix(review): name a rewritten launch as itself, and leave nothing to hand-assemble

Dogfooded on a real 3A review of a live PR, and the run talked its way past the gate:

  compose-review printed: Verdict: Comment — an Approve was NOT available: a
  dimension nobody reviewed

  the run's next thought: "the compose-review flagged reverse audit as unreviewed
  (transcript visibility issue — the reverse audit did run substantively with two
  dry rounds). Let me proceed."

  the run then reported, and saved: Verdict: Approve

The gap was right and its wording was wrong, and the wording is what let the run
dismiss it. Two auditors HAD run — 16 and 23 tool calls each — and both HAD opened
their brief. What had actually happened is that the orchestrator skipped `--findings`
and hand-wrote their launches, keeping only the brief pointer, so no agent was
launched with the prompt the CLI built. The gap said "no agent was launched with it
that opened its brief", which is false as written, and "a transcript visibility
issue" is what a reader concludes from a message that does not describe what
happened.

So the floor tells the four shapes apart instead of collapsing them into one
boolean, and each says what happened and what to do:

  not-built     — the step was skipped; `agent-prompt --role <r>` never ran
  not-launched  — the prompt was built and nothing was launched with it
  rewritten     — an agent ran and opened its brief, but no agent got the built
                  prompt: the launch was written by hand instead of pasted
  brief-unread  — an agent got the built prompt and never opened the brief

`rewritten` is the one that just happened, and it is now un-dismissable: it concedes
the agent ran and read its brief, and names the orchestrator's own edit as the defect.

And the path that produced it is gone: `--findings` is now REQUIRED for a role that
takes findings. There is no bare-block-plus-hand-assembly path left — the command
refuses, and prints one block to paste. An early reverse-audit round with nothing
confirmed yet passes an empty file, which the command renders as "Nothing is
confirmed yet".

SKILL: Step 4/5 say `--findings` is required. Step 6 gains the second half of the
lesson — you may not overrule the line compose-review gives you; a cap you can
explain is still a cap, and the fix is to make the step verifiable and re-run, not to
keep the verdict you preferred. Step 8's report interpolates the verdict out of the
composed JSON (`jq -r .event`) instead of typing it, because the terminal is prose
and the archive is forever.

* fix(review): call the CLI that is running, not whatever `qwen` PATH finds

Reported from a real session: `npm run dev:daemon`, `/review 6998 --comment` in the
web shell, and the run died on

  Missing required argument: chunk

with a help screen for a command that has no `--role` at all. The daemon was running
the checkout — the skill it loaded is the current one, and says `--role 0` — but the
skill shells out to `qwen review agent-prompt …`, and `qwen` on that machine is
`/usr/bin/qwen` → a v0.19.10 global install whose `agent-prompt` predates #6892
entirely. The skill and the CLI it was talking to were different programs.

The skill assumed `qwen` on PATH is the build running it. That holds for a single
install and breaks for exactly the people most likely to run a dev daemon. It is also
invisible when it breaks: the error names an argument, not a version.

So the entry is passed down instead of rediscovered. `scripts/cli-entry.js` is the
executable entry and the one thing that knows its own path, so it publishes it as
`QWEN_CODE_CLI` (`||=`, so the relaunch into dist/cli.js keeps pointing callers back
at the wrapper with the shebang, not at itself). `daemon-dev.js` sets it too — the dev
daemon is started as `node scripts/dev.js` and never passes through the wrapper, which
is why this bit there first. `getShellContextEnvVars` passes it to every shell
subprocess, beside the session and project-dir vars that are already handed down for
the same reason. The skill's 23 command sites now read `"${QWEN_CODE_CLI:-qwen}"
review …`; the fallback keeps hosts that do not export it on the old behaviour.

PATH was the other candidate and was rejected: prepending a shim dir means writing an
executable at spawn time and overriding `PATH` in an env that
`normalizePathEnvForWindows` has already normalised — a `Path`/`PATH` collision on
Windows in exchange for saving one variable.

The env var is isolated in `shellContextEnv.test.ts` the way the session id already
is: the CLI now exports it to every shell it spawns, so `npm test` run from inside a
qwen session inherits it, and the exact-equality assertion would have failed on a
variable the test never set. Verified by running the suite with it set.

* fix(review): point the dev daemon's CLI at the source it is running, not dist

Verifying the previous commit on a real `npm run dev:daemon` caught it doing a
smaller version of the bug it fixes. The daemon runs the TypeScript **source**
through tsx; `cli-entry.js` runs `dist/cli.js`. Pointing QWEN_CODE_CLI there traded
"the subprocess is a whole major version behind" for "the subprocess is however
stale the last build was" — measured on the box that reported this, dist was **105
source files** behind the daemon. Same bug, smaller hat.

`scripts/dev.js` is the entry that runs what the daemon itself runs, so the dev
daemon points there. It gains a shebang and the exec bit, which is what lets a
caller invoke it as `"${QWEN_CODE_CLI}" review …` without knowing it needs node —
the same shape `cli-entry.js` already has for the published path.

Verified end to end on a headless box: started the dev daemon, read
/proc/<pid>/environ (QWEN_CODE_CLI=<repo>/scripts/dev.js, -rwxr-xr-x), and ran the
command that started this whole thread. Before: `Missing required argument: chunk`.
After: `agent-prompt: --role 0 needs a plan with prNumber and ownerRepo` — the role
is understood, and the complaint is about the fixture, which is the correct answer.

* fix(review): say what a missing brief proves, once, to the reader who can act on it

A role with no recorded prompt proves one thing: the brief never reached an
agent. The roster check claimed more than that — "no prompt was built for it
(`agent-prompt --role 0` never ran)" — and on #7012 it said that about all
twelve dimensions of a review that had just posted two Criticals with line
numbers. The agents were in the same comment the gate was calling empty.

Both failures are real and neither is the other. An orchestrator that writes the
launch by hand gets an agent that runs, reads the diff and finds things, having
never seen the severity bar, the finding format or this project's rules — all of
which live in the brief it was never given. That is worth blocking on. It is not
"nobody looked", and a check may not report the reading it cannot see.

Three changes, one shape:

- The per-role text says the brief never reached an agent, and that the
  dimension was reviewed "if at all" from a prompt the run wrote for itself.
  It no longer speaks for the agent's existence.
- Every role briefless collapses to one line. It is one failure — the run did
  not use the prompt builder — and saying it twelve times buries the fact that
  explains all twelve.
- The public body drops the internal command. `agent-prompt --role 2` is not
  something a PR author can run; on #7012 fourteen lines of it were the whole
  CHANGES_REQUESTED while the findings sat inline below the fold. The call
  survives in check-coverage's stderr, where the orchestrator reads it, and the
  role number is already in each label.

check-coverage no longer leads with a count: the collapsed line covers the whole
roster, so "1 required brief" would undercount it by the size of the review.

Behaviour is unchanged — the gate fires on exactly the same runs and still caps
the verdict. Only the sentence changes, and only where it was overclaiming or
talking to the wrong reader.

* fix(review): name the directory the missing briefs were missing from

"The prompt builder never ran" and "the prompt builder ran against a different
--plan" arrive at this check as the same thing — an absent file — and they are
fixed differently. Nothing in the error told them apart.

The record directory hangs off the plan path as given, so a relative --plan
resolves against the caller's cwd, and the skill runs Steps 2-6 from inside the
worktree it just created. Two cwds, one relative path, two directories. Proven
locally: the same `--plan .qwen/tmp/p.json` from a repo root and from a worktree
under it yields two record dirs.

That is not a reason to resolve the path differently — resolving a relative path
against the cwd is what a relative path means, and the mismatch mostly fails
loudly, because the plan is not in the worktree either and the read errors. It
is a reason to print where it looked. One line, on stderr, where the
orchestrator reads it; the PR author gets no path to a temp directory.

* feat(review): build the whole roster in one call, because compliance decays per call

The launch prompts are already small — a role line, the brief pointer, the diff
reads — and it did not save the run that stopped building them. Dogfooded on one
PR, the same environment went from a clean review to "no prompt was built for
any of twelve roles" over three reviews in a day. The per-agent form asks the
orchestrator for ~30 build-then-launch round trips on a large review, and that
is a compliance cost paid per agent, per review, forever; what decays under
repetition eventually decayed.

`agent-prompt --roster` builds every prompt the plan requires — chunk agents,
dimension agents, invariants — in one call: one labelled block per agent, each
recorded under the key `check-coverage` will look it up by. The list is
`requiredAgents(plan)`, the same list the coverage gate reads, so what gets
built is exactly what gets checked; a key the two derive differently is refused
at build time rather than surfacing later as "brief never reached an agent" on
a compliant run.

The blocks are separated by lines that are visibly not prompt text, and a block
copied lazily — separator included — still passes the add-only delivery check.
That is load-bearing: if honest-but-sloppy copying read as a rewrite, the gate
would punish exactly the behaviour this call exists to buy.

The per-agent forms stay, for rebuilding a single prompt after Step 3D names a
gap. Step 4/5 verify and reverse-audit are untouched: they are built per round,
with the findings folded in.

SKILL.md's Step 3A and 3B now ask for the roster once instead of one call per
agent, and check-coverage's missing-brief error names the one-call fix first.

* fix(review): close the review's five consistency gaps in the CLI-pinning story

Review feedback on this PR found five places where the fix stopped short of its
own thesis. All five, addressed:

1. Four copyable SKILL.md commands had missed the QWEN_CODE_CLI sweep —
   `pr-context` (lightweight mode), `cleanup` (cache hit), `capture-local
   --file` (file-path reviews), `agent-prompt --whole-diff` (Agent 8). On a
   skewed host those modes died exactly the way the motivating run did. All
   four now carry the prefix; the remaining bare mentions are prose.

2. check-coverage's own stderr recommended recovery with a bare `qwen` — the
   message is the interface the orchestrator acts on, and on a skewed host the
   recommended recovery reproduced the skew. All four recommendation sites now
   print the prefixed form, and the rebuild hint covers `--chunk <id>`, which a
   missing chunk agent needs and `--role` cannot express.

3. Ambient inheritance could silently re-point an entry at another session's
   CLI. A dev daemon started from inside another qwen session's shell — the
   usual dogfooding flow — inherited that session's QWEN_CODE_CLI through
   `??`/`||=` and called the OUTER build: the same skew, one level up, and
   silent. Every entry now stamps itself unconditionally; nested sessions each
   call their own build. The `||=` comment in cli-entry.js also claimed a
   relaunch hazard that does not exist (the relaunch child runs dist/cli.js and
   never re-executes the wrapper) — the comment now states the real reason.

4. The third dogfooding entry point was still unpinned: `npm run dev` and
   `npm start` published nothing, so a /review from a plain dev TUI fell back
   to PATH. `scripts/dev.js` now stamps the variable in the env it spawns with
   — which also covers the daemon, since daemon-dev launches serve through it,
   and the daemon's own deferring copy is gone (one writer, not two).
   `scripts/start.js` does the same and gains the shebang and exec bit that
   make it callable as the entry it now names.

5. `"${VAR:-fallback}"` is POSIX parameter expansion, which cmd.exe passes
   through literally and PowerShell rejects. The skill was already POSIX-bound
   (Step 0 pipes through `tee`); the requirement is now total, and SKILL.md
   says so where the variable is introduced: on Windows, run the review from
   git-bash.

The unconditional stamp is pinned by a test that inherits a foreign
QWEN_CODE_CLI and asserts the spawned child gets this checkout's dev.js;
flipping the assignment back to `??` turns exactly that test red.

* fix(review): finish the two-register split, and pin the last unpinned entry

Round-2 review feedback: five more places where this PR's own rules were not
yet applied to itself.

The Agent 7 brief handed its subagent a bare `qwen`. Its two fenced command
blocks (`build-test`, `test-efficacy`) are the one call site where a SUBAGENT
shells out to the review CLI — reachable by neither the SKILL.md sweep nor the
stderr hints. Its shell gets QWEN_CODE_CLI exactly as the orchestrator's does,
so the standard prefix works verbatim; without it, an old PATH global likely
lacks these subcommands entirely, wedging the agent between its mandate (no
hand-run builds) and a command that does not exist. A test now rejects any
line-initial bare `qwen review` in that brief.

The Step 4/5 gap texts and the blind-agent line carried remediation commands
into the posted body — the register §4 stripped from missingRoles, surviving
in the sibling paths, and partly ADDED by this PR (the rewritten texts). Each
gap is now two sentences for two readers: `gap` (author-facing, no internal
commands, rendered under `Not reviewed:`) and `fix` (orchestrator-facing,
printed by compose-review to stderr as `FIX:` lines, carried on the result as
`remediation`). The four-shape precision is intact — it moved channels, not
content — and tests pin both directions: the body may not contain
`agent-prompt`/`--findings`, and the remediation must.

Pinning start.js exposed a stdout contamination: check-build-status.js printed
"Checking build status..." to stdout ahead of every child, and start.js is now
an entry whose stdout callers consume — `review parse-args --stdin | tee`
would write a plan file whose first line is not JSON. The checker's status
lines go to stderr with its warnings; `./scripts/start.js --version` now emits
the version alone.

Also from review: the all-briefless hint no longer points at role labels the
collapsed line does not carry, and start.js's stamp gets the same test dev.js
has — inherit a foreign QWEN_CODE_CLI, assert the spawned child gets this
checkout's entry.

* fix(review): isolate the env var this PR exports, and give every gap its FIX

Two findings from the bot review of the previous commit.

The shellContextEnv suite isolated QWEN_CODE_SESSION_ID and QWEN_CODE_CLI but
not QWEN_CODE_PROJECT_DIR — the third variable the CLI exports to every shell,
and the one this suite's own per-session tests assign without cleanup.
Reproduced: run the suite with it set, as any `npm test` from inside a qwen
session does, and exactly the two `.toEqual()` exact-match tests fail on a key
the test never set. Same isolation, same shape, and it retires the in-file
leak too.

The remediation channel covered blind agents and the Step 4/5 gaps and stopped
there: missing briefs, rewritten launches, unread briefs and never-opened
diffs still reached the body with no FIX line beside them. A body disclosure
with no repair command is how #7012's orchestrator got to "the agents clearly
did their job" — the whole reason the channel exists. Each category now pushes
one remediation line (missing briefs point at `--roster`; the relaunch-shaped
ones say relaunch with the same printed prompt), and a test pins the pair for
a roster gap: the body says "brief never reached an agent" with no command in
it, and the remediation names the roster call.

* fix(review): retire the last two overclaims the round-3 review found

Two sentences, same class, both this branch's own thesis applied to itself.

A chunk agent that ran on a hand-written prompt while its chunk was never
built landed in the body as "no prompt was built for it (`agent-prompt` never
ran for this chunk)" — an internal command on the author-facing surface, one
line per chunk on a 3B replay of the #7012 shape. The label now says what
happened in the author's register (ran on a prompt the run wrote itself; the
brief never reached it); the rebuild command already rides the
rewritten-launches remediation line on stderr.

And the Step 4/5 `not-built` texts still said "no auditor ran" / "no verifier
ran" — the one residual of the overclaim this branch exists to retire.
`not-built` is decided before the transcripts are consulted: a run that
skipped the builder and hand-wrote the launch leaves no brief on disk whose
open could be looked for, so such an auditor is invisible to the check, and
"no auditor ran" claims sight it does not have. Both texts now use the roster
wording: what a missing record proves (no agent was launched with a prompt
this skill builds), then what it costs ("ran, if at all, without the method
its brief carries"). The Delivery docstring records why.

Tests pin the new sentences positively and negatively; the register pin
(no `agent-prompt`/`--chunk` in a body label) guards the first one.

* test(review): make the every-gap-has-a-FIX claim true, and pin the partial stderr shape

Round-5 review caught a test whose title outran its body: "every coverage gap
… has a FIX" exercised only the missing-roles path, so dropping the
remediation push for unread briefs — or rewritten launches, or never-opened
diffs — failed nothing. That is the exact disclosure-without-repair state the
channel exists to prevent, asserted by a test that could not see it.

The title now claims what the test covers, and a sibling test covers the rest:
one plan, three defects — a chunk agent on a hand-written prompt, one that
never opened its brief, one that never opened the diff — asserting each
category's FIX line and that none of the three drags a command into the body.
Between the blind-agent test, the missing-roles test and this one, every
category that discloses is now asserted to repair; mutation-checked by
deleting each push in turn, one red test each.

Also from the review: the missing-briefs stderr had handler coverage only for
the all-briefless collapse. The partial shape — one role missing, the rest
briefed — reached stderr through no test, so a formatting regression there
(a broken join, a lost --roster hint, a garbled Looked-in path) would ship
unseen. A second handler test pins it: the per-role detail, the rebuild
hints, and the record-dir line, with the collapse text asserted absent.

* fix(review): close the round-5 findings — entry contracts, gap reach, repair loops

A GPT-5 review pass filed twenty-eight findings against this branch. Nineteen
were real and are fixed here; two were refuted with evidence (the scripts test
suite IS in CI: `test:ci` runs `npm run test:scripts`); the rest are recorded
follow-ups of documented floor designs.

Entry contracts. The standalone package launches through a shim that carries
the bundled Node and announces itself via QWEN_CODE_LAUNCHER_PATH — stamping
cli-entry.js there handed subprocesses a `#!/usr/bin/env node` script on hosts
that may have no system Node; the shim is now preferred, with a test. The
variable also predates this branch with a second meaning: desktop tooling sets
it to a vendored dist/cli.js — a module path, no shebang — which a POSIX shell
would run as a shell script; getShellContextEnvVars now drops a shebang-less
script (and only a script: a native binary needs none), restoring the bare
`qwen` fallback for those hosts. And both dev launchers read a signal-killed
child (`code === null`) as exit 0 — a killed gate command reported green; both
now re-raise the signal, with close(null, 'SIGKILL') regressions. The
production entry's stamp gets the test only the dev entries had.

Gap reach. `not-launched` said the pass "did not run" — but a hand-written
launch that never opened the brief lands in that shape too, so it now uses the
certification language the other shapes got. The roster check judged only the
FIRST transcript matching a built prompt, so a failed attempt masked the
compliant relaunch that the remediation itself prescribes — all matches are
consulted now. An agent flagged rewritten is no longer also flagged unopened
(contradictory repairs for one agent), and the all-briefless collapse no
longer coexists with one "none was built" line per chunk transcript.

Repair loops. Every rebuild command the run prints is now executable as
written — plan, selector, and `--rules` included, because a rebuild without
the rules file writes a rules-free brief that every delivery check still
passes; the verify variant stops inviting the empty findings file that is only
legitimate for a reverse-audit round. check-coverage prints exact selectors
beside the human labels. Idle agents and unread chunks get FIX lines too, and
a handler test pins the boundary: every FIX on stderr, before the verdict,
never in the JSON. SKILL.md Step 6 now says what FIX lines are for: one
bounded repair round, recompose, then the cap stands.

Roster integrity. The output is self-checking against the 30 000-character
shell truncation the skill itself documents — numbered blocks, an
end-of-roster line, and SKILL.md redirects it to a file read back paged. A
PR-controlled filename can no longer forge a block boundary: control
characters flatten to spaces in the label and the launch prompt, and a test
pins the separator count.

The jq interpolation in the report template is gone — the verdict line is
copied from Step 6's output, not recomputed by a binary the host may not have.
The findings read-error no longer advises omitting a flag another guard
requires.

* fix(review): filter by overwriting, not omitting — the spread carries what the record drops

The shebang filter fixed the wrong layer. It omitted QWEN_CODE_CLI from the
record getShellContextEnvVars returns — but every spawn site composes the
child env as `{...process.env, ...vars}`, so a key omitted from the additive
record arrives anyway, inherited through the spread. On exactly the hosts the
filter was written for (desktop tooling setting the variable to a shebang-less
vendored dist/cli.js), the value leaked through and every
`"${QWEN_CODE_CLI:-qwen}"` in the skill died on exit 126 — where before this
branch those hosts ran bare `qwen` and worked.

The fix is the pattern this same function already documents for the
agent/prompt IDs: write an EMPTY string, which overwrites the inherited value
through the spread, and which the consumer's `:-` expansion treats exactly
like unset. The test comment that justified omission — "an empty string would
shadow the fallback" — was true only of the colon-less `${VAR-qwen}` form and
is corrected where it stood, so the reasoning that produced the bug does not
outlive it.

The tests now assert on the channel the bug lived in: composing
`{...process.env, ...getShellContextEnvVars()}` and reading the child env —
for the shebang-less case, the unreadable-path case, and the pass-through
case. Reverting the overwrite to an omission turns exactly the two filter
tests red. Verified end-to-end: with the desktop shape in the parent env, a
child shell resolves `"${QWEN_CODE_CLI:-qwen}"` to the PATH `qwen` again.

Also from the same review: the two adjacent `missingReceipts` blocks in
compose-review are one block now (disclosure and repair cannot drift apart),
and the `Exact selectors:` line says a rebuild of an already-built role is
idempotent, so the over-prescription cannot make an operator hesitate.

* fix(review): reunite roleLabel with the doc comment the selectorOf insertion orphaned

The insertion left roleLabel's one-line JSDoc stranded above selectorOf,
stacked on top of the new function's own — a maintainer chasing a wrong-label
bug would have edited the rebuild-flags function. Each doc sits on its
function again.

* fix(review): close the round-9 findings — convergence, injectivity, and the claims a record can carry

Fourteen findings from a GPT-5 review of the previous head; twelve fixed here,
one was already fixed in the commit the review missed, one re-recorded as the
standing roster-design follow-up.

Repair loops now converge. Coverage accumulated every historical failed
transcript, so the relaunch its own FIX line prescribes ADDED a transcript
while the failed one kept its flag — ok stayed false, the same FIX printed
forever. A failed attempt is now superseded by a compliant attempt at the same
target (same chunk served verbatim with the diff opened; same built prompt
delivered to an agent that opened its brief), and a rewritten agent is not
also told to relaunch the prompt that was the defect.

One transcript, one credit. Pasting the whole roster output to a single agent
produced one transcript that verbatim-contains every block, matched every
requirement independently, and certified an N-agent fan-out with one reader
(reproduced upstream: roster 8, agents 1, ok true). Requirements now claim
distinct transcripts; the paste-all run fails with a sentence that names the
mistake.

Records claim only what they prove. "Its brief never reached an agent" said
more than a missing record can see (the builder may have run against another
--plan spelling); it now reads "no record shows its brief reaching an agent".
The rewritten texts claimed the brief's method never arrived — but that shape
is DETECTED by the brief being opened; they now state exactly that, and that
the launch was not the built one. A zero-byte record (a torn write) no longer
counts as built anywhere: one predicate serves the collapse, the roster loop
and the chunk lookup.

Entries the shell can actually run. The shebang filter now also requires the
execute bit (a 0644 script passes the header check and dies on EACCES), and
cli-entry consumes QWEN_CODE_LAUNCHER_PATH at stamp time — the serve/mcp fast
path never reached the branch that deleted it, so a standalone daemon leaked
the outer shim into every child, where a different checkout would republish it
as its own entry.

Inputs a PR cannot weaponize, commands an operator can run. The invariant
brief interpolated the raw PR-controlled filename into the file the agent is
told is the whole of its instructions — display sinks now flatten control
characters and the functional read argument is JSON-quoted. Agent 7 no longer
receives the review rules its own workflow forbids it (SKILL.md: deterministic
commands, not code review). The verifier refuses an empty findings file — a
vacuous pass that cleared the delivery floor while ruling on nothing — while
the early reverse-audit round keeps it. FIX lines carry the run's real plan
path instead of a `<plan>` placeholder that pastes as a shell redirection, the
roster truncation hint names --file and --rules, and composed.json persists
the exact verdictLine so the archived report copies rather than reconstructs
it — event and cappedBy alone cannot express a presubmit downgrade.

Every new behaviour is pinned: convergence, paste-all refusal, and the
zero-byte collapse are mutation-checked (disabling each turns exactly its test
red); the exec-bit, brief-injection, launcher-consumption and verdictLine
contracts each carry a direct test. 1 236 tests across the affected suites.

* fix(review): close the three paths the round-11 review found still open

The Step 4/5 FIX lines still carried a literal `--plan <plan>`. Round 9
substituted the real path into compose-review's own remediation strings and
check-coverage's hints, and left the one builder both Step 4/5 gaps flow
through — `rebuildFix` — untouched: its output reached stderr through
verificationGaps with the placeholder intact, and a literal `<plan>` pasted
into a POSIX shell parses as input redirection, so the one repair round Step 6
prescribes could never run there. The push sites now substitute the plan path
verificationGaps was handed, and the test that pins the fix text asserts no
literal `<plan>` survives anywhere in the remediation.

A lightweight cross-repo review can now be REQUIRED to run Agent 0. plan-diff
takes `--pr <n> --repo <owner/repo>` — passed only after pr-context succeeds,
so the pair's presence doubles as the context-availability signal — and
writes the identity into the plan; the roster requires role 0 wherever the
full identity is present, not only in worktree mode (fetch-pr always writes
both fields, so PR-worktree behavior is unchanged). Half an identity is
refused: a roster demanding an agent nobody can brief would wedge the run.
SKILL.md's lightweight capture block carries the flags and the
when-not-to-pass-them rule.

And the path-inertness boundary is one function with a wider net: `inertPath`
now flattens every control character (a terminal escape in a filename must
not reach a terminal), the separator glyph, and the backtick — which could
close the Markdown code span the path is rendered inside and let the tail of
a PR-controlled filename run as markup in the brief the agent treats as
authoritative. The roster label and launch-prompt sites that had their own
narrower regexes now share it. The injection test's hostile filename gained a
backtick and an ESC sequence, and asserts the rendered heading carries
exactly the span's own backtick pair and no control bytes, while the
JSON-quoted functional read argument still round-trips the raw path.

Each fix is mutation-checked: reverting the substitution, re-gating the
roster on worktree mode, and narrowing inertPath each turn exactly one test
red.

* fix(review): bind the receipt to what was delivered, and match what actually assigns

Three review-integrity holes from the round-12 review, each with a
reproduction, each fixed at the layer the reproduction named.

The verify receipt could be satisfied by a partial delivery. The record was
deliberately the findings-free launch block, so one key could serve every
shard by the add-only rule — and that same rule let a caller build with a real
findings file, launch the agent with only the recorded tail, and clear the
gate while no verifier ever saw a finding. The record is now the EXACT printed
prompt, findings folded in, keyed per findings-content digest
(`verify--<sha>`, `reverse-audit--chunk-N--<sha>`); the delivery side collects
the whole key family with the documented floor of one. Tail-only delivery
matches nothing; each shard verifies against its own list; shard records no
longer share a key, so none clobbers another.

The injective roster matching was greedy, and greedy rejects valid
assignments. With transcript T1 containing blocks A+B and T2 containing only
A, first-come claiming took T1 for A and reported B missing — a compliant
repair permanently capped by transcript filename order. The claim set is now a
maximum bipartite matching (Kuhn's augmenting paths), seeded on the edges
where the transcript also opened the requirement's brief and extended over all
verbatim edges, so a requirement reports missing only when no injective
completion exists at all.

A rules-free rebuild could silently strip the brief. The launch prompt only
points at the brief, so rebuilding a rules-bearing role without --rules left
the recorded launch byte-identical while the project rules vanished from the
one file the agent treats as authoritative — every delivery check kept
passing. writeBrief now refuses the downgrade at the single choke point both
build paths pass through, with the escape hatch named (delete the record dir
to start over deliberately).

All three are mutation-checked: regressing the record to findings-free, the
matching to greedy, or disabling the downgrade guard each turns its own test
red. 704 review tests green.

* docs(review): let the docs and comments claim only what the new record design does

The round-13 review caught the drift this branch's own thesis forbids: two
SKILL.md sentences still described the findings-free record the previous
commit retired — an orchestrator reasoning from them would conclude a
findings-less delivery still matches, precisely the bypass that commit closed.
Both now state the new contract: the record is the exact printed block, keyed
per findings digest, and a launch that drops the list matches no record.

And the matching comment claimed more than Kuhn guarantees: phase-2
augmentation can displace an opened match onto an unopened edge to enlarge the
matching, so an unread flag describes the assignment, not an impossibility.
The comment now says so, and why cardinality is the right thing to maximize.

* docs(review): finish retiring the findings-free record from every sentence that described it

Round 15 found the three survivors round 13 missed — all in code, not
SKILL.md: the findingsSection docstring (all three of its clauses false since
the digest-key commit), the findings field doc ('Printed, not recorded'), and
the --findings --help text, which told an operator the exact opposite of what
the command now does. Each now states the new contract: the findings are part
of the recorded prompt, keyed per digest, and a launch that drops them matches
no record.

Also from the same review: the plan-path substitution uses a function
replacer, so a path containing $& or $` cannot be misrendered as a
replacement pattern. Practically unreachable for .qwen/tmp paths; closed
because it costs four characters.

* docs(review): the actually-last sentence describing the findings-free record

Round 16 counted one survivor of the sweep the previous commit's title
claimed complete: the acceptsFindings jsdoc in agent-briefs.ts, present-tense,
whose '(see runAgentPrompt)' pointed at a function whose own comment says the
opposite. It now states the digest-key contract like its siblings, and a
whole-tree grep for present-tense descriptions of the retired design comes
back empty.

* test(review): pin the idle and missing-chunk FIX lines to the remediation channel

Round-18 review: the two remediation pushes added for the every-gap-has-a-FIX
rule had no test of their own — deleting either failed nothing, leaving a body
disclosure whose repair could silently vanish, the exact state the channel
exists to prevent. The idle-plan test now asserts the relaunch FIX; the
blind-plan test, whose chunks nobody reads, now asserts the chunks-nobody-read
FIX beside the blind one. Both mutation-checked: deleting each push turns
exactly one test red.

* fix(review): quote the plan path in every printed repair, and test the executable shebang-less shape

Round-21 review, three items. The plan path is now single-quoted at all seven
sites that print it into a repair command — a workspace path containing a
space split the copy-pasted FIX at the space, exactly the operator moment the
lines exist for; the earlier uniformity deferral ends here, uniformly.
PlanDiffResult declares prNumber/ownerRepo so a refactor away from the
conditional spread cannot silently drop the fields the roster's Agent-0
requirement reads. And the filter gains the test its primary target deserved:
an EXECUTABLE shebang-less .js (the desktop vendored bundle shape) is rejected
by the header read itself — the existing 0644 fixture never reached that
branch, so a regression in the byte read would have passed every test.

* fix(review): shell-quote the plan path properly — an apostrophe is not rarer than a space

Round-22 review: the bare '…' wrap from the previous commit closed at an
embedded apostrophe, so ~/Documents/John's Projects broke where it had worked
unquoted — one breakage class traded for another instead of both closed. A
shared shellQuotePath (the same '\'' dance as utils/standalone-update.ts)
now serves all six repair-printing sites, and a test drives verificationGaps
from a plan under an apostrophe directory, asserting the escaped form and
rejecting the naive wrap.

* fix(review): quote the --file selector, un-dead the spawn guard, test the half-identity

Round-24/25 reviews, four small items. selectorOf now shell-quotes the --file
path — the same copy-paste contract the --plan quoting just earned, on the one
selector that carries a path. RULES_MARKER moves above writeBrief's JSDoc,
which it had been silently stealing. The check-build-status test's reject
guard was dead (execFile always delivers string stdout, so an ENOENT resolved
and the empty-stdout assertion passed on a script that never ran) — it now
rejects on spawn-level errors, which carry string codes, while non-zero exits
still resolve. And the roster's ownerRepo guard gets the independent test it
never had: a plan with prNumber but no ownerRepo requires no Agent 0, since
the brief builder cannot serve half an identity.
2026-07-18 00:43:57 +00:00
Shaojin Wen
da22360c25
feat(web-shell): show the qwen-code version in the sidebar footer (#6222)
* feat(web-shell): show the qwen-code version in the sidebar footer

The Web Shell had no visible version. Show the running qwen-code version (from the daemon capabilities) in the sidebar footer, inline with the Settings button so it stays visible without taking its own row.

Render the version consistently wherever it appears:
- Prefix "v" only for a real semver release; a non-semver fallback such as "unknown" is shown as-is, so we never render a bogus "vunknown". Applied to the Web Shell badge and the TUI header.
- Dev builds (scripts/dev.js) now report the real package version instead of the "dev" sentinel, matching scripts/start.js, so the UI shows the actual version (e.g. v0.19.4). DEV=true / NODE_ENV=development remain the signals that mark a dev build.

* test: add readFileSync to node:fs mock in dev.test.js

scripts/dev.js now reads package.json via readFileSync at module load to
report the real CLI_VERSION, but the node:fs mock in dev.test.js did not
export readFileSync, causing vitest to throw "No readFileSync export is
defined on the node:fs mock" and failing the suite.
2026-07-03 08:56:08 +00:00
yao
f9080e44fb
fix(cli,core): harden OOM prevention — idempotent compaction tests, explicit GC, debug log defaults (#4914)
* test(cli): add compactOldItems idempotency regression tests

Cover the scenario fixed in commit 595701096 where already-compacted
tool groups (resultDisplay === UI_COMPACT_CLEARED_MESSAGE) were
incorrectly counted as having real output, causing over-compaction.

Three new test cases:
- Already-compacted groups are not re-compacted; second call is a no-op
- All tool groups already compacted → no-op
- Mixed tool group (some tools real, some cleared) → only groups with
  real output are compacted

* fix(cli,core): enable explicit GC and disable debug log by default

- enableExplicitGC defaults to true, --expose-gc added to start/dev scripts
- isDebugLogFileEnabled() defaults to false (opt-in via QWEN_DEBUG_LOG_FILE=1)
- Add safety tests: trigger_gc only in critical tier, global.gc() only in
  memoryPressureMonitor.ts trigger_gc case

* fix: address R1 review comments for memory pressure monitor

- Replace brittle source-parsing test with behavioral tests for global.gc()
- Export UI_COMPACT_CLEARED_MESSAGE constant and use in tests
- Remove redundant NODE_OPTIONS override from start script
- Add production bin wrapper with --expose-gc for OOM protection
- Remove unused path import from memoryPressureMonitor.test.ts

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix: forward --expose-gc to all deployment modes

Standalone package shims and daemon-spawned sessions (AcpBridge,
httpAcpBridge) were missing --expose-gc, causing explicit GC to
silently fail under critical memory pressure.

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix: forward child process signal in cli-entry wrapper

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(cli,channels): filter --inspect flags when forwarding execArgv to daemon children

* fix: make cli-entry.js executable (mode 100755)

* fix(core): reject whitespace-only QWEN_DEBUG_LOG_FILE and add QWEN_MEMORY_ENABLE_GC=0 opt-out

* fix(scripts): include cli-entry.js wrapper in dist package for npm publish

* fix(acp-bridge): forward --expose-gc and filter --inspect in spawnChannel

- Add --expose-gc to getAcpMemoryArgs() so daemon-spawned ACP children
  have global.gc() available for critical memory pressure cleanup
- Filter --inspect/-brk flags from process.execArgv to prevent port
  conflicts in multi-session daemon mode
- Update spawnChannel.test.ts for new getAcpMemoryArgs() return shape

This change was previously in httpAcpBridge.ts but lost during the
daemon refactor merge (#4490) that moved spawn logic to acp-bridge.

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-06-14 10:40:53 +08:00
Dragon
423cac110c
feat(acp): support desktop qwen integration (#4728)
* feat(acp): support desktop qwen integration

* feat(providers): add qwen3.7 standard models
2026-06-09 19:09:44 +08:00
Dragon
3605f81779
chore(vscode): run dev cli from source (#4283) 2026-05-19 06:56:16 +08:00
LaZzyMan
7a6b725b0c feat: replace qwen-settings-config with bundled qc-helper skill
- Remove project-level qwen-settings-config skill and its references/
- Create bundled qc-helper skill at packages/core/src/skills/bundled/
  that references docs/users/ for answering usage/config questions
- Update copy_bundle_assets.js to copy docs/users/ into dist/bundled/qc-helper/docs/
- Update dev.js to create symlink for dev mode docs access
- Add bundled docs directory verification in prepare-package.js
- Revert doc-update skills (docs-audit-and-refresh, docs-update-from-diff)
  to main branch versions
2026-03-27 12:03:00 +08:00
qwencoder
81fb6c6416 fix: improve Windows compatibility for command execution 2026-03-05 15:15:11 +08:00
DragonnZhang
3c513b6271 Add dev launch config and preserve existing NODE_OPTIONS
Add a "Dev Launch CLI" VS Code debug configuration and fix
scripts/dev.js to preserve existing NODE_OPTIONS (e.g. --inspect
flags injected by VS Code debugger) instead of overwriting them.
2026-02-10 16:45:04 +08:00
tanzhenxin
b1fa12f323 refactor(core): Unify package exports and improve dev experience
- Update license header to include Qwen copyright
- Add error handler for spawn in dev.js
- Refactor core/src/index.ts to export all public APIs
- Simplify core/index.ts to be a clean re-export
- Fix vitest alias to point to package entry

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-01 11:59:05 +08:00
tanzhenxin
07b186fcbf build: Improve build efficiency and add dev mode
- Remove duplicate webui build in vscode-ide-companion (fixes double build)
- Fix misleading [watch] log messages in esbuild.js (only show in watch mode)
- Update vite-plugin-dts to ^4.5.4 for TypeScript 5.8+ support
- Update baseline-browser-mapping to ^2.9.19 to silence outdated data warnings
- Fix vitest config to use @qwen-code/qwen-code-core instead of old gemini-cli-core
- Add resolve.alias in cli vitest.config.ts for source-based testing
- Add npm run dev script for running from TypeScript source without build

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-01-31 22:41:54 +08:00