Skip to content

feat(opencode): agent management surface - #35

Merged
lihaokun merged 102 commits into
devfrom
agent-management
Sep 19, 2026
Merged

lihaokun merged 102 commits into
devfrom
agent-management

Conversation

@lihaokun

@lihaokun lihaokun commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

Issue for this PR

Closes #23
Closes #32 — the fix from #34 is merged into this branch

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

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. The task tool is gone.

The shape. Delegation is always asynchronous. agent returns once the subagent has started and its result arrives later as a message; there is no foreground mode. agent_send is one-way — the recipient answers by calling agent_send back, or not at all. agent_stop cascades to everything the target launched and deletes nothing, so a stopped agent resumes with agent_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_send target 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_send became 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.

  • A completion notice could rebind its parent. The notice fell back to the child's agent type when the parent had not bound one, and createUserMessage takes 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 run threw 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 what claude -p does.
  • Depth 3 would have hung on permissions. The TUI collected permission and question requests one level down, which was right while nesting was off. A request from a grandchild appeared nowhere, so that agent waited on an answer no one could give.
  • The CLI test harness was not isolated. It sets cwd and a careful set of HOME and XDG variables, but opencode resolves its project from 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.md carries the analysis.

  • Legacy permission migration widened permissions. task rules were moved ahead of any explicit agent key, 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_send ran the target in the sender's instance. prompt_async means 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.
  • A sender name could forge a message header. Header fields are model-supplied and were interpolated raw. They are encoded now, backslash first so the encoding stays injective.
  • The one-shot run's wait was wrong in two ways. The ceiling cleared whenever anything was busy, so a child stuck in busy — producing no events at all — was the one case it could not catch. And the exit test was anchored on the root's own idle, which at depth one is sufficient and deeper than that is not, since a grandchild reports to its own parent and the root is never told; a finished nested tree hung forever. Waiting is now split by what it is waiting for: something busy but silent keeps the long ceiling and ends with an abort and a report; everything quiet gets a short settle and a clean exit.
  • /review summarised 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.
  • workdir had three unrelated defects: a relative cwd stored 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.

  • The subagent card reported every delegation finished the moment it started. It asked the tool part whether the subagent was still working, and delegation is asynchronous, so the agent call 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 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 the presence of a child session id now, and the tick and the duration share one notion of done.
  • agent_list built its table out of unencoded model input. name, agent_type and title all 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_id included, 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 and agent_send and cancellation in full (#39).

Permissions. The key is agent and its patterns are subagent type names; agent_list, agent_send and agent_stop take their own keys. Legacy task config is renamed to agent in place when config is read, because dropping it would widen permissions for anyone who had written task: 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 and evaluate takes 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_send is 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 touch prompt.ts and its tests in different places and merge without conflict, and nothing here relied on the old behaviour — shouldReArm reaches 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 coverage is cherry-picked verbatim from upstream 7f209437b5. The app's old interface retired on 2026-09-14 (oldInterfaceSunset in settings.tsx), after which resolveNewLayoutDesigns returns 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; dev fails 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:

  • the permission migration test asserts the resulting sequence, not just one outcome, because only particular interleavings go wrong;
  • the cross-directory routing test runs two real instances and asserts the directory the run actually used — forcing the routing branch off makes it fail with the sender's directory in place of the target's;
  • the nested-tree test runs on the shipping ceiling, because with a short one it passes by timing out rather than by converging;
  • a command subtask's own subagent still reporting back to it, which is what would have caught the notify flag 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/health returns 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 agent while still rendering task, 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_send addresses the current server's session namespace. Session.get is 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.

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.
@lihaokun

Copy link
Copy Markdown
Owner Author

两条都已修,复现用例转正为回归测试。

修法:把「等什么」和「等多久」拆开

run.ts 的等待循环改为在任何被跟踪 Session 的事件后重新求值:

状态 上界 终局
root 在出活 无 那是 run 自己的输出
有人忙但不出声 长 ceiling abort + 如实报告
全体安静 短 settle(1 秒) 干净退出,不报"放弃"
从未创建过子 Session 无 root 一 idle 即退出

P0-1:退出条件与 root 解锚,改由"全体安静"驱动,不再依赖一个 depth ≥ 2 下不会到来的 root idle。

P0-2:任何事件都重新求值,等于"复工即重装";session.created 也补进了活动事件。

你的第 3 条(abort 不该杀正在消费结果的 root):ceiling 现在只在 rootIdle 为真时武装,故 abort 不可能落在那种时刻。abort root 本身仍是让取消级联到子的方式,改的是时机不是目标。

额外一条:已 abandoned 后不再重装 ceiling,改走 settle —— abort 若送不到也不会反复重试。

与你建议的第 1 条有一处不同

你建议「任意成员 idle 且 working.size === 0 即退出」。解锚照做,立即退出改成了短 settle:effect/runner.ts 的 finishRun 里 yield* idle 排在 complete(done, exit) 之前,所以子 idle 到达 CLI 时 job 尚未结算、通知尚未投递,此刻退出会丢掉该 run 存在的理由。

settle 取 1 秒:它覆盖的是 deferred 结算 → fork → 父转 busy 这段纯进程内交接,量级毫秒;1 秒是三个数量级余量,且每个用到子 Agent 的 run 只付一次。

回归测试(run-process.test.ts,28/28)

  • 嵌套树跑完即退出 —— 用出厂默认 ceiling,短 ceiling 会让它因超时而假通过;
  • 子先干活再挂起 → ceiling 仍触发;
  • 子持续出活(间隔 < 上限、总时长 > 上限)→ 不被误杀;
  • OPENCODE_RUN_AGENT_WAIT_MS=0 → 树跑完仍 exit 0(0 关掉的是"放弃",不是"离开")。

@github-actions

Copy link
Copy Markdown

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.
lihaokun and others added 3 commits September 16, 2026 15:25
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.
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.
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.
@lihaokun
lihaokun merged commit df73187 into dev Sep 19, 2026
15 checks passed
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]>
@lihaokun
lihaokun deleted the agent-management branch October 14, 2026 16:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants