Repository navigation
feat(opencode): agent management surface - #35
Merged
Merged
Conversation
Research stage per docs/workflow.md §4.1 for issue #23: survey the existing task/Subagent surface, compare it against Claude Code and Codex, map it onto the open upstream proposals, and recommend a five-tool Agent management surface addressed solely by session_id. Confirmed; the architecture stage owes the state enum, the stop cascade and termination-notice contracts, and message delivery ordering.
Architecture stage per docs/workflow.md §4.2 for issue #23, built on the confirmed research. Five modules behind one model-facing surface: AgentTree resolves relationships and authority, AgentStatusProjection derives the state, AgentInbox delivers messages, AgentLifecycle creates, resumes and stops, and AgentTools is the only thing the model sees. Settles the three items the research deferred. The state enum is running / idle / failed / cancelled, with the terminal values read from the session's last assistant message rather than the in-process job registry so they survive a restart. Message delivery takes the target session's own agent and model, never the caller's. Stopping walks the subtree bottom-up and waits for each layer's termination notice to be persisted before cancelling the layer above, which is what keeps a notice from waking the parent it was about to stop. Includes the §4.2.1 correctness argument (goal to module mapping, assumptions H1-H6, invariants I1-I3 with preservation) and a §3.4 concurrency spec for the stop cascade. Known gaps are listed rather than papered over.
Four of the six entries under key assumptions were not assumptions. §4.2.1 reserves that section for premises the architecture cannot control; properties this feature defines or maintains belong in the decision table and the invariant list. "At most one active execution per Agent" is our own definition of Agent, not an external fact — the research defines Execution as the Agent's *current* internal execution state, singular, and that is precisely what makes session_id sufficient as the only identity. It moves to the decision table with that reasoning, and I3 keeps the mechanism. "One final result per message sequence" splits the same way into a decision and I4. The termination-notice premise was a consequence of I1, and the mid-teardown spawn premise was already listed as a known gap; both are dropped rather than restated. Two external assumptions remain, which is the point.
Checking Claude Code's actual tool set while writing the architecture showed the §9 comparison row for "query a single Agent" was wrong. Claude Code exposes four agent-management tools — Agent, ListAgents, SendMessage, TaskStop — and has nothing that queries one agent. What the row credited to it, viewing an agent's transcript and status, is a UI affordance rather than a model-callable tool. ListAgents carries each agent's busy/idle state on its own row, which is why no second query tool is needed. The nearest thing, TaskOutput, is deprecated there, and it is addressed by task_id, blocks for completion, and polls for status — the three things §6 already excludes. So agent_get would have been a tool neither Claude Code nor Codex has, serving a need the roster should carry. The surface is now four tools; the gap it answered is met by the roster row. §12 records the correction.
Follows the research revision in §12: agent_get is gone and its goal is met by the roster row, so the flow, the module surface, the M5 spec and the goal mapping all lose it, and AgentTree no longer needs a single-agent locate. Two deviations found while auditing this document against the research are fixed in the same pass. AgentInfo carried time_updated, which is only touched when a user message is written — an agent forty tool calls into a long turn would report a timestamp from before it started, which is exactly the inaccurate last-activity signal §6 excludes; the field is dropped. The task compatibility alias from §8 was missing entirely; M5 now owns it as a hidden entry that normalizes task_id to session_id and forwards to the same implementation, with no second execution path. Both new decisions are recorded in §6 with their reasoning.
The research only asks the status to separate "actually executing" from "session exists with nothing running" — two values. failed and cancelled were mine, and everything they carry has already been delivered to the waiter through the existing notification channel by the time anyone reads a roster. Claude Code's ListAgents gives busy/idle for the same reason. This also removes the one place this document knowingly narrowed the research: the projection no longer needs a terminal-state source, so it neither reads BackgroundJob nor infers outcomes from message history, and the question of which of the two to trust disappears rather than being decided. H1 keeps the restart caveat but says what actually degrades: after a restart every execution really has stopped, so idle is true; what is lost is how each one ended, which was reported when it ended.
A §4.4 review pass over the whole document. Collapsing the status enum and dropping agent_get left contradictions behind: §2 still described deriving a terminal state from the last assistant message, M2's own summary line still claimed to combine a persisted terminal state, and the decision table carried two mutually exclusive rows about where the terminal state comes from. The correctness argument still counted four enum values in two places. M5 still said five tools. Three format defects are fixed too. Accepted and StopOutcome appeared in interface specs without a §3 definition, which §3.3 forbids; both are now defined, and StopOutcome gained the failed list the M5 interface already promised to surface. The M5 to M2 interface took an AgentInfo while M2's own precondition took a session_id. §4.6 adds the tool schemas, which the research asked the architecture stage to define and this document had omitted entirely. Writing them surfaced an open question: agent with a session_id and agent_send are the same mechanism, so the surface may collapse to three tools. Flagged for the confirmation gate rather than settled here.
Writing the tool schemas showed agent with a session_id and agent_send are the same mechanism — both append a user message to an existing Agent and let it run — and that the research contradicted itself: §9 listed explicit resume as its own row while the same section had already chosen Claude Code's semantics, where one agent_send covers both target states. Claude Code's own Agent tool documents the split plainly: SendMessage continues an existing agent, a new Agent call always starts fresh. So agent drops session_id and only creates, AgentLifecycle.create replaces createOrAdopt, and resume runs through AgentInbox like any other message — an idle target starts a new execution, which §5.3 already specified. The surface stays at four tools; a parameter went away, not a tool. Research §13 records it.
The termination-notice argument assumed a grandchild being stopped while its parent still waits, but task.ts gates spawning on depth >= (subagent_depth ?? 1), so by default a subagent cannot spawn one and the tree is two levels. At two levels the only stopper and the only parent are both the main agent, which already knows, so the notice and the bottom-up ordering have no subject — the feature's multi-level orchestration is off by default. Default goes to 2: main spawns a child, the child spawns a grandchild, the grandchild is blocked. Implementation must change both the fallback in task.ts and the schema description in core config, which still says it defaults to 1. Also records what this review turned up: background is gated behind an experimental flag and vanishes from the schema when it is off, which takes the automatic notification channel with it — noted as a gap since the two-value status leans on that channel. And a new section names the two places this design deliberately parts from Claude Code, so "aligned with CC" is not read as uniform.
The foreground path awaits background.wait for the child, so the parent sits inside that tool call until the child finishes and cannot issue agent_list, agent_send or agent_stop while it runs. The management surface is unusable against a foreground child, which is the whole feature. Foreground is not a mode to keep behind a flag; it is incompatible. So the background parameter and its experimental gate both go, and an agent call returns AgentInfo immediately with the outcome arriving through the existing notification channel. Claude Code's Agent tool has no such parameter either, for the same reason. This also settles the gap the previous commit recorded: the notification channel is no longer conditional, so the two-value status enum's premise holds. What replaces it as a gap is the migration — the foreground path currently carries the assertions about how a child's errors reach its parent, and those observation points move from the tool return value to the notice.
Claude Code scopes ListAgents to "agents you can SendMessage to", and for the subagent dimension that is the ones you spawned — not a whole tree. SendMessage takes main, a teammate, or an agent from that listing: parent, sibling, child. So agent_list returns the caller's parent, children and siblings with the relation labelled, agent_send addresses that same set, and agent_stop narrows further to direct children, with the cascade reaching their descendants as an effect rather than a wider target set. subagent_depth's default goes to 3, matching CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH — same unit, and the current default of 1 is exactly what that setting calls nesting off. At the limit, tools are withheld rather than failing the call: agent and agent_stop go, since a limit-depth agent can never have children to spawn or stop, while agent_list and agent_send stay because parent and siblings remain addressable. The test is depth, not whether children exist right now. Narrowing stop changed who receives a termination notice — the boundary recipient is now always the agent that issued the stop — so §4.4's reasoning is rewritten and I1 restated around the recipients that actually occur. The bottom-up ordering stays; its reason is preventing a notice from reviving a just-cancelled parent, which the cascade still produces.
agent keeps the existing task tool's ctx.ask with subagent_type as the pattern. The other three do not prompt: agent_list is read-only, and agent_send and agent_stop act between agents the caller already had permission to spawn, so prompting per call would make orchestration unusable. Cross-session permission laundering follows Claude Code, which states the rule in SendMessage's description rather than enforcing a gate. Enforcing it would mean running a target under the intersection of sender and target permissions, which reaches into permission derivation for far less than it costs.
Claude Code states plainly that launching a subagent needs no approval — the lever is a deny rule on Agent(subagent_type), not a prompt per call, and what actually gets prompted is the subagent's own tool calls under its permission mode. OpenCode's task tool asks, so agent stops asking; deny rules still bite and the spawned agent's own calls stay governed by its session. Checking that turned up a second gate on nesting: childToolDenies appends a task deny to every child session unless the agent definition already mentions it, so raising subagent_depth to 3 on its own leaves children unable to spawn anything. Claude Code lets the agent definition's tool list decide and grants by default, so the blanket deny goes and the definition governs. The todowrite and primary_tools denies are unrelated and stay.
A message transfers no authority — the target acts under its own session's permissions either way — so the worst a stray session id buys is bothering an unrelated session, not privilege. Claude Code's SendMessage already reaches sessions outside any subagent tree, and we already chose to handle the stronger laundering risk with a description rule rather than a code gate; gating the weaker one more tightly would not be consistent. So agent_send validates only that the target session exists and is not the sender. Scope still narrows in practice because the roster only ever hands out neighbours' ids, which is where the constraint belongs. isNeighbor loses its last caller and goes; AgentTree keeps isChild for agent_stop, which stays restricted since stopping is unilateral. The research's exclusion of cross-root messaging now reads as not offering cross-tree addressing rather than blocking the channel.
Detailed design per docs/workflow.md §4.3 covering all five modules: 14 functions carrying the module specs plus four internal helpers, each written against the §4.3.1 completeness rules with a §4.3.2 correctness argument where non-trivial. The parts that needed real derivation rather than transcription: deliver splits persistence from waking so Accepted means persisted, passing the target's own agent and omitting model so the message cannot rewrite the target's identity; stop awaits each layer's notices before cancelling the layer above, which is where I1 is actually enforced; descendants and callerDepth carry termination arguments resting on the acyclic parent chain; and available derives tool withholding from whether an addressable set is permanently empty rather than currently empty. Also lists the five existing-code changes implementation must make, and the error model — four typed failures, with M5 as the only place they become model-readable text.
Reviewing the detailed design against items 5 and 6 turned up eight gaps. Against the module specs: deliver never argued why I4 survives, which matters because the architecture hangs that invariant on M3 — it now shows that appending a message and waking the target registers no per-message completion callback. stop never connected its per-member notice to notify_boundary or to "still resumable"; both are now derived rather than asserted. Against ambiguity: stop called a parentOf helper that does not exist anywhere, plan read a depth that descendants was never specified to produce, children passed a depth it was never given, and create returned AgentNotFound for a missing agent type although that error carries a session_id. descendants and children now take the depth explicitly and propagate it by level, and AgentTypeNotFound is its own type. The no-prompt rule moves to a module preamble so it covers all four tools rather than only agent.
ctx.ask is not a prompt — it evaluates rules and only shows UI when the rule says ask, falling back to ask when nothing matches, which is the actual reason task prompts today. Deleting the call would have removed the only place deny rules are evaluated, and Permission.disabled only hides whole-tool denies with pattern "*", so subtype denies would have silently stopped working. The lever is the fallback action, not the call: agent defaults to allow, the call stays, and task shares the permission key so existing denies still bind. Raising the depth also breaks permission prompts for grandchildren. The TUI aggregates prompts from direct children only while a subagent's own view shows none, which was equivalent while the tree was two levels; at three a grandchild's prompt has nowhere to be answered and it hangs. Recorded as a consequence of the depth change for implementation to fix.
The first detailed design treated task.ts as somewhere a message gets posted, when it is a complete subagent execution contract — result extraction, failure classification, identity inheritance, metadata, titles, attachment expansion and permission merging all live there. The review's finding about missing notification ownership was one facet of that; patching findings one at a time would keep turning up more. So this walks all 42 behaviours and marks each keep, change, drop, or missing. Sixteen are missing from the design. Six of those are one block: what a subagent's final result actually is and how failures are classified — the abort, assistant-error, length, failed-tool-part, incomplete-stream and last-visible-text branches — which is precisely the content the notification channel was supposed to carry. Kept as the coverage baseline for the rework rather than a one-off analysis.
The automatic completion notice lives only inside tool/task.ts's closure — notify and inject — and is not something SessionPrompt or BackgroundJob does on its own. The first design treated it as a free-standing fact, so neither creating an agent nor resuming an idle one had anyone to deliver an outcome, and the two-value status enum rested on a channel that was never connected. M6 AgentExecution now owns execution registration, outcome classification and outcome delivery, and is the only producer of completion, failure and cancellation notices. Making it a module rather than folding it into AgentLifecycle also breaks a dependency cycle: AgentInbox needs it to start an execution and AgentLifecycle needs it to cancel one. Outcome classification copies the existing six branches rather than gesturing at them, since that is the content the channel carries. StopOutcome now separates members that actually transitioned from those already idle, because cancelling an idle session succeeds as a no-op — counting those as stopped is what made repeat stops emit duplicate notices and idle members receive "cancelled". I1 is restated around transitions, and I5 records the single producer whose absence caused the duplicate-notice bug.
The previous commit's message described these but a syntax error in the edit script meant they never reached the file. §8's ordering constraint still keyed on the parent being outside the stop set, where the consequent never occurs; it now keys on the parent being inside, which is the case the ordering exists to protect, and names cancelAndAwaitNotice's return as what carries it rather than watcher scheduling. The stop unit's guarantee adds that it delivers no notices itself. §6 gains the three decisions behind this rework: why AgentExecution is its own module, why outcome classification copies the six existing branches instead of gesturing at them, and why StopOutcome separates transitioned from unchanged.
M6 gets its four functions. ensure deduplicates twice — an explicit running check plus BackgroundJob.start's own same-id semantics — so a message to a busy agent registers no second watcher, which is where I4 now rests. runExecution copies the six outcome branches in order, and records why the order matters: MessageAbortedError is also an error, so classifying it after the error branch would report cancellations as failures. cancelAndAwaitNotice checks for a running job before cancelling, since cancelling an idle session succeeds silently and would otherwise be reported as a stop and emit a notice for something that was never running. watch delivers to the target's parentID rather than the caller, because a sibling can now send. deliver passes agent, model and variant explicitly instead of omitting them: the agent definition's model outranks the session's current one, so omitting was never "keep what the target has". create inherits model and variant from the calling assistant message exactly as the existing task does, including that variant is inherited only when the subagent has no fixed model, and adds the title convention, permission dedup, attachment expansion and tool metadata the inventory turned up. stop no longer delivers anything itself.
runExecution returned the outcome as a success value, but BackgroundJob reads a job's status from the run body's exit — success is completed, failure is error, interrupt is cancelled. As written every execution would have settled completed, so watch could never see a failure or a cancellation and cancelAndAwaitNotice could never observe one. It now returns text on success, fails with the formatted failure text, and interrupts on abort, exactly as the existing execution body does, with the branch-to-exit mapping written out. The order of the branches now carries its reason: MessageAbortedError is also an error, so evaluating the error branch first would both misreport cancellations as failures and settle the job as error rather than cancelled. Four more from the same pass. toInfo takes the status it was leaving empty even though the field is required. callerDepth moves from AgentTools to AgentTree, where it belongs — AgentTree and AgentLifecycle both call it, so leaving it in AgentTools made the call graph point both ways. ensure now shows where its AgentNotFound comes from, and the deferred that lets cancelAndAwaitNotice know a notice has landed is named and given a lifecycle instead of being alluded to. The five failure types are recorded as cross-module in the architecture, which is what §2.4 makes them.
The architecture was marked confirmed and then materially changed — M6 was added, M3 and M4 rewritten, StopOutcome reshaped, I1 restated and I5 introduced — so what was confirmed is no longer what the file says. Status goes back to awaiting confirmation with the reason recorded. Both documents now cite the delivery race as an external dependency rather than carrying a fix for it. M6's outcome delivery and M3's message delivery both ride the shared prompt path, so the feature's delivery certainty rests on that patch landing; duplicating a guarantee here would give the codebase two mechanisms for one problem.
Raising the depth default, letting subagents spawn, and making execution always asynchronous together turn "several agents editing the same tree" from an edge case into the default, so shipping without isolation would mean this feature created a dangerous default itself. The soft version needs no filesystem-resolution changes: all six file tools already accept absolute paths, shell takes a cwd, and external_directory is an existing enforcement point, so a worktree plus a permission entry allowing it gets most of the way. What it does not get is CC's strength — the main checkout sits inside the instance directory, so external_directory lets it through by design; it guards against leaving, not against reaching across. Issue #33 records that gap and why closing it waits for V2's Location scoping. Cleanup differs from CC because our agents resume: a worktree is removed at settle when unchanged, and ensure recreates one if a resumed agent finds its own worktree gone. Recreating an unchanged tree loses nothing. One item is left open rather than decided quietly: how to durably tell a worktree this feature created from a directory the caller passed as cwd, since the two have opposite cleanup rules and confusing them would delete a user's directory.
Worktrees branch from the current HEAD rather than the default branch. Claude Code defaults the other way but says to use head when isolating subagents that need to work on in-progress work, which is our case exactly — branching a subagent from the default branch hides everything its parent has already done. OpenCode's Worktree.create already branches from HEAD, so nothing changes there, and nesting inherits naturally: a grandchild branches from its parent subagent's own worktree HEAD. The open question about marking a worktree as ours is answered by something that already exists: Worktree.create registers the directory through project.addSandbox, and that list is exactly the set opencode created. No new schema, and unlike a path prefix it can't misjudge a cwd the caller points inside our root. Resuming diverges from Claude Code deliberately. CC clears the binding and continues unisolated when a worktree is gone; we recreate, because we only ever remove unchanged ones, so recreating loses nothing — CC is handling trees a user deleted, which may hold work. For us isolation is a safety property of concurrent orchestration, not a convenience. Copying gitignored files in is included rather than deferred: a fresh checkout has no .env, and without it worktree isolation is unusable out of the box on a lot of real projects. Sweeping, run locks and crash orphans are not, and are recorded as gaps.
Five findings, one of them a real escalation path. create takes a cwd from the model, and the draft left it unstated whether that directory also gets an external_directory grant. Granting it would have let a model hand itself access anywhere by spawning a subagent with cwd pointing at it. It does not get a grant: a directory outside the instance still prompts on first use, which is the existing guard doing its job and does not contradict the tools not prompting — what does not prompt is the tool, not access beyond the project. The rest: create's signature never admitted worktree creation could fail, so WorktreeUnavailable joins the error set; AgentInfo had no directory, leaving both create's return and the roster unable to say where an agent works, which matters once agents sit in different trees; copying gitignored files had no failure path and now logs and proceeds rather than failing creation over convenience files; and M4 claimed a postcondition about cleaning worktrees that M6's watch actually performs, so it moves to M6.
Claude Code creates worktrees under .claude/worktrees/ at the repository root. OpenCode's Worktree.create puts them in its data directory, outside the project, which is the only reason the design needed an external_directory grant per worktree — and that grant then needed guarding against a model passing cwd to obtain one for anywhere. Inside the project, containsPath is true and the whole mechanism falls away: no pre-granted rule, and nothing to abuse. What remains is the correct behaviour for an explicitly passed cwd outside the project, which still prompts once. The root ignores itself with a .gitignore containing *, rather than asking the user to edit theirs as Claude Code does. Ignoring is required, not cosmetic: ripgrep honours gitignore by default, so an unignored root would make glob and grep return a copy of every file per worktree. Worktree.create needs a root parameter for this; existing callers pass none and keep the current location.
The previous commit said a nested agent's worktree lives under the creating agent's own worktree, which would lose work: a child's worktree sits inside the parent's and is gitignored, so the parent's git status reports nothing, the parent is judged unchanged, and removing it takes the child's work with it. Claude Code says "at your repository root" — all worktrees are siblings — and that is what avoids this. Read that as "the current worktree's root" and introduced the nesting. Worktrees are now flat under the main checkout. Branching still layers work, since git worktree add runs in the creator's cwd and takes its HEAD; only the location is flat. Also drops two stale lines: the research still told the implementation to pre-grant external_directory for each worktree, and a decision row still cited that grant as the reason no filesystem changes were needed.
Claude Code's Agent tool has no name parameter — the name that SendMessage and TaskStop accept is the agent type's own name, with latest-wins when several share it. So this costs nothing to mirror: agent_send and agent_stop take a target that is either a session_id or an agent type name resolved against the caller's own addressable set. The point is practical. Today a model has to copy a twenty-character opaque id out of a roster exactly, which is a real source of error, and the roster already carries the type name beside it. Resolution cannot widen anything: a name is only looked up inside the set the caller could already address — neighbours for send, direct children for stop — so it stays an alias for one session rather than the parallel identity the research rejected, which was about identifying different things. A name that matches nothing fails with the candidate list attached, so the model can switch to a session_id without asking for the roster again. notify_when_idle is dropped from consideration rather than recorded as a gap: it belongs to cross-session peers, which have no parent to deliver to, and it is main-conversation-only, so a sibling subagent cannot use it in Claude Code either. Recording it as a non-goal would imply we ought to have had it.
Owner
Author
|
两条都已修,复现用例转正为回归测试。 修法:把「等什么」和「等多久」拆开
P0-1:退出条件与 root 解锚,改由"全体安静"驱动,不再依赖一个 depth ≥ 2 下不会到来的 root idle。 P0-2:任何事件都重新求值,等于"复工即重装"; 你的第 3 条(abort 不该杀正在消费结果的 root):ceiling 现在只在 额外一条:已 abandoned 后不再重装 ceiling,改走 settle —— abort 若送不到也不会反复重试。 与你建议的第 1 条有一处不同你建议「任意成员 idle 且 settle 取 1 秒:它覆盖的是 回归测试(
|
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
Both failed on the Windows unit job and nowhere else. The cwd test hard-coded /tmp/somewhere as both input and expectation. Resolving it against the instance directory — which is the point of the change — turns it into C:\tmp\somewhere there, so the assertion now builds the expectation the same way the code does. A relative cwd gets its own test, since that is the case the absolutising exists for: the value is stored on the session and read back later, and a relative one would mean whatever the reader's process directory happened to be. The ceiling test needed a delay and used sleep. The shell behind the bash tool brings its own builtins and sleep is not among them, which is why the existing tests reach for printf; on Windows the command simply failed, the child never paused, and the assertion that the run outlasted its ceiling had nothing to stand on. bun -e with Bun.sleep works the same everywhere.
It failed on the Windows runner for a reason that has nothing to do with the behaviour: the ceiling genuinely fired, because a single subprocess launch there can take longer than the whole 700ms budget, so the gap between the child's events blew past it. The property is a wall-clock one — each gap under the ceiling, the total over it — and that window needs the machine to be roughly predictable. Widening it far enough for Windows would make the ceiling so long the test stops distinguishing a clock that resets from one that was never armed, which is the whole point. Other platforms still cover it, and the behaviour is not platform-specific. cliIt gains a skip that matches how the rest of it is shaped.
The committed spec still described /experimental/session/{sessionID}/background
and ExperimentalCapabilities.backgroundSubagents, both of which this branch
removed, and did not carry the four agent permission keys it added. The console
serves this file, so a stale one is published rather than merely inconsistent.
It regenerates on a push to dev, not on a branch, which is why it drifted. The
output is exactly what that job would produce: 161 paths unchanged, one path
gone, and the four keys added. The revert shape also picks up messageTimeCreated
and partTimeCreated, which are not from this branch — they had been waiting for
the next regeneration.
#32's lost-wake window sits directly under what this branch builds on: a delivered message that lands while a run is finishing had nowhere to go, and agent_send is exactly that shape. Rather than depend on it landing separately, the fix comes in here and its PR closes. The two touch prompt.ts and its tests in different places and merge without conflict. Nothing here depended on the old behaviour: shouldReArm is optional and absent means what it meant before, and the run-loop ceiling in run.ts reads status events either way — a re-armed run reports busy again rather than going idle, which the ceiling already handles.
3 tasks done
fmv1 was the baseline set in #31 after the upstream sync. This branch adds the agent management surface and carries the idle-race fix from #34, so the fork suffix moves on. The upstream base is unchanged at 1.18.25, and package.json still belongs to upstream's sync release versions commits — the build reads OPENCODE_VERSION and nothing else, so FORK_INSTALL.md is the only place this lives.
**Giving up could still hang.** The ceiling's callback set the flag and fired the abort and then armed nothing, while the branch that was supposed to end the run lived in reconsider — which only runs on an event. An abort that fails, or that reaches a session already gone, produces no event, so nothing was left to end the run. The comment there claimed the settle would handle it, which was false in exactly the case it named. The callback now decides again immediately rather than waiting to be asked. **A slow handoff outran the grace period.** One second was a guess and a 1.4-second notice beat it, taking the result with it. Raising it alone would have charged every run that used a subagent, so instead it is only spent when a child has just gone quiet with nobody having picked the work up — the ordinary case, where the parent consumed the result and went quiet last, pays nothing. That makes five seconds affordable, and OPENCODE_RUN_AGENT_SETTLE_MS overrides it. No fixed value can be proven right; what changed is who pays for it. **The sibling snapshot ignored a subagent's own agent_list deny.** It checked the derived session ruleset, and deriveSubagentSessionPermission deliberately leaves the subagent's own permissions out of that — they are merged at tool resolution. So a subagent that had opted out read as allowed. It merges both halves now, the way session/tools.ts does. This is the same mistake the roster had, repeated in code written after fixing it there. **A neighbour's name could forge a row in that snapshot.** render, renderTermination and the roster all encode their model-supplied fields; the snapshot interpolated them raw, so a newline in a name added a line to a list the reader is meant to trust. Tests for the last two. The first two keep the coverage they had: making an abort fail inside a real subprocess has no injection point, and the handoff window is the thing the grace period exists to paper over.
A one-shot run must not leave while a subagent's result is still travelling, and the result does not appear where it was started: the subagent finishes, its job settles, a watcher writes the result into the caller's session, and the caller wakes for another turn. Between the subagent going idle and the caller going busy, every session is idle and nothing is running. A client reading the event stream sees a finished tree that is not finished — and a cancelled subagent, whose result is never coming, looks exactly the same. Nothing in the stream distinguished them, so `opencode run` guessed at a duration: wait a moment after everything goes quiet, in case. Any fixed moment loses to a slower handoff, and losing meant exiting 0 with the result missing, which is the one outcome the wait exists to prevent. One second lost to 1.4; five lost to 5.5. So the delegation says it itself. agent.delegation carries `started` when it is registered and `settled` once the result has actually reached the caller — after the delivery, not after the job settles, because settling is where the gap opens rather than where it closes. Published only for delegations that report back on their own: a caller awaiting the result itself never leaves the gap, and the pair would be a debt nothing settles. run.ts keeps the ledger instead of a clock. Quiet and nothing owed ends the run at once, including a run that started no subagent. Quiet with something owed waits, and if it never arrives that is what the ceiling is for — the run says so rather than leaving successfully without it. No duration constant remains. This is the contract Claude Code has natively: stay open until the work completes, and give up only on continuous idleness. It can read completion directly because the subagent runs in its own process; across a client and a server the same fact has to be sent. The `started` publish is awaited and placed before the job starts. Forking it makes a heavy test pass, but it would then race the job's own events, and a `started` arriving after its `settled` leaves an entry nothing clears — the ordering cannot rest on timing, which is the whole point of the change. What that costs is real: publishing awaits every listener, and one test that holds a delegation open while it asserts no longer fits the default budget, so it gets its own.
The manifest test pins the number of public wire types so that adding one has to be deliberate. It is, so the count moves — and the new type is named alongside it, since a gate that only counts can be passed without anyone reading what went through.
The Windows unit job takes forty minutes, and the opencode package is nearly all of it — 1999 seconds out of 2038. It runs its CLI tests one at a time there against real subprocesses, deliberately, and turbo is also held to one package at a time. So the wall clock comes down by splitting the work rather than by loosening either of those, which were set for reasons worth understanding before changing. Only opencode is split. Everything else is around forty seconds in total and runs once, on the first shard, along with the generated-client check and the HttpApi gates, which would otherwise run three times. Checked rather than assumed: bun splits by test file and the parts add up; turbo forwards the argument and hashes it, so a later shard cannot pick up an earlier one's cached result — the hashes differ, which is what makes this safe to run through turbo at all. Two things this costs. The check names become "unit (linux 1/3)" and so on, so any branch protection naming them needs updating. And each shard pays the setup again — six minutes on Windows, since that runner installs from a cache that has never been populated — so runner minutes go up by about a quarter while the wait roughly halves.
Both timed out on the Windows shard at thirty seconds while passing on Linux and locally. Neither is a hang: one runs a real shell command with two loops around it, the other boots a real instance. That is process spawning and filesystem work, and the Windows runner is slow at both while the job around it is saturated. Ninety seconds rather than a marginal bump, so this does not come back as a flake at the next slow run. The reason is on each of them, since a raised timeout with no explanation is indistinguishable from one raised to hide a problem.
Two of them ask the session to run "sleep 0.2". The shell behind that brings its own builtins and sleep is not among them, so on Windows the command failed and the test sat waiting for a run that was never going to finish. bun with Bun.sleep works the same everywhere, and the tests keep their Windows coverage rather than being marked POSIX-only — what they check, that a loop waits for a shell and that completion resumes queued callers, has nothing to do with the platform. The two nearby tests that shell out to sleep 30 are already guarded; these two were missed. The previous commit raised a timeout to ninety seconds to fix this and raised the wrong test's — the one below it, which needs nothing. Reverted. It would not have helped anyway: the test was not running out of time, it was waiting on a command that had already failed, and a longer budget only makes that wait longer. CI reporting thirty seconds when the source said ninety is what gave it away.
The inbox test that asserts deliver returns without awaiting the target's turn wrapped deliver in a 2-second budget. deliver reads the default agent, and that first read builds Agent's instance state, which scans the filesystem for skill directories -- 334ms here, and on a Windows runner enough on its own to consume the whole budget, so the test failed claiming deliver had blocked when it had not. The read is now done before the timed region: deliver's own work in it goes from 334ms to 3ms, so the budget measures what the test is about.
The subagent card asked the tool part whether the subagent was still working. Delegation is asynchronous, so the agent call completes the moment the child starts: the card showed a tick immediately, never spun, never showed the running tool line, and printed a duration of zero until the result arrived and the memo recomputed. The card already had the right branch -- it was gated on a background flag that the old task tool wrote only for its background mode, its foreground mode having held the part running for the whole run. agent is always asynchronous and writes no such flag, so both branches were false. The gate is now the presence of a child session id, and the tick and the duration line share one notion of done. An unknown child status still reads as finished, so an old transcript does not spin forever.
This was referenced Sep 18, 2026
agent_list renders name, agent_type and title into a tab-separated table. All three originate with the model, and in that table a tab separates the columns and a newline separates the rows -- so a name carrying either closes its own row early and opens one that says whatever it likes, session_id included. That is the field the table's own closing line tells a reader to route on. The same encoding guards the message header, the sibling snapshot and the termination notice. This was the fourth renderer and the only structured one, which is where a separator does the most damage, and it was the one left out. Found by manual testing: an agent named with a real newline broke its row in half in the live output. Not an escalation -- a name is chosen by the parent, which already controls its child's prompt -- but it makes the table unparseable and leaves the one place a separator matters unguarded.
yixiao5428
added a commit
that referenced
this pull request
Sep 22, 2026
…e items Visual truncation of the notice line (#39), in-dialog key handling and existing navigation-key regressions (#38), selector hiding, mid-run message delivery, file parts through the new payload, and the permission-hoisting known limitation (#37). Also scopes the checklist away from #35's known outstanding issues. Co-Authored-By: Claude <[email protected]>
yixiao5428
added a commit
that referenced
this pull request
Sep 22, 2026
Subagent sessions were read-only in the TUI: visible() demanded a root session. Opening the input naively would route the globally selected agent/model into createUserMessage, whose setAgentModel write-back would persist them over the subagent's own identity — the same P0 class #35 fixed for agent_send. The inbox now takes a tagged InboxMessage: "agent" carries agent_send and stop traffic through the existing render(); "user" carries the human's parts behind a fixed [Message from user] header. Both flow through one identity resolution — the target's own agent/model/variant, "default" folded away — so the rule that a delivered message never rewrites the target keeps exactly one owner (I1). The new endpoint accepts parts and nothing else: a pick, not an omit, because omitting from PromptPayload would still admit noReply, which persists a message without ever running the loop. The TUI wires the takeover through a Prompt prop that reroutes submission ahead of the shell/slash/prompt branches and hides the agent/model readout; the route injects it only for child sessions and calls the regenerated sdk.client.session.agentMessage. Old transcripts and the root-session path are unchanged. Co-Authored-By: Claude <[email protected]>
yixiao5428
added a commit
that referenced
this pull request
Sep 24, 2026
Verification findings P1+P2 from the beta's manual pass. P1: the list becomes the single navigation surface. Members are the whole tree — Main (the root), every subagent, and the session being viewed, with the current one preselected via DialogSelect's `current` and tagged in its description; the root renders as "Main". The SubagentFooter loses its Parent/Prev/Next buttons (info line stays) and the up/left/right/ <leader>down bindings go back to the editor, along with the hidden commands and their handlers. The transcript hint now reads "<down> view agents". P2: subagent tool rows no longer label themselves "Task" — the tool was removed in #35 and the word must not resurface; the agent type leads the title, background keeps its marker. Commit 2's exclusion of the root and the "no subagents, no UI" behavior are overturned by verified usage feedback; design doc, expectations (INV-4) and the manual checklist §2 are amended accordingly. Co-Authored-By: Claude <[email protected]>
3 of 6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue for this PR
Closes #23
Closes #32 — the fix from #34 is merged into this branch
Type of change
What does this PR do?
A model can launch subagents, see them, message them and stop them, through four tools:
agent,agent_list,agent_send,agent_stop. Thetasktool is gone.The shape. Delegation is always asynchronous.
agentreturns once the subagent has started and its result arrives later as a message; there is no foreground mode.agent_sendis one-way — the recipient answers by callingagent_sendback, or not at all.agent_stopcascades to everything the target launched and deletes nothing, so a stopped agent resumes withagent_send. Nesting goes three deep by default; at the limit the spawning and stopping tools are not offered rather than offered and then refused.Each subagent gets its own git worktree under
.opencode/worktrees/and is told to use it. That is advisory, not a sandbox: it can still reach the rest of the project by absolute path. Enforced isolation needs a filesystem root that is decidable per session, which V1 does not have (#33).Three judgements this reversed along the way.
Type names as addresses. The first design let
agent_sendtarget a subagent type, picking the most recent on a tie, on the grounds that Claude Code does the same. It does not — CC disambiguates with a per-row ref or errors. Silently picking one means messaging the wrong agent with nobody the wiser, and fan-out is the main use of subagents, so type names fail exactly where they are needed. An optional instance name replaces it. Names are weak: concurrent creation may duplicate one, and resolution then returns every candidate and refuses.A general execution layer. An earlier round added a module owning execution registration and outcome delivery for every message. It rested on each message having an outcome, which is not true — a recipient may simply read a message and carry on. Once
agent_sendbecame a message rather than a call the premise went, and the module with it. Only the initial delegation keeps an automatic result, which is the one place a result means something.Notifying every parent in a stop cascade. A review proposed it and accepted that a late notice could wake an agent that had just been cancelled. Delivery is forked and the caller proceeds straight to cancelling that parent, so that is a coin flip rather than a narrow race. Every member's parent is inside the stop set except the target's — and that one is the caller, outside it and awake because it is the one calling. So exactly one notice goes out and nothing can be resurrected. What the silence costs comes back as a subagent list written into the transcript when it changes.
Bugs found while building it.
createUserMessagetakes that definition's model with it and persists the result. A subagent pinned to a model the parent could not resolve took the parent's own loop down.opencode runthrew away its delegations. It left the moment the root session went idle, so the agent's result — which arrives as a message the session still has to answer — was dropped and a half-finished agent abandoned. It waits now, following whatclaude -pdoes.PWD ?? cwd, and PWD was inherited from the shell — so subprocess tests resolved their project to this repository and wrote there.Bugs found by review, after the first round. Each has a regression test;
docs/fixes/agent-management-fix-pr35-review.mdcarries the analysis.taskrules were moved ahead of any explicitagentkey, which also moved them past whatever sat in between — a wildcard included.{task: "allow", "*": "deny", agent: {reviewer: "allow"}}migrated into a ruleset where an unnamed subagent came out allowed, having been denied. It is a rename in place now, so the sequence is identical position for position.agent_sendran the target in the sender's instance.prompt_asyncmeans a fork and a switch to the target session's instance; only the fork was reused, so a session in another directory executed against the sender's directory, config and permissions. Both callers now go through one entry point./reviewsummarised the confirmation that a subagent started rather than what it concluded. The command path waits on the same delegation, with the notice suppressed so the parent hears once rather than twice.cwdstored as given, a timestamp as the workspace name, and an exclude pattern anchored to the repository root while the directory was not.Bugs found by running it. Neither was reachable from CI; both came out of working through the feature by hand.
agentcall completes as soon as the child starts: a tick appeared straight away, the spinner and the live tool line never appeared, and the duration read zero until the result arrived. The card already had the right branch — it was gated on abackgroundflag that the oldtasktool wrote only for its background mode, its foreground mode having held the part running for the whole run.agentis always asynchronous and writes no such flag, so both branches were false. The gate is the presence of a child session id now, and the tick and the duration share one notion of done.agent_listbuilt its table out of unencoded model input.name,agent_typeandtitleall originate with the model and went into a tab-separated table raw — where a tab separates the columns and a newline separates the rows, so either one closes its own row early and opens one saying whatever it likes,session_idincluded, which is the field the table's own closing line tells a reader to route on. The same encoding already guarded the message header, the sibling snapshot and the termination notice; this was the fourth renderer and the only structured one, which is where a separator does the most damage. Not an escalation, since a name is chosen by the parent and the parent already controls its child's prompt, but it left the table unparseable and the one place a separator matters unguarded. Found by an agent deliberately named with a real newline: its row broke in half in the live output.Three gaps are recorded rather than closed, all in the TUI's subagent surface: a subagent session cannot be typed into (#37), there is no subagent list — only blind cycling with
left/right(#38), and Agent notices render three different ways, with the completion notice invisible andagent_sendand cancellation in full (#39).Permissions. The key is
agentand its patterns are subagent type names;agent_list,agent_sendandagent_stoptake their own keys. Legacytaskconfig is renamed toagentin place when config is read, because dropping it would widen permissions for anyone who had writtentask: deny. Two default denies on spawning are gone — either would have made depth 3 useless, since the session ruleset is merged after the agent definition andevaluatetakes the last match.#34 is merged into this branch. The lost-wake window it fixes (#32) sits directly under what this builds on: a message delivered while a run is finishing had nowhere to go once that run went idle, and
agent_sendis exactly that shape. Rather than have this depend on a separate merge, the fix comes in here and its PR is closed as merged. The two touchprompt.tsand its tests in different places and merge without conflict, and nothing here relied on the old behaviour —shouldReArmreaches every session run through the only two callers there are, and the run-loop ceiling reads status events either way, since a re-armed run reports busy again rather than going idle.One unrelated change is included.
test(app): retire legacy layout e2e coverageis cherry-picked verbatim from upstream7f209437b5. The app's old interface retired on 2026-09-14 (oldInterfaceSunsetinsettings.tsx), after whichresolveNewLayoutDesignsreturns true whatever the stored preference says, so two e2e tests that asserted the old layout began failing on that date. This branch's first CI run was the same day, which is why it looks like ours;devfails the same way today. Upstream fixed it on the day and taking their change avoids diverging over it.How did you verify your code works?
Around 110 tests added across 30 files. The ones worth naming are the regressions, each checked to fail without its fix:
notifyflag leaking through a forked fiber into the whole subtree.Two ceiling cases are deliberately not written, with reasons recorded in the fix document: a lost notification needs the child's job to settle
cancelled, which no fake-LLM path produces, and a failing abort has no injection point in a real subprocess.CI is the check that counts here. Locally the HttpApi SDK suite fails on this machine for an unrelated reason —
/global/healthreturns 503 from workspace routing — and it fails identically on a clean checkout of the branch point, so it is not a signal either way.Screenshots / recordings
Not a UI change. The only frontend edits teach the session timeline, the share view and the tool error card to recognise
agentwhile still renderingtask, so transcripts recorded before the rename keep their cards.Checklist
Known limitations
Working directories accumulate; V1 never removes them (#33 tracks enforcement, cleanup waits for V2). A message is accepted rather than guaranteed delivered, which is the existing async channel's existing strength.
agent_sendaddresses the current server's session namespace.Session.getis a primary-key lookup against the local database, so an id belonging to another server is simply not found and reports as such; there is no cross-server addressing to support or to refuse.