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 300 commits into
unixfox:masterfrom
khalilgharbaoui:master
Open

khalilgharbaoui wants to merge 300 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.

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 CLI dispatches all tool_use blocks in an assistant message in
parallel (e.g. two bash calls in one turn). The proxy broker tracked a
single pending call per session, so the second call was rejected and
Claude saw spurious tool errors.

Re-key the broker by toolCallId with a sessionKey reverse index. Buffer
pending calls in the language model and drain after a short quiet window
so every parallel call lands in one tool-calls stream finish. Resolve
each call by id from the next-turn prompt; reject orphans so claude
CLI's HTTP handlers do not hang. Reject session-wide on subprocess
close/error.

Adds test-broker.ts with multi-call queue/resolve/reject coverage.
…sult

Two bugs caused MCP servers to disappear from Claude CLI's view and proxy
tool calls to time out:

1. {env:VAR} placeholders not substituted in bridged MCP config.
   translateServer wrote the raw spec.environment / spec.headers through
   to the Claude CLI --mcp-config file, so any server using opencode's
   interpolation syntax received the literal string '{env:VAR}' as its
   credential value. Servers that validate credentials at startup
   (slack-mcp-server) crashed before exposing tools; servers that defer
   validation (github-mcp-server) registered fine but every API call
   401'd. Now substitute placeholders from process.env in both env maps
   and HTTP headers, matching what opencode does when it spawns MCPs
   itself.

2. Drain race when Claude CLI emits result with a pending proxy call.
   If the 100ms drain timer hadn't fired yet (or Claude CLI abandoned
   the HTTP request after an internal timeout), the call sat in the
   broker for the full 10-minute timeout, surfacing as a hard 2-minute
   'operation timed out' to the SDK caller. Now drain through the normal
   tool-calls flow at the turn-result boundary if anything is buffered,
   and reject orphans so proxy-mcp returns to the caller immediately.
Adds optional system-prompt hint (multiStepContinuation, default true)
encouraging Claude to complete multi-step tasks in one turn instead of
pausing for user confirmation between subtasks. Each opencode turn
boundary requires the user to press 'continue' to resume, so for
multi-step work this reduces friction.

Respects the design principle from 49345e3 (short-circuit empty turns):
plugin still defers entirely to Claude's stop_reason; the hint nudges
model behavior without overriding turn-end signals.
The smart auto-continuation heuristic was evaluating final-answer
keywords (done|implemented|updated|summary|...) against the full
accumulated text of every assistant turn since the last continue. Mid
-task narration like 'Implementing now. Updated the search index.'
hit those keywords reliably and short-circuited the auto-continue to
'final-answer' — STOP — even though the next text block was a mid-task
pause and the user still expected more work.

Track lastVisibleText separately: reset on each new text content_block
start, append on text deltas. Pass it through AutoContinueSnapshot.
Final-answer detection now considers only the most recent text block,
which is the actual candidate end-of-turn sentence. Question / blocker
detection still uses the accumulated text — a question raised earlier
in the turn should still block auto-continue.

Also add file-based logging at $XDG_DATA_HOME/opencode-claude-code/
plugin.log (defaults to ~/.local/share/opencode-claude-code/plugin.log)
so NOTICE/WARN/ERROR are observable without depending on
DEBUG=opencode-claude-code or stderr redirection. Auto-continue
decisions are now NOTICE level (always emitted, both to stderr and
file) instead of INFO (debug-only).

Tests: 54 passing (+3 new for the last-block / accumulated split).
opencode delivers proxy MCP tool results in AI-SDK V3 tool-role
messages. hasNewUserContent only inspected user/assistant roles, so
turns carrying only a tool-result short-circuited to finishReason
stop and forced the user to press continue after every proxy tool
call. Now treats tool-role messages with any tool-result part as
new content. Verified pattern in plugin.log: every wall was
message_stop -> drain (tool-calls) -> 'doStream short-circuit: no
new user content' -> [user pressed continue].
v0.4.7 fixed hasNewUserContent to detect tool-role tool-results, but
getClaudeUserMessage still only iterated msg.role === 'user' and
dropped tool-role messages. Result: the gate let the prompt through
but the message builder emitted the '(empty)' sentinel, so Claude CLI
saw a no-op turn and ended it — forcing the user to press 'continue'
between every proxy tool call.

Symmetric fix: when iterating recent messages, also extract tool-result
parts from tool-role messages. Matches hasNewUserContent's shape so
the two functions agree on where to find tool-results.

Tests: 4 new in test-get-claude-user-message.ts cover tool_result
emission, multiple results per message, sentinel fallback when a
tool-role message has no tool-result parts, and mixed user+tool
content. All 63 unit tests pass.
NOTICE was emitting to console.error (alwaysStderr=true), which opencode's
TUI captures and renders as a UI warning bubble. That meant 'auto-continuation
stopped reason: final-answer' — the normal happy-path log line after every
successful turn — produced a yellow warning in the UI after each task.

Reserve console output for warn/error (genuine problems). NOTICE remains
always-on in the plugin.log file, so observability of auto-continue decisions
and startup events is preserved without UI noise.
All four changes push the heuristic toward STOP — the safe failure
direction. Adds three regex extensions and one threshold change. No
behavior changes on the CONTINUE side; no new helper functions called
from the hot path.

Tweak 2 — Question regex picks up indirect offers:
  let me know if|let me know whether|let me know what|if you'd like|
  if you want to|tell me if|tell me which|tell me whether|
  say go|say yes|push back|sign off|sounds good|sounds right|
  your call|your move|up to you|ready to ship|happy to proceed|...

Tweak 3 — Blocker regex picks up intent-equivalents to 'requires your':
  needs your|needs you to|action required

Tweak 4 — Final-answer length floor lowered 40 → 30 chars so short clean
  completions like 'Task is now completely done. Pushed.' match.

Tweak 5 — '?' anywhere in the last block (was: endsWith only). Catches
  long answers that pose a question mid-text then list options and end
  with a period. FP risk on inline code (`result?.value`) accepted —
  cost is one extra continue press in the safe direction.

Validated against 32-case sim corpus: 22/32 baseline → 28/32 candidate.
Zero false positives. Real fires (today's 03:31:16 'say go or push back'
and earlier 02:48:11 'if you want to') flip from FP to clean stops.

Tweak 1 (mid-task continuation override of completion-keyword detection)
prototyped in sim/eval-candidate.ts but NOT shipped — would widen
auto-continue (unsafe direction), and zero G-class fires observed in
real plugin.log.

Sim infrastructure committed under sim/ as permanent regression bench.
khalilgharbaoui and others added 30 commits September 14, 2026 22:59
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.
opencode-dcp declares `compress` directly rather than through an MCP
server, so the automatic MCP routing skipped it and the model could never
obey dcp's "you MUST use the compress tool now" reminders.

`proxyOpencodeTools` is an explicit allowlist, empty by default, that
forwards such a tool through the existing broker. `stripContextReminders`
removes the reminders when no compress tool is reachable.

The `compress` name collision is resolved at both layers: the forwarded
def loses a contested name, and the in-process interceptor is registered
only for the plugin's own def, not for any def that happens to be called
compress.
proxyOpencodeMcpTools defaulted to true and routed nothing. Discovery
read client.tool.list(), which enumerates opencode's tool registry:
built-ins plus plugin-declared tools, never MCP ones. opencode merges
MCP tools into the model's tool set after the registry is read, so the
`tools` argument of doStream is the only place a provider plugin can
see them. resolveMcpProxyToolDefs reads that instead; the server-name
prefix match is unchanged and was always correct.

Default goes true to false, which changes no behaviour because the
option was inert. Leaving it on would have silently moved every user's
MCP traffic off the direct bridge that carries it today.

excludeServers now names only the servers a def was built for, so an
enabled server with no def is not dropped from --mcp-config without
being put on the proxy.
Set DISABLE_AUTOUPDATER=1 and CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 on
every spawned claude, on both the headless and interactive paths, and only
where the user has not set the var themselves.

detectCliVersion caches one version per cliPath for the life of the opencode
process, and --thinking-display summarized, --plugin-dir and fast mode are all
gated on it. A CLI that updates itself mid-session leaves those gates
describing a binary that is no longer running.

Both names were read out of the 2.1.263 bundle rather than assumed. Also
records a 2026-09-20 re-measurement of auto-continue and abort against that
CLI in AGENTS.md.
* Offer another account when one hits its limit

* Escape the NUL that made the module binary
* Correct the plan-mode bridge's dormancy note

* Say why a silent CLI closed the turn

* Bound the serve-mode session and ledger maps
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.