mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-19 20:53:34 +00:00
* 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 in521b212cd0— 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. Commitf663f779a1moved 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 from521b212cd0— 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:8d6e199cc4refuses 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 since192bb98cdafound 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 to6a69c0b5bbwhen 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 closee0f75795ccmade 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 on8d3e506a3, 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 on45a9668a8f(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 port1d3173a53epicked 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>
288 lines
9.8 KiB
JavaScript
Executable file
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);
|
|
});
|