Skip to content

OpenCode native subagent tracking - #2390

Draft
peyton-alt wants to merge 22 commits into
mainfrom
feat/opencode-subagent-tracking
Draft

OpenCode native subagent tracking#2390
peyton-alt wants to merge 22 commits into
mainfrom
feat/opencode-subagent-tracking

Conversation

@peyton-alt

@peyton-alt peyton-alt commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

https://entire.io/gh/entireio/cli/trails/1316

Summary

  • track OpenCode task tool subagents as durable task records on the parent session, one record per task call, with the child's exported transcript and exact token usage
  • stop OpenCode child sessions from registering as top-level Entire sessions (they previously took the checkpoint while the parent logged "no files modified")
  • two generic lifecycle changes: Event.DeferredCompletion records an in-flight marker at a fully-identified start, and event-files-only capture is keyed on SubagentTranscriptUnavailable rather than CompletionWithoutLaunch (Copilot sets both, so it is unchanged)
  • research probe script and one-pager (cmd/entire/cli/agent/opencode/AGENT.md) capturing the verified OpenCode 1.18.30 contract

How it works

The embedded plugin learns child sessions from parentID on session.* events and from the parent's task-part metadata, and forwards no lifecycle hooks for them. It fires subagent-start from the parent's task part once it is running with state.metadata.sessionId, and subagent-stop synchronously from tool.execute.after. The Go adapter exports the child with opencode export, declares that file as the transcript, attaches its token usage, and emits a Final + CompletionWithoutLaunch SubagentEnd; the shared final capture extracts files from the declared transcript and completes the record exactly once. Condensation materializes tasks/<callID>/{task.json, agent-<child>.jsonl} under the parent checkpoint.

Verified

  • Real OpenCode 1.18.30 sessions before the change (single, two concurrent children, read-only child): contract captured, and the bug reproduced every time.
  • Real sessions with the candidate binary (google/gemini-2.5-flash-lite; the Anthropic key stored in OpenCode on this machine is rejected and Gemini Flash hit its free-tier quota): one Entire session, checkpoint on the parent titled with the user's prompt, tasks/<callID>/task.json + child transcript materialized, exact tokens, two concurrent children kept distinct with disjoint files.
  • Unit, integration (TestOpenCodeSubagentTaskRecord), and Vogon canary pass. mise run lint clean.
  • The paid OpenCode TestSubagentCommitFlow e2e now asserts a task record and a single session; not run in this PR.

Judgment call to confirm

Child sessions are suppressed plugin-side, so a user who opens a child session directly in the OpenCode TUI and drives it by hand is not tracked at all (no session, no task record). That is deliberate for v1 and documented in AGENT.md; flagging it here so a human can move it.

Known gaps

  • Background subagents (OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS) are out of scope and ignored.
  • Parent-level SubagentTokens aggregate is a follow-up; record-level tokens are exact.
  • On macOS a child that spells a path through an unresolved symlink (/var/folders/... vs git's /private/var/...) is dropped from the record's files by the shared normalizer; the parent's turn-end still attributes the file. Agent-agnostic.
  • A failed opencode export at stop time completes the record as transcript-unavailable; a lazy fetch at condensation is a follow-up.
  • TestRunStatus_CheckpointPushDisabledDestinations fails on origin/main too (unrelated).
  • Seven commits by implementer subagents carry a Claude Sonnet 5 co-author trailer; a squash merge normalizes it.

🤖 Generated with Claude Code


Note

Medium Risk
Changes checkpoint/session attribution for OpenCode subagents and shared subagent final-capture semantics; failures degrade to transcript-unavailable records rather than blocking the parent session.

Overview
Adds native OpenCode task subagent tracking so child sessions no longer become separate Entire sessions or steal checkpoints from the parent.

The embedded plugin (entire.ts) tracks childSessions from parentID and task metadata, suppresses all child lifecycle hooks (including context injection), and fires sync subagent-start (parent task part running with sessionId) and subagent-stop (tool.execute.after for foreground task).

The OpenCode Go adapter parses those hooks, sets DeferredCompletion on start and Final + CompletionWithoutLaunch on stop, **opencode export**s the child for transcript path and token usage, and validates subagent IDs.

Shared lifecycle gains recordDeferredTaskLaunch / Event.DeferredCompletion (in-flight task marker, no pre-task baseline) and keys event-files-only final capture on SubagentTranscriptUnavailable instead of CompletionWithoutLaunch so OpenCode can still scan a declared child export.

Docs, probe script, integration/e2e tests, and plugin template tests cover the contract.

Reviewed by Cursor Bugbot for commit a1bc976. Configure here.

peyton-alt and others added 21 commits September 11, 2026 14:50
Record how OpenCode's task tool exposes child sessions to a plugin and how
the current integration mishandles them, ahead of implementing task records
for OpenCode.

Captured live against OpenCode 1.18.30 with a probe plugin planted beside
Entire's own: tool.execute.before(task) on the parent, session.created for
the child carrying parentID, the parent's task part binding callID to the
child session ID in state.metadata, the child's full turn, then
tool.execute.after(task) with the same metadata. The child is a real session
that `opencode export` returns with exact per-message tokens.

Today the plugin treats the child as a new top-level session: the checkpoint
lands on the child, the parent logs "no files modified", and the parent's
hooks re-fire mid-turn. The one-pager proposes the event mapping and lists
the gaps (background tasks, task_id resumption).

The probe script builds an isolated repo, links the global plugin deps when
the version matches, records every plugin signal to events.jsonl, and prints
a timeline plus Entire's view after a commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entire-Checkpoint: 01M297724RX5YB0M87H3CW5XN3
…y children

Add --scenario to the probe (single, concurrent, readonly) and record the
two extra live runs. Two children launched in one message keep distinct
callID→child bindings through the task part metadata, and a read-only
explore child emits the same signals with no file parts. Both still
surface today as separate top-level Entire sessions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entire-Checkpoint: 01M29A38G9K2ZV6X1JGYV7VV81
…nt tracking

One task record per `task` tool call on the parent session, keyed on the
call ID, with the child's exported transcript declared and its exact tokens
attached. The plugin learns child sessions from parentID and stops
forwarding their lifecycle, fires subagent-start from the parent's running
task part and subagent-stop from tool.execute.after. Two generic lifecycle
changes: Event.DeferredCompletion records an in-flight marker at start, and
event-files-only capture is keyed on transcript unavailability rather than
on CompletionWithoutLaunch, which leaves Copilot unchanged.

Plan reviewed chunk by chunk against the code: mock analyzer agent shape,
macOS realpath normalization in tests, dupl-safe launch-marker helper, the
committed dogfood plugin copy, GetSessionState's nil-nil contract, and the
existing CopyTranscriptToEntireTmp helper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entire-Checkpoint: 01M29BBH8RHNJE2DTC115VK365
docs/superpowers is gitignored on purpose — the Copilot and Codex planning
documents there were never committed — and the previous commit force-added
these two against that convention. Untrack them; they stay on disk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entire-Checkpoint: 01M29BF09G4XSKZGSMR729JT0X
…apes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…arts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mise run lint runs shellcheck at its default level, which flags the
A && B || C idiom (SC2015) that the probe used for its PASS/WARN lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entire-Checkpoint: 01M29DM6V90DK7BQ0PF8XEVATG
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds the HookNameSubagentStart/HookNameSubagentStop constants and the
subagentStartRaw/subagentStopRaw payload types the plugin will send for
OpenCode's task tool. Not yet wired into ParseHookEvent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The parent's `task` tool part binds the tool call ID to the child
session ID before the child finishes, so subagent-start records a
DeferredCompletion marker rather than a normal launch; completion
arrives separately from subagent-stop (tool.execute.after).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…script

subagent-stop fires from tool.execute.after, once, at true completion,
so it is the authoritative final capture regardless of whether the
start hook was ever seen. It exports the child session, declares the
transcript path on the event, and computes its token usage; an export
failure degrades the record (SubagentTranscriptUnavailable) rather
than dropping the completion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t comments

Collapse subagentStartRaw/subagentStopRaw into one subagentRaw type and
extract parseSubagentPayload (read + validate + resolve parent
transcript path) so both ParseHookEvent cases only build their event.

Sharpen the SubagentTranscriptUnavailable doc comment and the
eventFilesOnly comment in handleSubagentStopFinal to cover both causes
(Copilot CLI's contract vs. OpenCode's failed fetch), and correct the
attachSubagentTranscript comment: OpenCode does implement
TranscriptFetcher, but neither the SessionEnd sweep nor condensation
calls it, so a failed export here permanently loses a transcript that
still exists in OpenCode's store. Note the follow-up (lazy
FetchTranscript at condensation).

Update AGENT.md's proposed mapping table to match what was actually
implemented and drop the now-false "single-signal agent" caveat.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…session

Task launches spawn a real child OpenCode session, and the plugin previously
treated it like a new user session — firing session-start/turn-start/turn-end
for it and putting the checkpoint on the child instead of the parent. Learn
child session IDs from parentID on session.created/session.updated and guard
the event switch (and the one-time context injection) so a child never fires
lifecycle hooks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fire subagent-start from the parent's task tool part once it reaches
running with the child session ID bound in state.metadata.sessionId — the
first signal that safely joins the tool call to its child, since
tool.execute.before has no child ID yet and session.created events for
sibling tasks can interleave. Fire subagent-stop synchronously from
tool.execute.after on true completion (skipping background tasks and a
child's own nested task calls), since `opencode run` exits on the parent's
idle right after and an async hook would be killed before finishing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…adata

Code review fixes on the child-session tracking added for subagent hooks:

- Gate the parentID-learning line on event.type.startsWith("session.") —
  AssistantMessage.parentID names the parent USER MESSAGE, so an unguarded
  check added a msg_… id to childSessions on every assistant message.updated,
  growing the set unboundedly.
- Also learn child identity from the two places that positively name it: the
  parent's task part (state.metadata.sessionId) and tool.execute.after's
  output.metadata.sessionId, so a child whose own session.created this
  process never saw (resumed, or after a restart) is still suppressed.
- Stop clearing childSessions/announcedTasks on session.deleted: a child's
  own deletion never reaches that case (the guard returns first), so the
  clear only ever fired for an unrelated top-level session, which would drop
  a live child back to top-level mid-task. The sets now live for the process
  and are cleared only on server.instance.disposed.
- Reuse the hoisted props/info in the switch cases instead of re-deriving
  them, and drop the session.status case's shadowing local `props`.
- Wrap tool.execute.after's body in try/catch, matching the event handler.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entire-Checkpoint: 01M29H9HHNAQZHQZK3SDZT901D
Some models' write tool refuses to create parent directories, and the
child then asks for permission instead of writing, which leaves nothing
to commit and skips the checkpoint half of the probe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entire-Checkpoint: 01M29HBFAMXZKR7TQ7PND9EKJP
Adds SimulateOpenCodeSubagentStart/Stop to the OpenCode hook runner and
TestEnv, and a new integration test driving the real hook binary:
subagent-start leaves an in-flight task record, subagent-stop completes
it with the child's exported files and exact tokens, a stop with no
prior export leaves the record transcript-unavailable, the child never
gets its own session state, and the commit materializes task.json and
the child's transcript under the parent checkpoint's tasks/ subtree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entire-Checkpoint: 01M29HK5E0R20JAN74PJ3RN6ZW
Extends the copilot-cli task-record assertion to opencode, and adds a
session-count check that the child never becomes a session of its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Documents the shipped subagent-start/subagent-stop hooks in the agent
guide's event table and "declaring a subagent transcript" section, adds
an OpenCode Producers entry to sessions-and-checkpoints.md, and updates
the OpenCode AGENT.md one-pager from "proposed" to "implemented": the
mapping table, the plugin-side suppression rationale (including that a
TUI-driven child session is fully suppressed too), and a new Gaps note
about the macOS unresolved-symlink path-normalization limitation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s, tighten docs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entire-Checkpoint: 01M29JPH73P7SG607MDK7SZZ9J
Copilot AI lite review requested due to automatic review settings September 12, 2026 01:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

One critical and four moderate findings remain unresolved, along with a documentation nit.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds native OpenCode subagent tracking, preserving child transcripts and token usage as task records on the parent session.

Changes:

  • Adds plugin hooks and child-session suppression.
  • Exports transcripts and records exact token usage.
  • Extends shared lifecycle handling for deferred and unavailable completions.
  • Adds integration, E2E, documentation, and probe coverage.

Open findings: Critical (1 vote): process-wide call-ID guards can suppress new sessions. Moderate (1 vote): the embedded plugin has the same issue. Moderate (3 votes): staged export read failures can leave records incomplete. Moderate (1 vote): token-calculation failures have the same failure mode. Moderate (1 vote): resumed child sessions can produce cumulative per-call records. Nit (1 vote): documentation incorrectly promises recursive nested-task mapping.

File summaries
File Reviewed change
e2e/tests/subagent_commit_flow_test.go Verifies one parent session and materialized task records.
docs/architecture/sessions-and-checkpoints.md Documents OpenCode task-record lifecycle.
docs/architecture/agent-guide.md Updates agent lifecycle and transcript guidance.
cmd/entire/cli/lifecycle.go Adds deferred task markers and transcript-aware final capture.
cmd/entire/cli/lifecycle_test.go Tests deferred completion and declared-transcript capture.
cmd/entire/cli/integration_test/opencode_subagent_test.go Covers native OpenCode task-record integration.
cmd/entire/cli/integration_test/hooks.go Adds OpenCode subagent hook test helpers.
cmd/entire/cli/agent/opencode/types.go Defines OpenCode hook and export payload types.
cmd/entire/cli/agent/opencode/lifecycle.go Parses hooks, exports child transcripts, and attaches tokens.
cmd/entire/cli/agent/opencode/lifecycle_test.go Tests OpenCode lifecycle and export behavior.
cmd/entire/cli/agent/opencode/hooks.go Registers OpenCode lifecycle hooks.
cmd/entire/cli/agent/opencode/hooks_test.go Tests plugin hook behavior.
cmd/entire/cli/agent/opencode/entire_plugin.ts Tracks tasks and suppresses child sessions.
cmd/entire/cli/agent/event.go Adds deferred-completion and transcript-availability fields.
.opencode/plugins/entire.ts Updates the dogfood plugin implementation.
Review details

Suppressed comments (4)

cmd/entire/cli/agent/opencode/AGENT.md:163

  • These lines claim nested task mapping is recursive, but the plugin returns early for every event whose session is already in childSessions; a task started by a child therefore emits no lifecycle hooks and cannot be mapped recursively. Please document nested subagents as currently suppressed/out of scope, or implement child-to-grandchild propagation before promising recursive support.
- **Nested subagents** are off by default (`subagent_depth: 1`); when enabled,
  `parentID` chains and the same mapping applies recursively.

cmd/entire/cli/agent/opencode/entire_plugin.ts:264

  • announcedTasks is process-scoped and is only cleared on server disposal, but OpenCode callIDs are not globally unique. If a new parent session reuses a call ID from an earlier session, this guard suppresses its subagent-start, so no deferred/in-flight task record is created (the stop path can only reconstruct it later). Key this set by the parent session ID plus call ID, or clear it when switching parent sessions.
            if (part.type === "tool" && part.tool === "task" && part.callID &&
                part.state?.status === "running" && part.state?.metadata?.sessionId &&
                !announcedTasks.has(part.callID)) {
              announcedTasks.add(part.callID)
              childSessions.add(part.state.metadata.sessionId)

cmd/entire/cli/agent/opencode/lifecycle.go:265

  • A token-calculation failure has the same incomplete-record failure mode as the read failure above: the declared transcript remains present, so final capture still attempts analysis and can fail rather than completing the task without token metadata. Set SubagentTranscriptUnavailable before returning so this path follows the documented fail-open behavior.
	usage, err := a.CalculateTokenUsage(data, 0)
	if err != nil {
		logging.Warn(logCtx, "opencode: could not compute subagent token usage",
			slog.String("subagent_id", event.SubagentID), slog.String("error", err.Error()))
		return

cmd/entire/cli/agent/opencode/lifecycle.go:254

  • OpenCode's task_id resumption can reuse one child session across multiple call IDs, but this path exports from the beginning and calculates tokens from offset zero. A later call therefore receives the cumulative earlier transcript/files and token total under a new task record, which is not exact per-call tracking. Please either slice export and token usage at the call boundary or model the reused child as one multi-turn record rather than advertising one exact record per call.
	path, err := a.fetchAndCacheExport(ctx, event.SubagentID)
	if err != nil {
		logging.Warn(logCtx, "opencode: could not export subagent transcript; completing task without it",
			slog.String("session_id", event.SessionID),
			slog.String("tool_use_id", event.ToolUseID),
			slog.String("subagent_id", event.SubagentID),
			slog.String("error", err.Error()))
		event.SubagentTranscriptUnavailable = true
		return
	}
	event.SubagentTranscriptPath = path
  • Files reviewed: 17/17 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +260 to +263
if (part.type === "tool" && part.tool === "task" && part.callID &&
part.state?.status === "running" && part.state?.metadata?.sessionId &&
!announcedTasks.has(part.callID)) {
announcedTasks.add(part.callID)
Comment on lines +255 to +260
data, err := a.ReadTranscript(path)
if err != nil {
logging.Warn(logCtx, "opencode: could not read exported subagent transcript for token usage",
slog.String("subagent_id", event.SubagentID), slog.String("error", err.Error()))
return
}
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entire-Checkpoint: 01M29KBP9YH5JWQY7NATB9C7GM
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants