Skip to content
This repository was archived by the owner on Apr 26, 2026. It is now read-only.

feat: selective tool proxying, session isolation, MCP bridging, and streaming fixes - #16

Open
khalilgharbaoui wants to merge 281 commits into
unixfox:masterfrom
khalilgharbaoui:master
Open

khalilgharbaoui wants to merge 281 commits into
unixfox:masterfrom
khalilgharbaoui:master

Conversation

@khalilgharbaoui

@khalilgharbaoui khalilgharbaoui commented Apr 24, 2026

Copy link
Copy Markdown

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 proxyTools option 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:

  • An embedded MCP server starts on 127.0.0.1 (random port) when proxyTools is configured.
  • For each proxied tool, --disallowedTools <ToolName> is passed to the CLI.
  • Claude calls the MCP proxy tool instead → the plugin emits a client-executed tool-call to opencode → opencode runs the real tool with its native permission checks → the result flows back to Claude.
  • Non-proxied tools (Read, Glob, Grep, etc.) remain fully native to Claude CLI for performance.

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). The x-session-affinity header 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 (via cwd, OPENCODE_CONFIG, OPENCODE_CONFIG_DIR, $XDG_CONFIG_HOME/opencode) and translates its mcp block into Claude CLI's --mcp-config format. Local servers get type: \"stdio\", remote servers get type: \"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 (default true), mcpConfig (extra paths), strictMcpConfig.

4. Streaming correctness fixes

  • Empty content sentinel (0736306, 5def53c): replaced \"(continue)\" with \"(empty)\" so the model doesn't interpret the sentinel as an instruction to resume the previous turn.
  • Tool-execution semantics (33cb03a): TodoWrite and WebSearch are now forwarded as client-executed (not provider-executed), so opencode's todo UI and search results populate correctly.
  • Object-shaped tools (c665524): opencode sometimes passes tools as an object map rather than an array; the scope classifier now handles both.
  • CLI error surfacing (09db874): if Claude returns only a result message with error text (rate limit, auth failure), it's now emitted as visible text instead of a blank turn.
  • Control request handling (70badf9): can_use_tool control requests get immediate control_response replies with configurable allow/deny policy, preventing stream deadlocks.
  • Per-iteration usage (6d126c3, refined in 4af2a96): uses usage.iterations[-1] instead of cumulative totals and computes inputTokens.total = noCache + cacheRead + cacheWrite, preventing inflated context estimates and fixing cache-aware token accounting.
  • Per-block text emission (6d126c3, refined in 4af2a96): each text content block gets its own text-start/delta/text-end lifecycle so partial text is preserved on stream abort.
  • Result fallback timing (6d126c3, refined in 4af2a96, tightened in ce5701c): a 5-second timeout closes the stream gracefully if the CLI emits content but never sends a result event. 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.
  • Anthropic cache metadata (4af2a96): emits providerMetadata.anthropic.cacheCreationInputTokens so OpenCode can display cache write tokens correctly.
  • Lazy cwd resolution (ce3eb26): provider init no longer freezes process.cwd(), so each request resolves cwd at call time.

Other improvements

  • AI SDK v3 compatibility (0ae354c)
  • Reasoning effort levels (93d610c): --thinking-effort passthrough for low/medium/high/xhigh/max
  • Image input support (93d610c, hardened in 4af2a96): base64 image parts forwarded to Claude CLI, plus MIME allowlist, robust data URI parsing, and early rejection of unsupported remote URL images
  • --permission-mode passthrough (ea27f17)
  • Windows compatibility (6d126c3): shell: process.platform === \"win32\" on both spawn sites so claude.cmd works on Windows
  • Comprehensive README rewrite with architecture diagrams, config reference, and proxy documentation

Relationship 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:

Open PR Author What it does How this PR addresses it
#6 @simonseo Stream finish handling + effort passthrough + session scoping by effort We fix stream finish (result fallback timer, per-block text), pass --thinking-effort, and scope sessions by x-session-affinity header. We intentionally keep our variant-based effort approach rather than model-suffix ergonomics.
#9 @nbalzotti Windows shell:true for .cmd spawn Included in 6d126c3 — same fix on both spawn sites.
#12 @Aptul9 AI SDK V3 migration, per-iteration usage, per-block text, result fallback timer, providerExecuted flag, empty content We independently implemented all of these and then tightened the last details in 4af2a96: V3 spec (0ae354c), lastIterationUsage via iterations[-1] (6d126c3), cache-aware totals + noCache (4af2a96), per-block text emission (6d126c3), smarter fallback timing + abort grace (4af2a96), providerExecuted (33cb03a, refined in 4af2a96), empty content sentinel (0736306, 5def53c).
#13 @Aptul9 Image support in user messages Included in 93d610c; hardened in 4af2a96 with supported MIME allowlist, robust data URI parsing, and remote URL rejection.
#15 @waveywaves --effort flag via provider option Included in 93d610c — reasoning effort passthrough.

PR #4 is only partially addressed here. Commit ce3eb26 adopts the safe cross-platform piece by resolving cwd lazily per request instead of freezing process.cwd() at provider initialization. We intentionally did not adopt the desktop-specific SQLite/session lookup fallback, request-option sessionID/cwd plumbing, or hard-coded path logic from #4, so #4 remains 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


Commits (chronological)

  1. 0ae354c fix: make claude-code provider compatible with AI SDK v3
  2. 93d610c feat: add reasoning effort levels and image input support
  3. 0736306 fix: use neutral sentinel instead of "(continue)" for empty user content
  4. 33cb03a fix: correct tool-execution semantics for opencode-hosted tools
  5. 5def53c fix: use "(empty)" sentinel matching provider's parenthetical meta-note convention
  6. ea27f17 feat: expose --mcp-config passthrough and fix known-limitations wording
  7. 1941685 feat: fix session sharing and auto-bridge opencode MCP config to Claude CLI
  8. 70badf9 feat: handle Claude control-request permissions in stream-json mode
  9. 09db874 fix: surface CLI error text from stream-json result messages
  10. c665524 fix: detect object-shaped tools when choosing stream scope
  11. a663266 fix: emit Claude-compatible MCP transport types in bridge
  12. 4145493 feat: proxy Bash through opencode tools and permissions
  13. 9230421 feat: proxy Edit and Write through opencode tools
  14. 820cc22 feat: proxy WebFetch through opencode tools and permissions
  15. 6d126c3 fix: per-iteration usage, per-block text emission, result fallback timer, Windows spawn
  16. 4af2a96 fix: refine usage accounting, text emission, fallback timing, and image handling
  17. ce5701c fix: honor proxied tools in doGenerate and tighten fallback handling
  18. ce3eb26 fix: resolve cwd lazily per request

Test plan

  • tsc --noEmit passes
  • tsup build passes
  • opencode run \"hi\" -m claude-code/claude-sonnet-4-6 returns visible output (or explicit rate-limit text, not blank)
  • Proxy Bash: Claude calls mcp__opencode_proxy__bash, opencode executes, result flows back
  • Proxy Edit: Claude calls mcp__opencode_proxy__edit, opencode executes file diff
  • Proxy Write: Claude calls mcp__opencode_proxy__write, opencode writes file
  • Proxy WebFetch: proxied MCP tool is exposed and wired through the same selective proxy path
  • Proxy with bash: ask permission rule: opencode's permission.asked fires, auto-rejected in headless mode
  • MCP bridge: opencode MCP config translated to Claude CLI format (local → stdio, remote → http, disabled → skipped)
  • Session isolation: two chats with different x-session-affinity headers get separate CLI processes
  • Empty content: whitespace-only messages produce \"(empty)\" sentinel, not blank or \"(continue)\"
  • Rate-limit: 429 responses surface visible error text instead of blank turn
  • Per-iteration usage: usage.iterations[-1] used when present, falls back to cumulative
  • Cache-aware input totals: inputTokens.total includes cache read/write, noCache is populated
  • Windows: shell: true gated on process.platform === \"win32\"
  • Image handling: supported MIME types accepted, malformed data URIs and remote URLs rejected early
  • Lazy cwd resolution: provider no longer freezes init-time process.cwd()

Breaking changes

None. All new features are opt-in via config. Default behavior is unchanged from upstream.

Known limitations

  • Proxy tool set: only Bash, Edit, Write, and WebFetch are supported. More can be added when opencode gains matching built-in executors.
  • Non-proxied tools bypass opencode permissions: Read, Glob, Grep, etc. remain native to Claude CLI for performance.
  • Claude upstream bug #34046: Claude CLI does not emit can_use_tool control requests for built-in tools. The selective proxy approach works around this entirely.

@emreycolakoglu

Copy link
Copy Markdown

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

@khalilgharbaoui

Copy link
Copy Markdown
Author

@emreycolakoglu yep — I went ahead and published a maintained fork. It is on npm now, no clone/build needed:

Just add it to the plugin array in your opencode.json — the README has the up-to-date install/config and a few quirks worth knowing about (selective tool proxying, MCP bridge discovery order, MultiEdit pass-through, plan mode handling). Worth a quick read before wiring it up.

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.

khalilgharbaoui and others added 25 commits April 28, 2026 01:03
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.
khalilgharbaoui and others added 30 commits September 6, 2026 17:57
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.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.