Skip to content

Reliable OpenCode subagents by default, --resume session continuation, JSONC configs, and session-handoff race fixes - #18

Closed
broskees wants to merge 8 commits into
khalilgharbaoui:masterfrom
broskees:feature/reliable-opencode-subagents
Closed

Reliable OpenCode subagents by default, --resume session continuation, JSONC configs, and session-handoff race fixes#18
broskees wants to merge 8 commits into
khalilgharbaoui:masterfrom
broskees:feature/reliable-opencode-subagents

Conversation

@broskees

Copy link
Copy Markdown

Summary

This branch makes OpenCode subagent dispatch through the plugin work reliably end-to-end, and fixes four defects found while testing it against a live OpenCode + Claude CLI setup: session continuation used the wrong CLI flag, configs were parsed as strict JSON, a process-handoff race corrupted session ownership, and benign proxy teardown logged as warnings.

All changes were verified live (real Claude CLI, real OpenCode session, foreground + parallel subagent dispatch) in addition to the test suite: 226/226 tests pass, typecheck and build clean.

Changes

1. Reliable OpenCode subagents (f00972e)

  • task is now included in the default proxy tool set, so Claude's built-in Agent tool is disabled by default and subagent calls route through OpenCode's native TaskTool — OpenCode owns subagent lifecycle, permissions, and orchestration.
  • The Task schema now carries OpenCode's current TaskTool fields: task_id (resume a prior subagent session), command, and background.
  • Fixed a result-before-broker ordering race in which a result event arriving before the broker subscriber attached could drop the Task call entirely (a bounded settlement window preserves the tool-calls finish boundary). This is likely the same class of failure reported in fix: remove stopReason short-circuit from auto-continue #15.
  • Fixed JSON-RPC error responses correlating to the wrong request id.
  • Abort now cleans up pending Task calls instead of leaving the CLI's HTTP handlers blocked.
  • Integration coverage exercises the real loopback MCP server + broker: parallel Task batching, out-of-order success/error resolution, duplicate results, delayed broker arrival, and parent-turn continuation after tool results.

2. Resume sessions with --resume, not --session-id (a6d0df2)

--session-id means "create a new session with this UUID" — the CLI rejects it with Session ID … is already in use whenever a transcript for that id already exists on disk, live process or not. Every respawn that tried to continue a conversation (MCP hot-reload, LRU eviction, crash recovery) failed this way, cleared the session, and silently fell back to a fresh session with history re-injected as text. Verified empirically against the real CLI. Continuation now uses --resume <id>; the stderr recovery matcher also catches the lowercase No conversation found with session ID error --resume prints for a purged transcript.

3. Session handoff race (3d9a405)

MCP hot-reload killed the old Claude process and immediately respawned with the same session id while the old process could still be alive. Replacement now waits for the old owner to exit (SIGKILL escalation after 1.5 s, fresh session as last resort), and a stale process's exit handler can no longer delete its replacement from the session map.

4. Parse configs as JSONC (0bbf6c0)

opencode.json/opencode.jsonc were comment-stripped and fed to JSON.parse, so trailing commas and other JSONC syntax failed with a WARN: failed to parse bubble. The bridge now uses jsonc-parser@3.3.1 with allowTrailingComma: true — the exact parser and options OpenCode itself uses for these files.

5. Notice-level logging for proxy teardown (d9add5e)

close() rejecting in-flight proxy calls ("proxy MCP server closed") surfaced as a WARN bubble on every normal teardown (process exit, abort, hot-reload respawn, compaction), even though the owning process is already gone and the rejection is pure cleanup. The expected-cleanup classifier is now an exported, unit-tested function and includes the server-closed message; genuine errors still log at WARN.

Usage notes

With Task proxied by default, subagents need Task permission on the agent, e.g.:

{
  "agent": {
    "build": {
      "permission": { "task": { "*": "allow" } }
    }
  }
}

Background subagents still require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true; nested subagents require explicit permission.task on the child agent and a larger subagent_depth. Documented in the README updates.

broskees added 5 commits July 15, 2026 16:06
The CLI treats --session-id as 'create a NEW session with this UUID'
and exits with 'Session ID ... is already in use' whenever a transcript
for that ID already exists on disk — so every respawn that tried to
continue a session (MCP hot reload, eviction, crash recovery) failed,
cleared the session, and fell back to re-injecting history as text.
Verified against the real CLI: --session-id reuse fails with no live
process holding the ID; --resume continues under the same session ID.

Also catch the lowercase 'No conversation found with session ID' error
that --resume prints for a purged transcript, so a stale remembered ID
still self-heals on the next turn.
The proxy server's close() rejects any in-flight tools/call so the HTTP
handler doesn't hang, but that rejection was logged at WARN — surfacing
a yellow TUI bubble on every normal teardown (process exit, abort kill,
MCP hot-reload respawn, compaction). By the time close() runs, the
owning Claude process is gone or being replaced, so nobody can consume
the response; the rejection is pure cleanup.

Extract the expected-cleanup classification into an exported
isExpectedCleanupError(), add the server-closed message to it, and share
the message string via SERVER_CLOSED_MESSAGE so the classifier and
close() cannot drift apart. Genuine errors still log at WARN.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for proxying the 'Task' tool natively through OpenCode, allowing Claude to orchestrate subagents with proper permission, lifecycle, and background execution support. Key changes include updating the default proxy tools list, replacing the custom JSON comment stripper with jsonc-parser to robustly handle JSONC configurations, switching from --session-id to --resume for continuing sessions, and implementing a deferred result boundary mechanism to wait for parallel or delayed proxy calls at turn-result boundaries. Additionally, process management is improved with graceful shutdown waiting (deleteActiveProcessAndWait) and comprehensive test suites are added in test-proxy-task.ts and test-session-manager.ts. As there are no review comments provided, I have no feedback to provide on the review itself.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@broskees

Copy link
Copy Markdown
Author

Follow-up fix in ab4916d: defer MCP hot reload while a proxied tool result is pending. A runtime MCP status change (observed when browsermcp disconnected) previously closed the parent proxy and killed Claude between a Bash/Task call and its OpenCode result, so the resumed process received no stdin and hung. Added a regression that mutates MCP config between the proxy boundary and tool-result turn. Verified against a real claude-fable-5 OpenCode run: pwd completed, the hash change was deferred, the result returned to the existing parent process, Fable replied DONE, and the stream emitted terminal stop. Full suite remains 226/226; typecheck and build pass.

@broskees

Copy link
Copy Markdown
Author

Transcript-backed parallel Task fix in 36af082. Claude Code's remote HTTP MCP client was aborting each Task request at its 60-second default even though the plugin's internal server/broker timers were 10 minutes. In session ses_07a9a1bddffebfyCADU2Ff8Rbv, completed children took 68.9s, 342.0s, and 106.6s, so OpenCode persisted 6.9-8.2 KB outputs after Claude had already received The operation timed out. The generated opencode_proxy MCP config now carries timeout: 1800000, and server/broker deadlines are aligned to 30 minutes. Also preserve unmatched parallel siblings across individual tool-result and interim Claude result boundaries instead of rejecting them as orphaned. Full suite 226/226, typecheck and build pass.

@broskees

Copy link
Copy Markdown
Author

Static model-catalog update in 3e21886: register claude-sonnet-5 and claude-opus-5. Metadata is sourced from Anthropic's current model docs and launch pages: both have 1M context, 128k max output, image/tool support, and reasoning variants; Opus 5 uses / standard pricing and a 2026-07-24 release date; Sonnet 5 uses current / introductory pricing through August 31 (standard / from September 1) and a 2026-06-30 release date. Added exact metadata regressions and README rows. Full suite 227/227, typecheck/build pass, and opencode models claude-code now lists both IDs.

@khalilgharbaoui khalilgharbaoui left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This is an outstanding contribution — live-verified fixes, real root-cause analysis (the --session-id vs --resume finding especially), and thorough tests. Thank you. Direction is accepted on all five changes; here's the plan to get it merged:

  1. Models commit cherry-picked ahead. Users were blocked on Opus 5/Sonnet 5 (#19), so I pulled 3e21886 onto master (your authorship preserved) and released it as v0.9.3. Please rebase this branch on latest master and drop that commit — the rest of the PR is unaffected.

  2. Task-proxy-by-default: accepted in principle. This flips a default the roadmap deliberately kept opt-in, and your live verification is exactly the real-world feedback it was waiting for. Before merging I'll run my own smoke test on my setup (foreground + parallel subagent dispatch), since a default change should be verified by more than one environment. Assuming that's clean, this lands as v0.10.0 (minor bump for the behavior change) with a prominent changelog entry covering the permission.task requirement and the proxyTools escape hatch for anyone who wants the old behavior.

  3. Small ask: the AGENTS.md gotchas currently document the old --session-id behavior and the JSON-with-comment-stripping parser — if you're touching the branch anyway for the rebase, updating those two entries to match the new reality would be great; otherwise I'll do it in a follow-up.

Also flagging: your settlement-window fix for the result-before-broker race looks like the likely root cause behind #15 — I've asked over there for a retest on top of this branch.

@khalilgharbaoui

Copy link
Copy Markdown
Owner

Everything in this PR is now on master and published as v0.10.0 — landed via cherry-pick with your authorship preserved on all seven remaining commits (the models commit already shipped in v0.9.3). GitHub can't mark a cherry-picked PR as "merged", hence the close, but the git history credits you directly: git log --author=joseph.roberts shows the lot.

What shipped on top of your branch during integration:

  • @jknlsn's per-tool proxy timeouts absorbed and reconciled with your work: your flat 30-min PROXY_CALL_TIMEOUT_MS became resolveProxyClientCeilingMs(overrides) — the --mcp-config client-side ceiling you discovered is still written, but now tracks the max of the per-tool deadlines (task defaults to 60 min, configurable via proxyToolTimeoutMs), so Claude's HTTP client never aborts before the broker deadline.
  • tools/call failures now return MCP results with isError: true instead of JSON-RPC error envelopes (Claude CLI rejects the latter as schema-invalid — also from jknlsn's branch). Your test-proxy-task.ts assertions were migrated to the new contract.
  • Their respawn-when-silent watchdog, adapted to your --resume semantics (their original appended --session-id, which your own analysis proved fails with "already in use" once a transcript exists).

Verification before release: your 226 tests plus the absorbed suites (256/256 total), typecheck + build clean, your --session-id vs --resume finding reproduced empirically against the live CLI, and a live end-to-end smoke test of Task-proxied-by-default through a real opencode session (subagent dispatch → clean TaskTool row → result returned → parent continued).

This was a genuinely excellent contribution — the --resume root-cause alone saved every user of this plugin from silent history loss on respawn. Thank you, and please keep them coming. 🙌

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants