qwen-code/scripts/dev.js
易良 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

288 lines
9.8 KiB
JavaScript
Executable file

#!/usr/bin/env node
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Development entry point for Qwen Code CLI.
*
* Runs the CLI from TypeScript source with prebuilt workspace dependencies.
* Changes to packages/core or packages/cli are reflected immediately.
*
* Usage: npm run dev -- [args]
* Example: npm run dev -- help
*/
import { spawn } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
writeFileSync,
mkdtempSync,
rmSync,
existsSync,
symlinkSync,
mkdirSync,
readFileSync,
readdirSync,
} from 'node:fs';
import { tmpdir, platform } from 'node:os';
import { copyBrowserUseAssets } from './copy-browser-use-assets.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const cliPackageDir = join(root, 'packages', 'cli');
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
// Ensure qc-helper bundled skill can find user docs in dev mode.
// In dev, import.meta.url resolves to the source tree, so the bundled skill
// directory is packages/core/src/skills/bundled/qc-helper/. We create a
// symlink from there to docs/users/ so the skill can read docs at runtime.
const qcHelperDocsLink = join(
root,
'packages',
'core',
'src',
'skills',
'bundled',
'qc-helper',
'docs',
);
const userDocsTarget = join(root, 'docs', 'users');
if (existsSync(userDocsTarget) && !existsSync(qcHelperDocsLink)) {
mkdirSync(dirname(qcHelperDocsLink), { recursive: true });
try {
symlinkSync(userDocsTarget, qcHelperDocsLink);
} catch {
// Symlink may fail on some systems; non-critical for dev
}
}
// Stage the Browser Use skill runtime next to its SKILL.md for dev mode. The
// bundle step does the same into dist/bundled; only dev mode reads the skill
// from the source tree, so this copy exists nowhere else (it is git-ignored,
// and no build step writes it). It needs the built browser-use package. The
// informational passthrough flags never reach a skill, so they skip the copy
// and stay a fast path.
const passthroughArgs = process.argv.slice(2);
const informationalOnly = passthroughArgs.some((argument) =>
['--version', '-v', '--help', '-h'].includes(argument),
);
if (!informationalOnly) {
try {
copyBrowserUseAssets(
root,
join(root, 'packages', 'core', 'src', 'skills', 'bundled', 'browser-use'),
);
} catch (error) {
console.warn(
`Browser Use skill runtime not staged for dev mode: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
// Entry point for the CLI
const cliEntry = join(cliPackageDir, 'index.ts');
// Create a temporary loader file
const tmpDir = mkdtempSync(join(tmpdir(), 'qwen-dev-'));
const loaderPath = join(tmpDir, 'loader.mjs');
const coreDir = join(root, 'packages', 'core');
const coreSpecifier = '@qwen-code/qwen-code-core';
const coreSourceUrl = pathToFileURL(join(coreDir, 'index.ts')).href;
const coreSrcUrl = pathToFileURL(join(coreDir, 'src') + '/').href;
const coreExports = JSON.parse(
readFileSync(join(coreDir, 'package.json'), 'utf-8'),
).exports;
const coreSubpathSourceUrls = {};
for (const [subpath, conditions] of Object.entries(coreExports ?? {})) {
const distEntry = conditions?.import;
// Skips the root (handled below) and the string-valued entries — `./dist/*`,
// `./src/*`, `./package.json` — whose target is not one fixed source file.
if (subpath === '.' || typeof distEntry !== 'string') continue;
if (!distEntry.startsWith('./dist/')) continue;
const sourcePath = join(
coreDir,
distEntry.slice('./dist/'.length).replace(/\.js$/, '.ts'),
);
if (existsSync(sourcePath)) {
coreSubpathSourceUrls[`${coreSpecifier}/${subpath.slice(2)}`] =
pathToFileURL(sourcePath).href;
}
}
// Shared node_modules may resolve the bridge from another checkout. Keep the
// daemon and its ACP children on this checkout's protocol without a build.
const bridgeDir = join(root, 'packages', 'acp-bridge');
// `?? {}` rather than a bare read: a manifest without an `exports` map means
// there is nothing to remap, and `Object.entries(undefined)` would throw at
// module load — taking the launcher down for every caller, not just this
// remapping.
const bridgeExports =
JSON.parse(readFileSync(join(bridgeDir, 'package.json'), 'utf-8')).exports ??
{};
for (const [subpath, conditions] of Object.entries(bridgeExports)) {
const entry = conditions?.import;
if (typeof entry !== 'string' || !entry.startsWith('./dist/')) continue;
const sourcePath = join(
bridgeDir,
'src',
entry.slice('./dist/'.length).replace(/\.js$/, '.ts'),
);
if (!existsSync(sourcePath)) continue;
const specifier =
subpath === '.'
? '@qwen-code/acp-bridge'
: `@qwen-code/acp-bridge/${subpath.slice(2)}`;
coreSubpathSourceUrls[specifier] = pathToFileURL(sourcePath).href;
}
// Keep shared worktree installs from mixing another checkout's channel build.
for (const name of readdirSync(join(root, 'packages', 'channels'))) {
const directory = join(root, 'packages', 'channels', name);
const manifest = join(directory, 'package.json');
const entry = join(directory, 'src', 'index.ts');
if (!existsSync(manifest) || !existsSync(entry)) continue;
const channel = JSON.parse(readFileSync(manifest, 'utf8'));
coreSubpathSourceUrls[channel.name] = pathToFileURL(entry).href;
}
const loaderCode = `
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const coreSourceUrl = '${coreSourceUrl}';
const coreSubpathSourceUrls = ${JSON.stringify(coreSubpathSourceUrls, null, 2)};
const coreSrcUrl = '${coreSrcUrl}';
const corePrefix = '${coreSpecifier}/';
export function resolve(specifier, context, nextResolve) {
const url =
specifier === '${coreSpecifier}'
? coreSourceUrl
: coreSubpathSourceUrls[specifier];
if (url) {
return {
shortCircuit: true,
url,
format: 'module',
};
}
if (specifier.startsWith(corePrefix)) {
// Deep paths mirror packages/core/src; leave missing files to Node.
const sub = specifier.slice(corePrefix.length).replace(/\\.js$/, '');
for (const ext of ['.ts', '.tsx']) {
const candidate = new URL(sub + ext, coreSrcUrl);
if (existsSync(fileURLToPath(candidate))) {
return { shortCircuit: true, url: candidate.href, format: 'module' };
}
}
}
return nextResolve(specifier, context);
}
`;
writeFileSync(loaderPath, loaderCode);
// Create the register script that uses the new register() API
const registerPath = join(tmpDir, 'register.mjs');
const loaderUrl = pathToFileURL(loaderPath).href;
const registerCode = `
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
register('${loaderUrl}', pathToFileURL('./'));
`;
writeFileSync(registerPath, registerCode);
// Preserve existing NODE_OPTIONS (e.g. VS Code debugger injects --inspect flags via NODE_OPTIONS)
const existingNodeOptions = process.env.NODE_OPTIONS || '';
const importFlag = `--import ${pathToFileURL(registerPath).href}`;
const env = {
...process.env,
DEV: 'true',
// Report the real package version (like scripts/start.js) so the UI shows
// e.g. "v0.19.4" instead of "dev". DEV=true / NODE_ENV=development remain the
// signals that distinguish a dev build.
CLI_VERSION: pkg.version,
NODE_ENV: 'development',
NODE_OPTIONS: `${existingNodeOptions} --expose-gc ${importFlag}`.trim(),
// The entry a `qwen …` subprocess should call to reach THIS build — without
// it, a skill that shells out to `qwen` gets whatever PATH resolves, which on
// a dev machine is routinely an older global install. Assignment, not `??` or
// `||=`: an inherited value is another session's CLI (a dev CLI started from
// inside an outer qwen session's shell — the usual dogfooding flow), and
// honouring it re-points every subprocess at the outer build — the same skew,
// one level up, and silent. Each entry stamps itself; nested sessions each
// call their own build. This one line also covers `npm run dev:daemon`, which
// launches serve through this file.
QWEN_CODE_CLI: fileURLToPath(import.meta.url),
};
// On Windows, use tsx.cmd; on Unix, use tsx directly
const isWin = platform() === 'win32';
const tsxBinName = isWin ? 'tsx.cmd' : 'tsx';
const localTsxCli = join(root, 'node_modules', 'tsx', 'dist', 'cli.mjs');
const localTsxCmd = join(root, 'node_modules', '.bin', tsxBinName);
const hasLocalTsxCli = existsSync(localTsxCli);
const tsxCmd = hasLocalTsxCli
? process.execPath
: existsSync(localTsxCmd)
? localTsxCmd
: tsxBinName;
const tsxArgs = [
...(hasLocalTsxCli ? [localTsxCli] : []),
'--tsconfig',
join(cliPackageDir, 'tsconfig.json'),
cliEntry,
...process.argv.slice(2),
];
const useShell = isWin && !hasLocalTsxCli;
const child = spawn(tsxCmd, tsxArgs, {
stdio: 'inherit',
env,
cwd: process.cwd(),
shell: useShell, // Needed only when falling back to tsx.cmd on Windows.
});
child.on('error', (err) => {
console.error('Failed to start dev server:', err.message);
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
process.exit(1);
});
child.on('close', (code, signal) => {
// Cleanup temp directory
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
// A signal-killed child reports `code === null`, and `code ?? 0` read that as
// success. This launcher is a QWEN_CODE_CLI entry now: a review gate command
// OOM-killed mid-run must not come back green. Re-raise the signal the way
// cli-entry.js does, so the caller sees the same death; fall back to a
// non-zero exit if the signal cannot be re-raised.
if (signal) {
try {
process.kill(process.pid, signal);
return;
} catch {
process.exit(1);
}
}
process.exit(code ?? 1);
});