feat: selective tool proxying, session isolation, MCP bridging, and streaming fixes - #16
khalilgharbaoui wants to merge 281 commits into
Conversation
|
@khalilgharbaoui would you consider publishing to npm yourself? this repo is likely dead. I'm looking forward to use your fixes but I couldn't use it locally (clone + build). |
|
@emreycolakoglu yep — I went ahead and published a maintained fork. It is on npm now, no clone/build needed:
Just add it to the Includes the fixes from this PR plus a couple of regressions I hit afterwards (empty-text-block 400s, variant selection on model pick, lazy cwd resolution). Issues / PRs welcome over on the fork. |
The previous default hard-coded WebSearch -> websearch_web_search_exa with executed:false, assuming users had the Exa MCP server installed in opencode. For everyone without it, opencode rejected every call as 'tool not available' and the model retried against the dead name. Replace the hardcode with a configurable `webSearch` option: - "claude" (default): provider-executed; Claude CLI runs WebSearch internally via Anthropic. Zero setup, no extra cost. - "<opencode-tool-name>" (e.g. "websearch_web_search_exa"): forward to that opencode tool with executed:false. Requires the matching MCP server in opencode. - "disabled": adds WebSearch to --disallowedTools so the model can't call it at all. mapTool now takes an opts arg threaded through from config.webSearch at all four call sites (doGenerate + the three doStream paths).
- Deep-merge per-server: partial overrides like {enabled: true}
layer onto the global spec instead of replacing it. Aligns with
opencode core's mergeDeep semantics so the bridge sees the same
effective config opencode does.
- Discovery aligned with opencode core: walks parents up to the
worktree root, loads opencode.json + opencode.jsonc at each
level, includes home-dir .opencode/, OPENCODE_CONFIG ordered
before project walk-up.
- Hot-reload: bridgeOpencodeMcp now returns {path, hash}. The
cached claude subprocess is evicted between turns when the hash
differs, so on-disk MCP edits are picked up without restarting
opencode or starting a new chat.
- Runtime overlay: opencode's /mcps UI toggle is in-memory only
(client.mcp.connect/disconnect, never written to disk). Plugin
now captures the SDK client and calls client.mcp.status() each
turn, overlaying connected->enabled and anything else->disabled
onto the disk merge before hashing.
- Tests: new test-bridge.ts with 23 cases via node:test + tsx
covering merge semantics, walk-up boundaries, jsonc precedence,
runtime overlay, and hash stability.
- Add cleanup-stale.ts that removes ~/.cache/opencode/node_modules/opencode-claude-code-plugin/
(the orphaned unscoped 0.1.2) at plugin load. Identity-checked against package.json name
and description, skips if user lists the unscoped name in their plugin config, never self-deletes.
Disable with OPENCODE_CLAUDE_CODE_PLUGIN_NO_CLEANUP=1.
- Remove the event hook that called evictAllSessions("global.disposed"). The hot-reload check
at language-model turn-start already detects MCP config drift via mcpHash and respawns claude
safely; the eviction was redundant and killed the in-flight subprocess mid-stream.
- Drop now-unused evictAllSessions and readEventType helpers.
When opencode is launched from the macOS Dock/Finder/Spotlight, launchd gives the parent process cwd=/. The plugin's createClaudeCode factory defaulted cwd to process.cwd(), so the Claude CLI subprocess inherited / even though opencode itself knew the real project directory. Read 'directory' (and 'worktree' as a secondary signal) from the opencode plugin context in the server() hook and use it as the default cwd in providerConfig. An explicit options.cwd in opencode.json still wins. Also surface the resolved cwd in the registration notice log.
When 'Edit' is in proxyTools, the plugin now passes both 'Edit' and 'MultiEdit' to claude --disallowedTools. Without this, Claude could batch file changes through MultiEdit and bypass opencode's permission UI / audit log entirely, since opencode has no MultiEdit equivalent to forward the call to. Ports the fix from Kurry Tran's fork: https://github.com/Kurry/opencode-claude-code-plugin/commit/216b0ac Closes #1. Co-Authored-By: Kurry Tran <kurry.tran@gmail.com>
The 5s result-fallback timer was previously armed at every text content_block_stop, then expected the next content_block_start to clear it. Sonnet routinely takes 5+ seconds to transition from a chat-text block to its next tool_use block, which guillotined the stream mid-turn with reason=stop and zero usage. Reframe the timer as a wire-inactivity watchdog: reset on every line received from the CLI, fire only after extended silence on stdout. Bump the default threshold from 5s to 60s for normal flow; the abort grace path keeps a short 5s window by passing it explicitly. The session-reuse hang the timer was originally added to catch (CLI emits content but never sends a result) is still covered. Ports the fix from Kurry Tran's fork: https://github.com/Kurry/opencode-claude-code-plugin/commit/ae9797c Co-Authored-By: Kurry Tran <kurry.tran@gmail.com>
Claude CLI has its own MCP servers (configured in ~/.claude/settings.json or via the bridged opencode MCP config) that opencode doesn't share. When the model calls one — e.g. mcp__atlassian__jira_get_issue — Claude CLI runs it internally and streams the result back. The plugin was mapping the call with providerExecuted:false, so opencode looked it up in its own tool registry, didn't find it, and routed the call through its built-in 'invalid' tool. The real MCP result was shadowed by an error message that read like the model fabricated a non-existent tool, even though Claude had run it correctly. Flip MCP-tool mapping to executed:true. Our own proxy tools (mcp__opencode_proxy__*) are already filtered out by callers before reaching mapTool, so this branch only sees user-configured MCP servers. Ports the fix from Jan Kozak's fork: galvani@b806409 Co-Authored-By: Jan Kozak <galvani78@gmail.com>
Pass --print and --include-partial-messages to the Claude CLI so it emits content_block_* deltas as Claude generates, instead of going silent until the whole response is ready. The parser now unwraps the stream_event envelope and skips the redundant full assistant message when partial events have already streamed the same content (avoids double-counting text and tool calls). Without these flags the CLI only emitted system/init, then nothing, then a single final assistant + result. Slow turns appeared 'done' in opencode because no events flowed; sending another message was the only way to 'wake it up' — actually just kicking off a new turn. Ports the fix from Jan Kozak's fork: galvani@b96ecfe Co-Authored-By: Jan Kozak <galvani78@gmail.com>
When opencode iterates the agent loop one more time after a turn naturally finished, the prompt it hands us ends with an assistant message and carries no fresh user content. Our message-builder used to fall through to its '(empty)' sentinel for that case, which made Claude CLI dutifully reply with stubs like 'No input received. Standing by' — those stubs scrolled the real answer in the UI. Detect the case at the model level (hasNewUserContent walks the prompt back and looks for any user-side text or tool-result after the last assistant message). When there is none, both doStream and doGenerate return a synthetic empty turn with finishReason 'stop' and zero tokens, without spawning Claude CLI. opencode sees 'model had nothing to add' and the loop terminates cleanly. Ports the fix from Jan Kozak's fork: galvani@0e301ee Co-Authored-By: Jan Kozak <galvani78@gmail.com>
The 0.3.0 short-circuit only looked for text and tool-result parts, so an image-only user turn (image attached, no text) was treated as empty and dropped to a synthetic stop response. Image and file parts also count as fresh user input.
Warnings such as MCP config parse failures and dropped image parts were only emitted when DEBUG=opencode-claude-code, hiding real problems from users running the plugin normally.
The HTTP handler awaited resolution forever. If the broker chain broke between turns or opencode quit mid-call the Claude subprocess sat idle waiting for a tool result that would never arrive. 10 min matches Claude CLI's hard upper bound for Bash.
The 5-second result fallback wording was carried over from before 0.2.6 reworked the timer into a 60s wire-inactivity watchdog with a 5s abort-grace path.
Bridged-MCP config and proxy-MCP config were written to /tmp with shared filenames and never deleted. Multiple opencode processes could race on the same path, and files leaked across runs. Now each plugin instance writes into /tmp/opencode-claude-code-<pid>/ which is rm'd in a process exit handler. The proxy server also unlinks its own config in close() so cleanup happens as soon as the subprocess dies.
Claude Code emits several proxy tool_use blocks in one assistant message but sends the MCP requests one at a time, each only after the previous result, so two `task` calls in one response always ran serially. `task_batch` is one MCP call whose `tasks` array the plugin fans out as N opencode `task` calls in a single tool boundary (which opencode runs concurrently), then gathers the children's results back onto the parent id, labelled in task order. Design and first implementation by Joseph Roberts (@broskees) on his fork, commit 68ed142. Adapted for this tree by Khalil Gharbaoui: the serial premise was re-measured live before building (second MCP request arrived 7 ms after the first resolved), validation happens in the tools/call handler before queueing, a partial set of child results resolves the parent with the gap named rather than erroring, the batch rides along with `Task` in `proxyTools`, and the "unlimited by default" task deadline from the same fork was not taken. Live-verified: two subagents started 13 ms apart, overlapped for their full runs, both tokens reached the model.
Three headless-transport lifecycle fixes: - baseline error listener on the child's stdin, so a write after the child died is logged instead of throwing inside opencode - LRU eviction picks the oldest idle process and skips the round when every process is mid-turn, instead of truncating a live answer - a child that closes without a terminal result ends the turn as an error with its exit status and retained stderr tail, not as a stop
task and task_batch no longer have a default deadline. Every way a call can end is observed and released on both the broker and the open HTTP request: opencode's result, an abort on any of its three paths, the next user message, the child exiting mid-turn or between turns, the chat being deleted, or opencode exiting. A positive proxyToolTimeoutMs still adds a wall-clock backstop; 0 now means "no deadline" consistently. Also: session.deleted hook, host-exit sweep, respawn keeps the in-flight marker, idle timer re-arms on a busy worker, skill bridge on doGenerate and interactive spawns, JSON-only keepalive. Defaults changed on purpose: bridgeOpencodeSkills true, idleProcessTimeoutMs 30 min, process cap 8.
Summary
This PR brings 18 commits that address the three known limitations listed in the original README and add significant new functionality. The changes fall into four areas:
1. Selective Tool Proxy — route dangerous tools through opencode's permission system
The headline feature. Claude CLI normally executes tools (Bash, Edit, Write, WebFetch) internally, bypassing opencode's permission UI entirely. This PR adds a
proxyToolsoption that selectively disables Claude's built-in tools and replaces them with equivalent MCP proxy tools hosted by an in-process HTTP server.How it works:
127.0.0.1(random port) whenproxyToolsis configured.--disallowedTools <ToolName>is passed to the CLI.tool-callto opencode → opencode runs the real tool with its native permission checks → the result flows back to Claude.New files:
src/proxy-mcp.ts(MCP server),src/proxy-broker.ts(pause/resume broker).Supported proxy tools:
Bash,Edit,Write,WebFetch.Config:
{ "options": { "proxyTools": ["Bash", "Edit", "Write", "WebFetch"] } }2. Session isolation — no more cross-chat interference
Sessions are now keyed by
(cwd, model, x-session-affinity)instead of just(cwd, model). Thex-session-affinityheader is set by opencode on LLM calls to third-party providers, so two simultaneous chats in the same project get separate CLI processes. An LRU cap (16 processes) prevents subprocess accumulation.3. MCP config auto-bridging — one config, not two
The plugin now auto-discovers
opencode.json/opencode.jsonc(viacwd,OPENCODE_CONFIG,OPENCODE_CONFIG_DIR,$XDG_CONFIG_HOME/opencode) and translates itsmcpblock into Claude CLI's--mcp-configformat. Local servers gettype: \"stdio\", remote servers gettype: \"http\", disabled servers are skipped. This means MCP servers configured in opencode are automatically available to Claude CLI without maintaining a separate~/.claude/settings.json.New file:
src/mcp-bridge.ts.Config overrides:
bridgeOpencodeMcp(defaulttrue),mcpConfig(extra paths),strictMcpConfig.4. Streaming correctness fixes
0736306,5def53c): replaced\"(continue)\"with\"(empty)\"so the model doesn't interpret the sentinel as an instruction to resume the previous turn.33cb03a):TodoWriteandWebSearchare now forwarded as client-executed (not provider-executed), so opencode's todo UI and search results populate correctly.c665524): opencode sometimes passes tools as an object map rather than an array; the scope classifier now handles both.09db874): if Claude returns only aresultmessage with error text (rate limit, auth failure), it's now emitted as visible text instead of a blank turn.70badf9):can_use_toolcontrol requests get immediatecontrol_responsereplies with configurable allow/deny policy, preventing stream deadlocks.6d126c3, refined in4af2a96): usesusage.iterations[-1]instead of cumulative totals and computesinputTokens.total = noCache + cacheRead + cacheWrite, preventing inflated context estimates and fixing cache-aware token accounting.6d126c3, refined in4af2a96): each text content block gets its owntext-start/delta/text-endlifecycle so partial text is preserved on stream abort.6d126c3, refined in4af2a96, tightened ince5701c): a 5-second timeout closes the stream gracefully if the CLI emits content but never sends aresultevent. The timer is now only armed on assistant text without tool use, abort starts a grace period instead of closing immediately, and the non-streaming path now honors proxied tools consistently.4af2a96): emitsproviderMetadata.anthropic.cacheCreationInputTokensso OpenCode can display cache write tokens correctly.ce3eb26): provider init no longer freezesprocess.cwd(), so each request resolves cwd at call time.Other improvements
0ae354c)93d610c):--thinking-effortpassthrough for low/medium/high/xhigh/max93d610c, hardened in4af2a96): base64 image parts forwarded to Claude CLI, plus MIME allowlist, robust data URI parsing, and early rejection of unsupported remote URL images--permission-modepassthrough (ea27f17)6d126c3):shell: process.platform === \"win32\"on both spawn sites soclaude.cmdworks on WindowsRelationship to other open PRs
This PR subsumes or addresses the core concerns of several other open PRs. We developed these independently and discovered many of the same issues:
--thinking-effort, and scope sessions byx-session-affinityheader. We intentionally keep our variant-based effort approach rather than model-suffix ergonomics.shell:truefor.cmdspawn6d126c3— same fix on both spawn sites.providerExecutedflag, empty content4af2a96: V3 spec (0ae354c),lastIterationUsageviaiterations[-1](6d126c3), cache-aware totals +noCache(4af2a96), per-block text emission (6d126c3), smarter fallback timing + abort grace (4af2a96),providerExecuted(33cb03a, refined in4af2a96), empty content sentinel (0736306,5def53c).93d610c; hardened in4af2a96with supported MIME allowlist, robust data URI parsing, and remote URL rejection.--effortflag via provider option93d610c— reasoning effort passthrough.PR #4 is only partially addressed here. Commit
ce3eb26adopts the safe cross-platform piece by resolvingcwdlazily per request instead of freezingprocess.cwd()at provider initialization. We intentionally did not adopt the desktop-specific SQLite/session lookup fallback, request-optionsessionID/cwdplumbing, or hard-coded path logic from#4, so#4remains distinct draft work for desktop-specific cwd recovery.PR #14 is independent and useful, but it's a standalone migration utility rather than a runtime plugin improvement.
Issues addressed
inputTokens.totalcrash): fixed by AI SDK v3 usage rewrite in0ae354cand the cache-aware usage refinements in4af2a96.0736306,5def53c,09db874,c665524,a663266, and6d126c3/4af2a96.Commits (chronological)
0ae354cfix: make claude-code provider compatible with AI SDK v393d610cfeat: add reasoning effort levels and image input support0736306fix: use neutral sentinel instead of "(continue)" for empty user content33cb03afix: correct tool-execution semantics for opencode-hosted tools5def53cfix: use "(empty)" sentinel matching provider's parenthetical meta-note conventionea27f17feat: expose --mcp-config passthrough and fix known-limitations wording1941685feat: fix session sharing and auto-bridge opencode MCP config to Claude CLI70badf9feat: handle Claude control-request permissions in stream-json mode09db874fix: surface CLI error text from stream-json result messagesc665524fix: detect object-shaped tools when choosing stream scopea663266fix: emit Claude-compatible MCP transport types in bridge4145493feat: proxy Bash through opencode tools and permissions9230421feat: proxy Edit and Write through opencode tools820cc22feat: proxy WebFetch through opencode tools and permissions6d126c3fix: per-iteration usage, per-block text emission, result fallback timer, Windows spawn4af2a96fix: refine usage accounting, text emission, fallback timing, and image handlingce5701cfix: honor proxied tools in doGenerate and tighten fallback handlingce3eb26fix: resolve cwd lazily per requestTest plan
tsc --noEmitpassestsupbuild passesopencode run \"hi\" -m claude-code/claude-sonnet-4-6returns visible output (or explicit rate-limit text, not blank)mcp__opencode_proxy__bash, opencode executes, result flows backmcp__opencode_proxy__edit, opencode executes file diffmcp__opencode_proxy__write, opencode writes filebash: askpermission rule: opencode'spermission.askedfires, auto-rejected in headless modex-session-affinityheaders get separate CLI processes\"(empty)\"sentinel, not blank or\"(continue)\"429responses surface visible error text instead of blank turnusage.iterations[-1]used when present, falls back to cumulativeinputTokens.totalincludes cache read/write,noCacheis populatedshell: truegated onprocess.platform === \"win32\"process.cwd()Breaking changes
None. All new features are opt-in via config. Default behavior is unchanged from upstream.
Known limitations
Bash,Edit,Write, andWebFetchare supported. More can be added when opencode gains matching built-in executors.can_use_toolcontrol requests for built-in tools. The selective proxy approach works around this entirely.