opencode-claude-max-proxy 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,61 +4,64 @@
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
  [![GitHub stars](https://img.shields.io/github/stars/rynfar/opencode-claude-max-proxy.svg)](https://github.com/rynfar/opencode-claude-max-proxy/stargazers)
6
6
 
7
- A transparent proxy that lets you use your **Claude Max subscription** with [OpenCode](https://opencode.ai) with full multi-model agent delegation.
7
+ A transparent proxy that allows a Claude Max subscription to be used with [OpenCode](https://opencode.ai), preserving multi-model agent routing.
8
8
 
9
- ## The Idea
9
+ OpenCode targets the Anthropic API, while Claude Max provides access to Claude via the [Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). The proxy forwards tool calls from Claude Max to OpenCode so agent routing works correctly.
10
10
 
11
- OpenCode speaks the Anthropic API. Claude Max gives you unlimited Claude through the [Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). This proxy bridges the two but the interesting part isn't the bridging, it's **how tool execution and agent delegation work**.
11
+ Tool execution is handled within the Agent SDK, which is responsible for running tools, coordinating sub-agents, and streaming responses. Because the SDK only has access to Claude models, any tool calls or delegated tasks are executed within that scope. In configurations such as [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode), where agents may be assigned to OpenaI, Gemini, or other providers, those assignments are not preserved at execution time, and all work is effectively routed through Claude.
12
12
 
13
- Most proxy approaches try to handle everything internally: the SDK runs tools, manages subagents, and streams back a finished result. The problem? The SDK only knows about Claude models. If you're using [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) with agents routed to GPT-5.4, Gemini, or other providers, the SDK can't reach them. Your carefully configured multi-model agent setup gets flattened to "everything runs on Claude."
13
+ We avoid that limitation by forwarding tool calls instead of executing them. When a `tool_use` event is emitted, the proxy intercepts it, halts the current turn, and sends the raw payload back to OpenCode. OpenCode executes the tool, including any agent routing, and returns the result as a `tool_result`. The proxy then resumes the SDK session.
14
14
 
15
- This proxy takes a different approach: **passthrough delegation**. Instead of handling tools internally, the proxy intercepts every tool call *before the SDK executes it*, stops the turn, and forwards the raw `tool_use` back to OpenCode. OpenCode handles execution including routing `Task` calls through its own agent system with full model routing. The result comes back as a standard `tool_result`, the proxy resumes the SDK session, and Claude continues.
16
-
17
- The effect: Claude thinks it's calling tools normally. OpenCode thinks it's talking to the Anthropic API. But behind the scenes, your oracle agent runs on GPT-5.4, your explore agent runs on Gemini, and the main session runs on Claude Max — exactly as configured.
15
+ From Claude’s perspective, tool usage proceeds normally. From OpenCode’s perspective, it is interacting with the Anthropic API. Execution remains distributed according to the configured agents, allowing different models to handle different roles without being constrained by the SDK.
18
16
 
19
17
  ```
20
18
  ┌──────────┐ ┌───────────────┐ ┌──────────────┐
21
- │ │ 1. Request │ │ SDK Auth │ │
19
+ │ │ │ │ │ │
22
20
  │ OpenCode │ ─────────────► │ Proxy │ ───────────► │ Claude Max │
23
- │ │ │ (localhost) │ │
24
- │ │ 4. tool_use │ │ 2. Generate │ │
25
- │ │ ◄───────────── PreToolUse │ ◄─────────── │ Response
26
- │ │ │ hook blocks │ │ │
27
- │ │ 5. Execute └──────────────┘
28
- │ │ ─── Task ───►
29
- │ │ (routes to ┌──────────────┐
30
- │ │ GPT-5.4, 6. Resume │ │ oh-my- │
31
- │ │ Gemini,etc) ◄──────────── │ │ opencode │
32
- │ │ │ tool_result agents
33
- │ │ 7. Final │ │
34
- │ │ ◄───────────── oracle:
35
- │ │ response │ │ │ GPT-5.4 │
36
- └──────────┘ └───────────────┘ explore:
37
- Gemini
38
- librarian:
39
- Sonnet
40
- └──────────────┘
21
+ │ │ │ (localhost) │ │ (Agent SDK)
22
+ │ │ │ │ │ │
23
+ │ │ │ ◄─────────── │ tool_use
24
+ │ │ │ ◄──────────── │ │ │
25
+ │ │ Intercept │ └──────────────┘
26
+ │ │ (stop turn)
27
+ │ │
28
+ │ │
29
+ │ │ ┌──────────────┐
30
+ │ │ │ │ OpenCode
31
+ │ │ agent
32
+ │ │ system
33
+ │ │ └──────────────┘
34
+ │ │
35
+ │ ▼
36
+ │ ◄────────────
37
+ │ Resume turn
38
+ │ │ │
39
+ │ │ ◄───────────── │
40
+ │ │ response │
41
+ └──────────┘ └───────────────┘
41
42
  ```
42
43
 
43
44
  ## How Passthrough Works
44
45
 
45
- The key insight is the Claude Agent SDK's `PreToolUse` hook — an officially supported callback that fires before any tool executes. Combined with `maxTurns: 1`, it gives us precise control over the execution boundary:
46
+ The Claude Agent SDK exposes a `PreToolUse` hook that fires before any tool executes. Combined with `maxTurns: 1`, it gives us precise control over the execution boundary without monkey-patching or stream rewriting.
46
47
 
47
48
  1. **Claude generates a response** with `tool_use` blocks (Read a file, delegate to an agent, run a command)
48
- 2. **The PreToolUse hook fires** for each tool call we capture the tool name, input, and ID, then return `decision: "block"` to prevent SDK-internal execution
49
+ 2. **The PreToolUse hook fires** for each tool call - we capture the tool name, input, and ID, then return `decision: "block"` to prevent SDK-internal execution
49
50
  3. **The SDK stops** (blocked tool + maxTurns:1 = turn complete) and we have the full tool_use payload
50
51
  4. **The proxy returns it to OpenCode** as a standard Anthropic API response with `stop_reason: "tool_use"`
51
52
  5. **OpenCode handles everything** — file reads, shell commands, and crucially, `Task` delegation through its own agent system with full model routing
52
53
  6. **OpenCode sends `tool_result` back**, the proxy resumes the SDK session, and Claude continues
53
54
 
54
- No monkey-patching. No forked SDKs. No fragile stream rewriting. Just a hook and a turn limit.
55
-
56
55
  ## Quick Start
57
56
 
58
57
  ### Prerequisites
59
58
 
60
- 1. **Claude Max subscription** — [Subscribe here](https://claude.ai/settings/subscription)
59
+ 1. **Claude Max subscription** — [Subscribe here](https://claude.ai/settings/billing)
61
60
  2. **Claude CLI** authenticated: `npm install -g @anthropic-ai/claude-code && claude login`
61
+ 3. **Clear any existing OpenCode Anthropic auth** — if you previously used OpenCode with a real Anthropic API key, log out first or the cached auth will override the proxy:
62
+ ```bash
63
+ opencode auth logout # select "anthropic" when prompted
64
+ ```
62
65
 
63
66
  ### Option A: npm Install
64
67
 
@@ -98,7 +101,7 @@ docker compose exec proxy claude login
98
101
  curl http://127.0.0.1:3456/health
99
102
  ```
100
103
 
101
- > **Note:** On macOS, `claude login` must be run inside the container (keychain credentials can't be mounted). On Linux, volume-mounting `~/.claude` may work without re-login.
104
+ > **Note:** On macOS, use `./bin/docker-auth.sh` to copy host credentials into the container (handles the keychain/scopes format difference). On Linux, volume-mounting `~/.claude` may work directly.
102
105
 
103
106
  ### Connect OpenCode
104
107
 
@@ -108,9 +111,9 @@ curl http://127.0.0.1:3456/health
108
111
  ./bin/oc.sh
109
112
  ```
110
113
 
111
- Each terminal gets its own proxy on a random port. No port conflicts, no concurrency crashes. The proxy starts automatically, connects OpenCode, and cleans up when you exit. Sessions resume across terminals via a shared session file.
114
+ Each terminal gets its own proxy on a random port. Proxy starts automatically, connects OpenCode, and cleans up on exit. Sessions resume across terminals via a shared file store.
112
115
 
113
- Add to your shell config for easy access:
116
+ Shell alias:
114
117
 
115
118
  ```bash
116
119
  # ~/.zshrc or ~/.bashrc
@@ -119,7 +122,7 @@ alias oc='/path/to/opencode-claude-max-proxy/bin/oc.sh'
119
122
 
120
123
  #### Shared Proxy
121
124
 
122
- If you prefer a single long-running proxy:
125
+ For a single long-running proxy:
123
126
 
124
127
  ```bash
125
128
  # Terminal 1: start the proxy
@@ -129,11 +132,11 @@ CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy
129
132
  ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
130
133
  ```
131
134
 
132
- The `ANTHROPIC_API_KEY` can be any non-empty string — the proxy doesn't use it. Authentication is handled by your `claude login` session.
135
+ `ANTHROPIC_API_KEY` can be any non-empty string. Authentication is handled by `claude login`.
133
136
 
134
137
  #### OpenCode Desktop / Config File
135
138
 
136
- For OpenCode Desktop (or to avoid env vars), add the proxy to `~/.config/opencode/opencode.json`. Start the shared proxy in the background and Desktop connects automatically.
139
+ Set the proxy URL in `~/.config/opencode/opencode.json` to use with Desktop or avoid env vars.
137
140
 
138
141
  ```json
139
142
  {
@@ -148,7 +151,7 @@ For OpenCode Desktop (or to avoid env vars), add the proxy to `~/.config/opencod
148
151
  }
149
152
  ```
150
153
 
151
- > **Tip:** Use the shared proxy with the supervisor for Desktop: `CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy`. Both Desktop and terminal instances share sessions via the file store.
154
+ > Desktop requires the shared proxy on a fixed port. Run `CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy` in the background. Sessions are shared between Desktop and terminal instances.
152
155
 
153
156
  ## Modes
154
157
 
@@ -172,13 +175,13 @@ bun run proxy
172
175
 
173
176
  Tools execute inside the proxy via MCP. Subagents run on Claude via the SDK's native agent system. Simpler setup, but all agents use Claude regardless of oh-my-opencode config.
174
177
 
175
- | | Passthrough | Internal |
176
- |---|---|---|
177
- | Tool execution | OpenCode | Proxy (MCP) |
178
- | Agent delegation | OpenCode → multi-model | SDK → Claude only |
179
- | oh-my-opencode models | ✅ Respected | ❌ All Claude |
180
- | Agent system prompts | ✅ Full | ⚠️ Description only |
181
- | Setup complexity | Same | Same |
178
+ | | Passthrough | Internal |
179
+ | --------------------- | ---------------------- | ------------------- |
180
+ | Tool execution | OpenCode | Proxy (MCP) |
181
+ | Agent delegation | OpenCode → multi-model | SDK → Claude only |
182
+ | oh-my-opencode models | ✅ Respected | ❌ All Claude |
183
+ | Agent system prompts | ✅ Full | ⚠️ Description only |
184
+ | Setup complexity | Same | Same |
182
185
 
183
186
  ## Works With Any Agent Framework
184
187
 
@@ -192,112 +195,93 @@ In internal mode, a `PreToolUse` hook fuzzy-matches agent names as a safety net
192
195
 
193
196
  ## Session Resume
194
197
 
195
- The proxy tracks SDK session IDs and resumes conversations on follow-up requests:
198
+ The proxy tracks SDK session IDs and resumes conversations on follow-up requests. Sessions are stored in `~/.cache/opencode-claude-max-proxy/sessions.json`, shared across all proxy instances (including per-terminal proxies).
196
199
 
197
- - **Faster responses** — no re-processing of conversation history
198
- - **Better context** — the SDK remembers tool results from previous turns
199
- - **Works across terminals** — sessions are shared via a file store at `~/.cache/opencode-claude-max-proxy/sessions.json`
200
+ Lookup order:
200
201
 
201
- Session tracking works two ways:
202
+ 1. **Header-based** add the included OpenCode plugin to inject session headers:
202
203
 
203
- 1. **Header-based** (recommended) — Add the included OpenCode plugin:
204
204
  ```json
205
- { "plugin": ["./path/to/opencode-claude-max-proxy/src/plugin/claude-max-headers.ts"] }
205
+ {
206
+ "plugin": [
207
+ "./path/to/opencode-claude-max-proxy/src/plugin/claude-max-headers.ts"
208
+ ]
209
+ }
206
210
  ```
207
211
 
208
- 2. **Fingerprint-based** (automatic fallback) — hashes the first user message to identify returning conversations
212
+ 2. **Fingerprint-based** (automatic fallback) — hashes the first user message to match returning conversations
209
213
 
210
- Sessions are cached for 24 hours. When using per-terminal proxies (`oc.sh`), the shared file store ensures a session started in one terminal can be resumed from another.
214
+ Sessions expire after 24 hours.
211
215
 
212
216
  ## Configuration
213
217
 
214
- | Variable | Default | Description |
215
- |----------|---------|-------------|
216
- | `CLAUDE_PROXY_PASSTHROUGH` | (unset) | Enable passthrough mode forward all tools to OpenCode |
217
- | `CLAUDE_PROXY_PORT` | 3456 | Proxy server port |
218
- | `CLAUDE_PROXY_HOST` | 127.0.0.1 | Proxy server host |
219
- | `CLAUDE_PROXY_WORKDIR` | (cwd) | Working directory for Claude and tools |
220
- | `CLAUDE_PROXY_MAX_CONCURRENT` | 1 | Max concurrent SDK sessions (increase with caution) |
221
- | `CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS` | 120 | Connection idle timeout |
218
+ | Variable | Default | Description |
219
+ | ----------------------------------- | --------- | -------------------------------------------------------- |
220
+ | `CLAUDE_PROXY_PASSTHROUGH` | (unset) | Enable passthrough mode to forward all tools to OpenCode |
221
+ | `CLAUDE_PROXY_PORT` | 3456 | Proxy server port |
222
+ | `CLAUDE_PROXY_HOST` | 127.0.0.1 | Proxy server host |
223
+ | `CLAUDE_PROXY_WORKDIR` | (cwd) | Working directory for Claude and tools |
224
+ | `CLAUDE_PROXY_MAX_CONCURRENT` | 1 | Max concurrent SDK sessions (increase with caution) |
225
+ | `CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS` | 120 | Connection idle timeout |
222
226
 
223
227
  ## Concurrency
224
228
 
225
- **Per-terminal proxies (`oc.sh`)** are the recommended approach for multiple terminals. Each OpenCode instance gets its own proxy — no concurrency issues at all.
226
-
227
- **Shared proxy** supports concurrent requests but has a known limitation:
228
-
229
- > **⚠️ Known Issue: Bun SSE Crash ([oven-sh/bun#17947](https://github.com/oven-sh/bun/issues/17947))**
230
- >
231
- > The Claude Agent SDK's `cli.js` subprocess (compiled with Bun) has a known segfault during cleanup of concurrent streaming responses.
232
- >
233
- > - **All responses are delivered correctly** — the crash only occurs after responses complete
234
- > - **The supervisor auto-restarts** in ~1-3 seconds
235
- > - **Per-terminal proxies avoid this entirely** — no concurrency, no crash
236
- >
237
- > We are monitoring the upstream Bun issue for a fix.
238
-
239
- ## Model Mapping
240
-
241
- | OpenCode Model | Claude SDK |
242
- |----------------|------------|
243
- | `anthropic/claude-opus-*` | opus |
244
- | `anthropic/claude-sonnet-*` | sonnet (default) |
245
- | `anthropic/claude-haiku-*` | haiku |
229
+ Per-terminal proxies (`oc.sh`) avoid concurrency issues entirely. Each terminal gets its own proxy.
246
230
 
247
- ## Disclaimer
248
-
249
- This project is an **unofficial wrapper** around Anthropic's publicly available [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). It is not affiliated with, endorsed by, or supported by Anthropic.
250
-
251
- **Use at your own risk.** The authors make no claims regarding compliance with Anthropic's Terms of Service. It is your responsibility to review and comply with [Anthropic's Terms of Service](https://www.anthropic.com/terms) and any applicable usage policies. Terms may change at any time.
252
-
253
- This project calls `query()` from Anthropic's public npm package using your own authenticated account. No API keys are intercepted, no authentication is bypassed, and no proprietary systems are reverse-engineered.
231
+ The shared proxy supports concurrent requests but the SDK's `cli.js` subprocess (compiled with Bun) can segfault during stream cleanup ([oven-sh/bun#17947](https://github.com/oven-sh/bun/issues/17947)). Responses are always delivered correctly; the crash occurs after completion. The supervisor auto-restarts within a few seconds.
254
232
 
255
233
  ## FAQ
256
234
 
257
235
  <details>
258
236
  <summary><strong>Why passthrough mode instead of handling tools internally?</strong></summary>
259
237
 
260
- Internal tool execution means the SDK handles everything but the SDK only speaks Claude. If your agents are configured for GPT-5.4 or Gemini via oh-my-opencode, that routing gets lost. Passthrough mode preserves the full OpenCode agent pipeline, including multi-model routing, full system prompts, and agent lifecycle management.
238
+ If the Agent SDK executes tools directly, everything runs through Claude. Any agent routing defined in OpenCode is bypassed. Passthrough mode just sends the tool calls to OpenCode to run.
239
+
261
240
  </details>
262
241
 
263
242
  <details>
264
243
  <summary><strong>Does this work without oh-my-opencode?</strong></summary>
265
244
 
266
- Yes. Both modes work with native OpenCode (build + plan agents) and with any custom agents defined in your `opencode.json`. oh-my-opencode just adds more agents and model routing the proxy handles whatever OpenCode sends.
245
+ Yes. Both modes work with native OpenCode (build + plan agents) and with any custom agents defined in your `opencode.json`. oh-my-opencode just adds more agents and model routing. The proxy handles whatever OpenCode sends.
246
+
267
247
  </details>
268
248
 
269
249
  <details>
270
- <summary><strong>Why do I need ANTHROPIC_API_KEY=dummy?</strong></summary>
250
+ <summary><strong>Why do I need `ANTHROPIC_API_KEY=dummy`?</strong></summary>
271
251
 
272
252
  OpenCode requires an API key to be set, but the proxy never uses it. Authentication is handled by your `claude login` session through the Agent SDK.
253
+
273
254
  </details>
274
255
 
275
256
  <details>
276
257
  <summary><strong>What about rate limits?</strong></summary>
277
258
 
278
259
  Your Claude Max subscription has its own usage limits. The proxy doesn't add any additional limits. Concurrent requests are supported.
260
+
279
261
  </details>
280
262
 
281
263
  <details>
282
264
  <summary><strong>Is my data sent anywhere else?</strong></summary>
283
265
 
284
266
  No. The proxy runs locally. Requests go directly to Claude through the official SDK. In passthrough mode, tool execution happens in OpenCode on your machine.
267
+
285
268
  </details>
286
269
 
287
270
  <details>
288
271
  <summary><strong>Why does internal mode use MCP tools?</strong></summary>
289
272
 
290
273
  The Claude Agent SDK uses different parameter names for tools than OpenCode (e.g., `file_path` vs `filePath`). Internal mode provides its own MCP tools with SDK-compatible parameter names. Passthrough mode doesn't need this since OpenCode handles tool execution directly.
274
+
291
275
  </details>
292
276
 
293
277
  ## Troubleshooting
294
278
 
295
- | Problem | Solution |
296
- |---------|----------|
297
- | "Authentication failed" | Run `claude login` to authenticate |
298
- | "Connection refused" | Make sure the proxy is running: `bun run proxy` |
299
- | "Port 3456 is already in use" | `kill $(lsof -ti :3456)` or use `CLAUDE_PROXY_PORT=4567` |
300
- | Title generation fails | Set `"small_model": "anthropic/claude-haiku-4-5"` in your OpenCode config |
279
+ | Problem | Solution |
280
+ | ----------------------------- | ------------------------------------------------------------------------- |
281
+ | "Authentication failed" | Run `claude login` to authenticate |
282
+ | "Connection refused" | Make sure the proxy is running: `bun run proxy` |
283
+ | "Port 3456 is already in use" | `kill $(lsof -ti :3456)` or use `CLAUDE_PROXY_PORT=4567` |
284
+ | Title generation fails | Set `"small_model": "anthropic/claude-haiku-4-5"` in your OpenCode config |
301
285
 
302
286
  ## Auto-start (macOS)
303
287
 
@@ -331,23 +315,14 @@ EOF
331
315
  launchctl load ~/Library/LaunchAgents/com.claude-max-proxy.plist
332
316
  ```
333
317
 
334
- ## Testing
318
+ ## Development
319
+
320
+ ### Run tests
335
321
 
336
322
  ```bash
337
323
  bun test
338
324
  ```
339
325
 
340
- 106 tests across 13 files covering:
341
- - Passthrough tool forwarding and tool_result acceptance
342
- - PreToolUse hook interception and agent name fuzzy matching
343
- - SDK agent definition extraction (native OpenCode + oh-my-opencode)
344
- - MCP tool filtering (internal mode)
345
- - Session resume (header-based and fingerprint-based)
346
- - Streaming message deduplication
347
- - Working directory propagation
348
- - Concurrent request handling
349
- - Error classification (auth, rate limit, billing, timeout)
350
-
351
326
  ### Health Endpoint
352
327
 
353
328
  ```bash
@@ -356,7 +331,7 @@ curl http://127.0.0.1:3456/health
356
331
 
357
332
  Returns auth status, subscription type, and proxy mode. Use this to verify the proxy is running and authenticated before connecting OpenCode.
358
333
 
359
- ## Architecture
334
+ ### Architecture
360
335
 
361
336
  ```
362
337
  src/
@@ -368,24 +343,17 @@ src/
368
343
  ├── mcpTools.ts # MCP tool definitions for internal mode (read, write, edit, bash, glob, grep)
369
344
  ├── logger.ts # Structured logging with AsyncLocalStorage context
370
345
  ├── plugin/
371
- └── claude-max-headers.ts # OpenCode plugin for session header injection
372
- └── __tests__/ # 106 tests across 13 files
373
- ├── helpers.ts
374
- ├── integration.test.ts
375
- ├── proxy-agent-definitions.test.ts
376
- ├── proxy-agent-fuzzy-match.test.ts
377
- ├── proxy-error-handling.test.ts
378
- ├── proxy-mcp-filtering.test.ts
379
- ├── proxy-passthrough-concept.test.ts
380
- ├── proxy-pretooluse-hook.test.ts
381
- ├── proxy-session-resume.test.ts
382
- ├── proxy-streaming-message.test.ts
383
- ├── proxy-subagent-support.test.ts
384
- ├── proxy-tool-forwarding.test.ts
385
- ├── proxy-transparent-tools.test.ts
386
- └── proxy-working-directory.test.ts
346
+ └── claude-max-headers.ts # OpenCode plugin for session header injection
387
347
  ```
388
348
 
349
+ ## Disclaimer
350
+
351
+ This project is an **unofficial wrapper** around Anthropic's publicly available [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). It is not affiliated with, endorsed by, or supported by Anthropic.
352
+
353
+ **Use at your own risk.** The authors make no claims regarding compliance with Anthropic's Terms of Service. It is your responsibility to review and comply with [Anthropic's Terms of Service](https://www.anthropic.com/legal/consumer-terms) and [Authorized Usage Policy](https://www.anthropic.com/legal/aup). Terms may change at any time.
354
+
355
+ This project calls `query()` from Anthropic's public npm package using your own authenticated account. No API keys are intercepted, no authentication is bypassed, and no proprietary systems are reverse-engineered.
356
+
389
357
  ## License
390
358
 
391
359
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-max-proxy",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Use your Claude Max subscription with OpenCode via proxy server",
5
5
  "type": "module",
6
6
  "main": "./src/proxy/server.ts",
@@ -32,7 +32,9 @@
32
32
  "bin/",
33
33
  "src/proxy/",
34
34
  "src/logger.ts",
35
- "README.md"
35
+ "src/mcpTools.ts",
36
+ "README.md",
37
+ "src/plugin/"
36
38
  ],
37
39
  "keywords": [
38
40
  "opencode",
@@ -0,0 +1,185 @@
1
+ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk"
2
+ import { z } from "zod"
3
+ import * as fs from "node:fs/promises"
4
+ import * as path from "node:path"
5
+ import { exec } from "node:child_process"
6
+ import { promisify } from "node:util"
7
+ import { glob as globLib } from "glob"
8
+
9
+ const execAsync = promisify(exec)
10
+
11
+ const getCwd = () => process.env.CLAUDE_PROXY_WORKDIR || process.cwd()
12
+
13
+ export const opencodeMcpServer = createSdkMcpServer({
14
+ name: "opencode",
15
+ version: "1.0.0",
16
+ tools: [
17
+ tool(
18
+ "read",
19
+ "Read the contents of a file at the specified path",
20
+ {
21
+ path: z.string().describe("Absolute or relative path to the file"),
22
+ encoding: z.string().optional().describe("File encoding, defaults to utf-8")
23
+ },
24
+ async (args) => {
25
+ try {
26
+ const filePath = path.isAbsolute(args.path)
27
+ ? args.path
28
+ : path.resolve(getCwd(), args.path)
29
+ const content = await fs.readFile(filePath, (args.encoding || "utf-8") as BufferEncoding)
30
+ return {
31
+ content: [{ type: "text", text: content }]
32
+ }
33
+ } catch (error) {
34
+ return {
35
+ content: [{ type: "text", text: `Error reading file: ${error instanceof Error ? error.message : String(error)}` }],
36
+ isError: true
37
+ }
38
+ }
39
+ }
40
+ ),
41
+
42
+ tool(
43
+ "write",
44
+ "Write content to a file, creating directories if needed",
45
+ {
46
+ path: z.string().describe("Path to write to"),
47
+ content: z.string().describe("Content to write")
48
+ },
49
+ async (args) => {
50
+ try {
51
+ const filePath = path.isAbsolute(args.path)
52
+ ? args.path
53
+ : path.resolve(getCwd(), args.path)
54
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
55
+ await fs.writeFile(filePath, args.content, "utf-8")
56
+ return {
57
+ content: [{ type: "text", text: `Successfully wrote to ${args.path}` }]
58
+ }
59
+ } catch (error) {
60
+ return {
61
+ content: [{ type: "text", text: `Error writing file: ${error instanceof Error ? error.message : String(error)}` }],
62
+ isError: true
63
+ }
64
+ }
65
+ }
66
+ ),
67
+
68
+ tool(
69
+ "edit",
70
+ "Edit a file by replacing oldString with newString",
71
+ {
72
+ path: z.string().describe("Path to the file to edit"),
73
+ oldString: z.string().describe("The text to replace"),
74
+ newString: z.string().describe("The replacement text")
75
+ },
76
+ async (args) => {
77
+ try {
78
+ const filePath = path.isAbsolute(args.path)
79
+ ? args.path
80
+ : path.resolve(getCwd(), args.path)
81
+ const content = await fs.readFile(filePath, "utf-8")
82
+ if (!content.includes(args.oldString)) {
83
+ return {
84
+ content: [{ type: "text", text: `Error: oldString not found in file` }],
85
+ isError: true
86
+ }
87
+ }
88
+ const newContent = content.replace(args.oldString, args.newString)
89
+ await fs.writeFile(filePath, newContent, "utf-8")
90
+ return {
91
+ content: [{ type: "text", text: `Successfully edited ${args.path}` }]
92
+ }
93
+ } catch (error) {
94
+ return {
95
+ content: [{ type: "text", text: `Error editing file: ${error instanceof Error ? error.message : String(error)}` }],
96
+ isError: true
97
+ }
98
+ }
99
+ }
100
+ ),
101
+
102
+ tool(
103
+ "bash",
104
+ "Execute a bash command and return the output",
105
+ {
106
+ command: z.string().describe("The command to execute"),
107
+ cwd: z.string().optional().describe("Working directory for the command")
108
+ },
109
+ async (args) => {
110
+ try {
111
+ const options = {
112
+ cwd: args.cwd || getCwd(),
113
+ timeout: 120000
114
+ }
115
+ const { stdout, stderr } = await execAsync(args.command, options)
116
+ const output = stdout || stderr || "(no output)"
117
+ return {
118
+ content: [{ type: "text", text: output }]
119
+ }
120
+ } catch (error: unknown) {
121
+ const execError = error as { stdout?: string; stderr?: string; message?: string }
122
+ const output = execError.stdout || execError.stderr || execError.message || String(error)
123
+ return {
124
+ content: [{ type: "text", text: output }],
125
+ isError: true
126
+ }
127
+ }
128
+ }
129
+ ),
130
+
131
+ tool(
132
+ "glob",
133
+ "Find files matching a glob pattern",
134
+ {
135
+ pattern: z.string().describe("Glob pattern like **/*.ts"),
136
+ cwd: z.string().optional().describe("Base directory for the search")
137
+ },
138
+ async (args) => {
139
+ try {
140
+ const files = await globLib(args.pattern, {
141
+ cwd: args.cwd || getCwd(),
142
+ nodir: true,
143
+ ignore: ["**/node_modules/**", "**/.git/**"]
144
+ })
145
+ return {
146
+ content: [{ type: "text", text: files.join("\n") || "(no matches)" }]
147
+ }
148
+ } catch (error) {
149
+ return {
150
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
151
+ isError: true
152
+ }
153
+ }
154
+ }
155
+ ),
156
+
157
+ tool(
158
+ "grep",
159
+ "Search for a pattern in files",
160
+ {
161
+ pattern: z.string().describe("Regex pattern to search for"),
162
+ path: z.string().optional().describe("Directory or file to search in"),
163
+ include: z.string().optional().describe("File pattern to include, e.g., *.ts")
164
+ },
165
+ async (args) => {
166
+ try {
167
+ const searchPath = args.path || getCwd()
168
+ const includePattern = args.include || "*"
169
+
170
+ let cmd = `grep -rn --include="${includePattern}" "${args.pattern}" "${searchPath}" 2>/dev/null || true`
171
+ const { stdout } = await execAsync(cmd, { maxBuffer: 10 * 1024 * 1024 })
172
+
173
+ return {
174
+ content: [{ type: "text", text: stdout || "(no matches)" }]
175
+ }
176
+ } catch (error) {
177
+ return {
178
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
179
+ isError: true
180
+ }
181
+ }
182
+ }
183
+ )
184
+ ]
185
+ })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * OpenCode plugin that injects session tracking headers into Anthropic API requests.
3
+ *
4
+ * This enables the claude-max-proxy to reliably track sessions and resume
5
+ * Claude Agent SDK conversations instead of starting fresh every time.
6
+ *
7
+ * Installation:
8
+ * Add to your opencode.json:
9
+ * { "plugin": ["./path/to/claude-max-headers.ts"] }
10
+ *
11
+ * Or copy to your project's .opencode/plugin/ directory.
12
+ *
13
+ * What it does:
14
+ * Adds x-opencode-session and x-opencode-request headers to requests
15
+ * sent to the Anthropic provider. The proxy uses these to map OpenCode
16
+ * sessions to Claude SDK sessions for conversation resumption.
17
+ *
18
+ * Without this plugin:
19
+ * The proxy falls back to fingerprint-based session matching (hashing
20
+ * the first user message). This works but is less reliable.
21
+ */
22
+
23
+ type ChatHeadersHook = (
24
+ incoming: {
25
+ sessionID: string
26
+ agent: any
27
+ model: { providerID: string }
28
+ provider: any
29
+ message: { id: string }
30
+ },
31
+ output: { headers: Record<string, string> }
32
+ ) => Promise<void>
33
+
34
+ type PluginHooks = {
35
+ "chat.headers"?: ChatHeadersHook
36
+ }
37
+
38
+ type PluginFn = (input: any) => Promise<PluginHooks>
39
+
40
+ export const ClaudeMaxHeadersPlugin: PluginFn = async (_input) => {
41
+ return {
42
+ "chat.headers": async (incoming, output) => {
43
+ // Only inject headers for Anthropic provider requests
44
+ if (incoming.model.providerID !== "anthropic") return
45
+
46
+ output.headers["x-opencode-session"] = incoming.sessionID
47
+ output.headers["x-opencode-request"] = incoming.message.id
48
+ },
49
+ }
50
+ }
51
+
52
+ export default ClaudeMaxHeadersPlugin
@@ -118,7 +118,7 @@ function storeSession(
118
118
  if (fp) fingerprintCache.set(fp, state)
119
119
  // Shared file store (cross-proxy resume)
120
120
  const key = opencodeSessionId || fp
121
- if (key) storeSharedSession(key, claudeSessionId)
121
+ if (key) storeSharedSession(key, claudeSessionId, state.messageCount)
122
122
  }
123
123
 
124
124
  /** Extract only the last user message (for resume — SDK already has history) */
@@ -415,34 +415,118 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
415
415
 
416
416
 
417
417
 
418
- // When resuming, only send the last user message (SDK already has history)
419
- const messagesToConvert = isResume
420
- ? getLastUserMessage(body.messages || [])
421
- : body.messages
418
+ // When resuming, only send new messages the SDK doesn't have.
419
+ const allMessages = body.messages || []
420
+ let messagesToConvert: typeof allMessages
422
421
 
423
- // Convert messages to a text prompt, preserving all content types
424
- const conversationParts = messagesToConvert
425
- ?.map((m: { role: string; content: string | Array<{ type: string; text?: string; content?: string; tool_use_id?: string; name?: string; input?: unknown; id?: string }> }) => {
426
- const role = m.role === "assistant" ? "Assistant" : "Human"
427
- let content: string
422
+ if (isResume && cachedSession) {
423
+ const knownCount = cachedSession.messageCount || 0
424
+ if (knownCount > 0 && knownCount < allMessages.length) {
425
+ messagesToConvert = allMessages.slice(knownCount)
426
+ } else {
427
+ messagesToConvert = getLastUserMessage(allMessages)
428
+ }
429
+ } else {
430
+ messagesToConvert = allMessages
431
+ }
432
+
433
+ // Check if any messages contain multimodal content (images, documents, files)
434
+ const MULTIMODAL_TYPES = new Set(["image", "document", "file"])
435
+ const hasMultimodal = messagesToConvert?.some((m: any) =>
436
+ Array.isArray(m.content) && m.content.some((b: any) => MULTIMODAL_TYPES.has(b.type))
437
+ )
438
+
439
+ // Strip cache_control from content blocks — the SDK manages its own caching
440
+ // and OpenCode's ttl='1h' blocks conflict with the SDK's ttl='5m' blocks
441
+ function stripCacheControl(content: any): any {
442
+ if (!Array.isArray(content)) return content
443
+ return content.map((block: any) => {
444
+ if (block.cache_control) {
445
+ const { cache_control, ...rest } = block
446
+ return rest
447
+ }
448
+ return block
449
+ })
450
+ }
451
+
452
+ // Build the prompt — either structured (multimodal) or text
453
+ let prompt: string | AsyncIterable<any>
454
+
455
+ if (hasMultimodal) {
456
+ // Structured messages preserve image/document/file blocks for Claude to see.
457
+ // The SDK only accepts role:"user" in SDKUserMessage, so assistant messages
458
+ // are converted to text summaries wrapped as user messages.
459
+ const structured = messagesToConvert.map((m: any) => {
460
+ if (m.role === "user") {
461
+ return {
462
+ type: "user" as const,
463
+ message: { role: "user" as const, content: stripCacheControl(m.content) },
464
+ parent_tool_use_id: null,
465
+ }
466
+ }
467
+ // Convert assistant/tool messages to text summary
468
+ let text: string
428
469
  if (typeof m.content === "string") {
429
- content = m.content
470
+ text = `[Assistant: ${m.content}]`
430
471
  } else if (Array.isArray(m.content)) {
431
- content = m.content
432
- .map((block: any) => {
433
- if (block.type === "text" && block.text) return block.text
434
- if (block.type === "tool_use") return `[Tool Use: ${block.name}(${JSON.stringify(block.input)})]`
435
- if (block.type === "tool_result") return `[Tool Result for ${block.tool_use_id}: ${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}]`
436
- return ""
437
- })
438
- .filter(Boolean)
439
- .join("\n")
472
+ text = m.content.map((b: any) => {
473
+ if (b.type === "text" && b.text) return `[Assistant: ${b.text}]`
474
+ if (b.type === "tool_use") return `[Tool Use: ${b.name}(${JSON.stringify(b.input)})]`
475
+ if (b.type === "tool_result") return `[Tool Result: ${typeof b.content === "string" ? b.content : JSON.stringify(b.content)}]`
476
+ return ""
477
+ }).filter(Boolean).join("\n")
440
478
  } else {
441
- content = String(m.content)
479
+ text = `[Assistant: ${String(m.content)}]`
480
+ }
481
+ return {
482
+ type: "user" as const,
483
+ message: { role: "user" as const, content: text },
484
+ parent_tool_use_id: null,
442
485
  }
443
- return `${role}: ${content}`
444
486
  })
445
- .join("\n\n") || ""
487
+
488
+ // Prepend system context as a text message
489
+ if (systemContext) {
490
+ structured.unshift({
491
+ type: "user" as const,
492
+ message: { role: "user", content: systemContext },
493
+ parent_tool_use_id: null,
494
+ })
495
+ }
496
+
497
+ prompt = (async function* () { for (const msg of structured) yield msg })()
498
+ } else {
499
+ // Text prompt — convert messages to string
500
+ const conversationParts = messagesToConvert
501
+ ?.map((m: { role: string; content: string | Array<{ type: string; text?: string; content?: string; tool_use_id?: string; name?: string; input?: unknown; id?: string }> }) => {
502
+ const role = m.role === "assistant" ? "Assistant" : "Human"
503
+ let content: string
504
+ if (typeof m.content === "string") {
505
+ content = m.content
506
+ } else if (Array.isArray(m.content)) {
507
+ content = m.content
508
+ .map((block: any) => {
509
+ if (block.type === "text" && block.text) return block.text
510
+ if (block.type === "tool_use") return `[Tool Use: ${block.name}(${JSON.stringify(block.input)})]`
511
+ if (block.type === "tool_result") return `[Tool Result for ${block.tool_use_id}: ${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}]`
512
+ if (block.type === "image") return "[Image attached]"
513
+ if (block.type === "document") return "[Document attached]"
514
+ if (block.type === "file") return "[File attached]"
515
+ return ""
516
+ })
517
+ .filter(Boolean)
518
+ .join("\n")
519
+ } else {
520
+ content = String(m.content)
521
+ }
522
+ return `${role}: ${content}`
523
+ })
524
+ .join("\n\n") || ""
525
+
526
+ prompt = systemContext
527
+ ? `${systemContext}\n\n${conversationParts}`
528
+ : conversationParts
529
+ }
446
530
 
447
531
  // --- Passthrough mode ---
448
532
  // When enabled, ALL tool execution is forwarded to OpenCode instead of
@@ -499,10 +583,7 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
499
583
  }
500
584
  : undefined
501
585
 
502
- // Combine system context with conversation
503
- const prompt = systemContext
504
- ? `${systemContext}\n\n${conversationParts}`
505
- : conversationParts
586
+
506
587
 
507
588
  if (!stream) {
508
589
  const contentBlocks: Array<Record<string, unknown>> = []
@@ -1034,6 +1115,12 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
1034
1115
  }
1035
1116
  })
1036
1117
 
1118
+ // Catch-all: log unhandled requests
1119
+ app.all("*", (c) => {
1120
+ console.error(`[PROXY] UNHANDLED ${c.req.method} ${c.req.url}`)
1121
+ return c.json({ error: { type: "not_found", message: `Endpoint not supported: ${c.req.method} ${new URL(c.req.url).pathname}` } }, 404)
1122
+ })
1123
+
1037
1124
  return { app, config: finalConfig }
1038
1125
  }
1039
1126
 
@@ -18,6 +18,7 @@ export interface StoredSession {
18
18
  claudeSessionId: string
19
19
  createdAt: number
20
20
  lastUsedAt: number
21
+ messageCount: number
21
22
  }
22
23
 
23
24
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
@@ -73,13 +74,14 @@ export function lookupSharedSession(key: string): StoredSession | undefined {
73
74
  return session
74
75
  }
75
76
 
76
- export function storeSharedSession(key: string, claudeSessionId: string): void {
77
+ export function storeSharedSession(key: string, claudeSessionId: string, messageCount?: number): void {
77
78
  const store = readStore()
78
79
  const existing = store[key]
79
80
  store[key] = {
80
81
  claudeSessionId,
81
82
  createdAt: existing?.createdAt || Date.now(),
82
83
  lastUsedAt: Date.now(),
84
+ messageCount: messageCount ?? existing?.messageCount ?? 0,
83
85
  }
84
86
  writeStore(store)
85
87
  }