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

feat: selective tool proxying, session isolation, MCP bridging, and streaming fixes#16
khalilgharbaoui wants to merge 214 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.

khalil and others added 14 commits April 24, 2026 17:52
- Read providerOptions[provider].reasoningEffort and inject the
  corresponding Claude Code thinking keyword (think / think hard /
  think harder / megathink / ultrathink) into the outgoing user
  message. Enables a low/medium/high/xhigh/max effort selector when
  declared as variants on a model in opencode.json.
- Convert AI SDK v3 file/image content parts with image/* mediaType
  into Claude's image content blocks. Supports URL, data URL, raw
  base64 string, and Uint8Array/Buffer sources.
- Use a single-space placeholder for the empty-content fallback so
  cache_control markers never land on an empty text block (the
  Anthropic API rejects that combination).
The Claude CLI rejects a zero-block user message with 400, so we send a
placeholder when no text/image/tool-result survives filtering. The prior
"(continue)" string was being read as an instruction by the model,
causing it to resume its previous response. Swap to "." — non-whitespace
(so Anthropic's API accepts it) and minimally directive. Also add a
log.warn to observe how often this path fires.
- Always report finishReason "stop" on CLI result messages — tools ran
  internally, so "tool-calls" made opencode loop trying to execute them
  again (and fed empty content back to the CLI, triggering the sentinel
  fallback repeatedly).
- Drop forwarded tool_result for tools we reported as providerExecuted:false
  so opencode's own execute path runs instead of being short-circuited.
- Route TodoWrite through opencode (executed: false) so Todo.Service and
  the UI widget get populated.
…te convention

"." was non-whitespace but still directive-feeling; the model could read
it as a continuation cue. Switch to "(empty)", which matches the
parenthetical keyword pattern this file already uses for reasoning effort
("(think)", "(megathink)", etc.) — the model reliably treats those as
out-of-band metadata rather than content.
Adds `mcpConfig` (string | string[]) and `strictMcpConfig` (boolean) to
provider settings so users can point Claude CLI at the same MCP servers
their opencode config references, instead of maintaining two separate
MCP configs. Also corrects the "one session per directory per model"
README entry — separate opencode instances are separate Node processes
with separate plugin state, so they don't literally share a CLI process;
what they can share is the CLI's own filesystem state under `.claude/`.
…de CLI

Session keying now includes the `x-session-affinity` header opencode sets
on LLM calls to third-party providers, so two chats in the same cwd+model
get separate CLI processes instead of stomping on each other. Adds an LRU
cap of 16 live subprocesses so session-affinity keying doesn't accumulate
processes unboundedly.

Adds `bridgeOpencodeMcp` (default true): discovers opencode config via
OPENCODE_CONFIG, OPENCODE_CONFIG_DIR, walk-up from cwd, and XDG; parses
JSONC; translates opencode's `mcp` schema (type-discriminated, single
`command: string[]`, `environment`) to Claude CLI's `--mcp-config` shape
(`mcpServers`, separate `command`/`args`, `env`); writes a temp scratch
file and passes it through on spawn. Precedence: global < project <
OPENCODE_CONFIG_DIR < OPENCODE_CONFIG, matching opencode's merge order.
User-supplied `mcpConfig` entries stack on top of the bridged file.

Remaining known limitation (permission UI bypass) restated honestly in
README — a real fix requires a plugin-level permission.ask bridge via
Claude CLI's --permission-prompt-tool, which is out of scope here.
…mer, Windows spawn

- Use usage.iterations[-1] instead of cumulative totals to prevent
  inflated context size estimates and premature compaction
- Emit text-start/delta/end per content block instead of one pair per
  turn so partial text is preserved on abort
- Add 5s fallback timer that closes the stream if CLI emits content
  but never sends a result event (session-reuse edge case)
- Add shell: process.platform === 'win32' on both spawn sites so
  claude.cmd works on Windows
@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).

Publish maintained fork under a scoped npm name. Resets version to 0.1.0
since this is a new package on the registry.

- package.json: scoped name, author, publishConfig.access=public, repo URL
- src/index.ts: PACKAGE_NPM and plugin id
- src/models.ts: NPM constant used in default model api.npm
- jsr.json: scope updated
- README: title, fork attribution, npm install + all config snippets
Keep the original 'Claude Code' product name (vs the dropped 'code' or
invented 'cli' suffix) and use a scoped fork pattern so the relationship
to the upstream unixfox/opencode-claude-code-plugin stays legible.
- Correct model IDs (claude-haiku-4-5, claude-sonnet-4-5/4-6, claude-opus-4-5/4-6/4-7)
  instead of the haiku/sonnet/opus aliases inherited from the upstream README.
- Show the minimum config (just "npm") up front; move the full options block
  into a reference section so users don't think they have to redeclare models.
- Document the proxy MCP architecture, MCP bridge discovery order, session
  keying with x-session-affinity, plan mode handling, and the recent fixes
  (empty text block drop, lazy cwd, per-iteration usage, fallback timer).
- Recommend the simpler 'plugin: [...]' form as the primary config; the
  plugin's config hook self-registers the provider, so a separate
  provider.claude-code.npm block isn't needed.
- Fix the proxy-tools table: only Edit (not MultiEdit) is disabled when
  'Edit' is in proxyTools; call out the MultiEdit gap explicitly.
- Note that only bash/edit/write/webfetch are valid proxyTools values;
  anything else is silently ignored.

ci: set NODE_AUTH_TOKEN on publish step

Required for npm publish to authenticate via NPM_TOKEN; without it the
workflow runs but auth fails.
@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 30 commits July 26, 2026 15:31
Route ExitPlanMode through opencode's native `question` tool: render the
plan, end the turn on `tool-calls`, then feed the operator's answer back
to the CLI as the `tool_result` for the original ExitPlanMode tool_use.
That tool_result is what actually unlocks plan mode; a "yes" typed as
ordinary prose never does.

Absorbed from CollieIsCute's fork (8c5b583) with authorship preserved,
per their go-ahead on issue #21. Maintainer adaptations on top of the
original commit:

- Gated behind a new `planModeQuestion` option, default off. opencode's
  question form does not currently render (anomalyco/opencode#36604), so
  an ungated bridge would trade a working text prompt for a hang. All
  four ExitPlanMode sites keep the legacy text path in the `else`.
- Gated on the live registry too (`isPlanModeQuestionActive`): emitting a
  `question` tool-call on a build without that entry renders as invalid
  and wedges the turn.
- Gate resolved in the doStream/doGenerate prologue, since the branches
  run in a synchronous line handler and a reused process never reaches
  the spawn block.
- `fetchLiveToolInfo` memoized via `liveToolInfoOnce()` so the plan-mode
  gate shares the single `tool.list()` fetch with the proxy overlays;
  unresolved fetches are not memoized.
- Surfaced in the startup diagnostics block.
- Dropped the fork's unrelated package.json changes (`prepare` script,
  tsx version), kept the test-script entry.

Closes #21.
Parse opencode's real question tool result wrapper so a plan approval
answer maps back to the ExitPlanMode tool_use instead of being read as
free text. Replace the model-lifetime registry memo with a per-turn
loader so a later turn sees runtime tool changes. Clear pending
approvals centrally from deleteClaudeSessionId, and drop the stray
prepare lifecycle script (CI builds explicitly before publish).
Screenshots and pasted images from opencode were silently dropped
because toImageBlock() read part.data ?? part.url ?? part.source?.data
but AI SDK v4 ImagePart stores the binary in part.image.

Fix: add part.image as the first candidate in the lookup chain.
All existing type branches (string/Uint8Array/Buffer/URL) already
handle the values that part.image can hold.

(cherry picked from commit 60a6e9a)
Add two regression tests for toImageBlock: a v4 part.image payload must
survive (fails without flupkede's fix) and a data-carrying file part must
keep working. Update the fork sweep note with why the two compress
commits are held: JSON-RPC error envelope on tools/call, unconditional
context-note rewrite, restart racing pending tool results, and a prompt
that overstates how much context the restart actually drops.
Reimplements flupkede's compress branch. Claude calls
mcp__opencode_proxy__compress with a summary; the plugin answers it
in-process through a new interceptor map on the proxy MCP server, then
resets the Claude session at the start of the next turn so the fresh
child carries only that summary.

Fixes four defects in the original: interceptor failures now return an
MCP result with isError instead of a JSON-RPC envelope the CLI rejects,
the summary survives the deleteClaudeSessionId that the reset itself
calls, the reset defers while a turn is delivering tool results, and the
runtime note only advertises the tool when it is actually enabled.

Off by default, like Question. Tests in test-compress-tool.ts.
TaskOutput is displayed by running a real bash call, and only `"` was
escaped, so `$(...)`, backticks and `${...}` in the model-controlled
payload were executed while the operator saw a command that reads like a
print. Wrap the payload as one single-quoted word and print it with
printf, which also avoids echo's shell-dependent backslash handling.

Reported by @tkszeler in #27, with the printf fix they suggested. Tests
run the generated command through bash for six payload shapes.
proxyTools derives --disallowedTools from a literal name map, so a Claude
built-in with no proxy equivalent has no off switch: NotebookEdit today,
and anything added after this release. Add extraDisallowedTools, merged
with the proxy-implied set and the WebSearch case by one resolver.

Also stop resolvedProxyTools swallowing unknown names. A typo used to
leave the matching built-in enabled and unmediated with no signal, and a
wholly unrecognised list disabled proxying entirely.

Reported by @tkszeler in #26. The NotebookEdit proxy they also suggest is
not included: it needs a matching opencode registry entry to forward to,
which is unverified.
opencode and models.dev express `cost.input` / `cost.output` /
`cost.cache_read` / `cost.cache_write` in dollars per MILLION tokens —
opencode divides by 1e6 itself when multiplying a cost by a token count.
models.dev's own entry for the same model reads
`anthropic/claude-haiku-4-5 -> {"input": 1, "output": 5, "cache_read":
0.1, "cache_write": 1.25}`.

The constants here were written as per-token dollars (1e-6 for Haiku
input), so every session cost opencode reported came out exactly
1,000,000x too low — effectively always $0.00. Token counts, including
the cache read/write split, were already correct; only the dollar amount
was wrong.

Verified end-to-end against opencode 1.18.12 with a real Haiku 4.5 turn
(10 input / 62 output / 10,583 cache write / 15,973 cache read):

  before: $0.00000002   (reported / actual = 0.000001)
  after:  $0.01514605   (reported / actual = 1.000000)

The `(N×)` multiplier suffix on display names is unaffected — it is
derived from the input/output price ratios, which are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Costs are per million tokens after #25; note it so nobody restores the
per-token form. Auto-continue's keyword heuristic is unreachable on
current CLI (53/53 decisions stop at end-turn), which is why #15 was
closed and what a narrower fix would look like. Mark #26 and #27 done.
The in-process proxy MCP server binds an HTTP listener on 127.0.0.1 and
exposes tools that opencode executes, including bash, edit and write. The
handler accepted any POST to /mcp that parsed as JSON-RPC 2.0: no
authentication, no Origin or Host validation, and no Content-Type check.
The generated MCP config carried only {type, url}, so there was no shared
secret at all.

Any local process could therefore drive the endpoint, and because
Content-Type was unvalidated a cross-origin page could send a CORS
"simple request" with text/plain and get blind execution after finding
the port. Queued calls are drained and executed without correlation to a
model request, so an injected call runs as though the model had asked
for it.

Mint a 256-bit token per server, hand it to Claude in the headers block
of the generated MCP config (the CLI replays configured headers on every
request), and require it on every inbound call. Reject a foreign Host to
defeat DNS rebinding, reject any Origin, and require application/json so
cross-origin callers are forced into a preflight that fails. All guards
run before the body is read, so an unauthenticated peer cannot stream an
unbounded body into memory. The token is compared with timingSafeEqual
and is kept out of the URL and out of every log line.
test-proxy-task.ts drives the proxy MCP server two ways, and both were
unauthenticated once the endpoint began requiring a bearer token.

postRpc now takes the server rather than a bare URL so it can send the
Authorization header. The fake Claude CLI already parsed the generated
--mcp-config to find the proxy URL, so it now reads the headers block
from that same entry and replays it on each call, which is what a real
MCP client does. That makes these tests exercise the full round trip:
config generation, client replay, and server validation.
Two review findings from a cross-family pass.

Rejected requests ended the response but left the connection usable. A
peer could declare a large Content-Length, send one byte, take the 401
and hold the socket -- and server.close() does not reap connections that
are still sending, so shutdown blocked behind an unauthenticated caller
for Node's five-minute request timeout. All five reject paths now go
through one helper that sets Connection: close and tears the socket down
once the response has flushed.

The new regression deliberately never finishes its body. An earlier
version of the suite would have masked this, because it destroyed the
socket client-side as soon as the response arrived -- exactly the cleanup
the server must not depend on. Mutation-checked: with only the
Connection: close and teardown removed it fails at 4s instead of passing
at 4ms.

The 0600 mode assertion is now POSIX-gated. Node implements no
owner/group/other mode bits on Windows, where it commonly reads back 0666
and confidentiality rests on the inherited ACL of os.tmpdir() instead, so
asserting it there tested nothing and claiming it in the PR would have
promised a guarantee this patch does not provide.

Comments at the Host and Origin guards were corrected too: the exact-Host
check blocks DNS rebinding, NOT a page posting directly to the loopback
port, which sends exactly the expected Host.
The Host, Origin and Content-Type guards are measured properties of the
Claude CLI we spawn rather than spec guarantees, so a client-side change
would 403 every proxy call with no other symptom. Report the reason at
NOTICE, carrying no header values. Also authenticate the compress tests
and write the invariants down.
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.

10 participants