opencode-claude-max-proxy 1.0.2 → 1.7.3

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,199 +4,330 @@
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
- Use your **Claude Max subscription** with [OpenCode](https://github.com/opencode-ai/opencode). Built to work with [Oh-My-OpenCode](https://github.com/code-yeongyu/oh-my-opencode) and its full agent orchestration system.
7
+ A transparent proxy that lets you use your **Claude Max subscription** with [OpenCode](https://opencode.ai) with full multi-model agent delegation.
8
8
 
9
- ![OpenCode with Claude Max](screenshot.png)
9
+ ## The Idea
10
10
 
11
- ## The Problem
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**.
12
12
 
13
- Anthropic doesn't allow Claude Max subscribers to use their subscription with third-party tools like OpenCode. If you want to use Claude in OpenCode, you have to pay for API access separately - even though you're already paying for "unlimited" Claude.
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."
14
14
 
15
- Your options are:
16
- 1. Use Claude's official apps only (limited to their UI)
17
- 2. Pay again for API access on top of your Max subscription
18
- 3. **Use this proxy**
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.
19
16
 
20
- ## The Solution
21
-
22
- This proxy bridges the gap using Anthropic's own tools:
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.
23
18
 
24
19
  ```
25
- OpenCode → Proxy (localhost:3456) → Claude Agent SDK → Your Claude Max Subscription
20
+ ┌──────────┐ ┌───────────────┐ ┌──────────────┐
21
+ │ │ 1. Request │ │ SDK Auth │ │
22
+ │ 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
+ └──────────────┘
26
41
  ```
27
42
 
28
- The [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) is Anthropic's **official npm package** that lets developers build with Claude using their Max subscription. This proxy simply translates OpenCode's API requests into SDK calls.
43
+ ## How Passthrough Works
29
44
 
30
- **Your Max subscription. Anthropic's official SDK. Zero additional cost.**
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:
31
46
 
32
- ## Is This Allowed?
47
+ 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
+ 3. **The SDK stops** (blocked tool + maxTurns:1 = turn complete) and we have the full tool_use payload
50
+ 4. **The proxy returns it to OpenCode** as a standard Anthropic API response with `stop_reason: "tool_use"`
51
+ 5. **OpenCode handles everything** — file reads, shell commands, and crucially, `Task` delegation through its own agent system with full model routing
52
+ 6. **OpenCode sends `tool_result` back**, the proxy resumes the SDK session, and Claude continues
33
53
 
34
- **Yes.** Here's why:
54
+ No monkey-patching. No forked SDKs. No fragile stream rewriting. Just a hook and a turn limit.
35
55
 
36
- | Concern | Reality |
37
- |---------|---------|
38
- | "Bypassing restrictions" | No. We use Anthropic's public SDK exactly as documented |
39
- | "Violating TOS" | No. The SDK is designed for programmatic Claude access |
40
- | "Unauthorized access" | No. You authenticate with `claude login` using your own account |
41
- | "Reverse engineering" | No. We call `query()` from their npm package, that's it |
56
+ ## Quick Start
42
57
 
43
- The Claude Agent SDK exists specifically to let Max subscribers use Claude programmatically. We're just translating the request format so OpenCode can use it.
58
+ ### Prerequisites
44
59
 
45
- **~200 lines of TypeScript. No hacks. No magic. Just format translation.**
60
+ 1. **Claude Max subscription** [Subscribe here](https://claude.ai/settings/subscription)
61
+ 2. **Claude CLI** authenticated: `npm install -g @anthropic-ai/claude-code && claude login`
62
+ 3. **Bun** runtime: `curl -fsSL https://bun.sh/install | bash`
46
63
 
47
- ## Features
64
+ ### Install & Run
48
65
 
49
- | Feature | Description |
50
- |---------|-------------|
51
- | **Zero API costs** | Uses your Claude Max subscription, not per-token billing |
52
- | **Full compatibility** | Works with any Anthropic model in OpenCode |
53
- | **Streaming support** | Real-time SSE streaming just like the real API |
54
- | **Auto-start** | Optional launchd service for macOS |
55
- | **Simple setup** | Two commands to get running |
66
+ ```bash
67
+ git clone https://github.com/rynfar/opencode-claude-max-proxy
68
+ cd opencode-claude-max-proxy
69
+ bun install
56
70
 
57
- ## Prerequisites
71
+ # Start in passthrough mode (recommended)
72
+ CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy
73
+ ```
58
74
 
59
- 1. **Claude Max subscription** - [Subscribe here](https://claude.ai/settings/subscription)
75
+ ### Connect OpenCode
60
76
 
61
- 2. **Claude CLI** installed and authenticated:
62
- ```bash
63
- npm install -g @anthropic-ai/claude-code
64
- claude login
65
- ```
77
+ ```bash
78
+ ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
79
+ ```
66
80
 
67
- 3. **Bun** runtime:
68
- ```bash
69
- curl -fsSL https://bun.sh/install | bash
70
- ```
81
+ The `ANTHROPIC_API_KEY` can be any non-empty string — the proxy doesn't use it. Authentication is handled by your `claude login` session.
71
82
 
72
- ## Installation
83
+ ### Shell Alias
73
84
 
74
85
  ```bash
75
- git clone https://github.com/rynfar/opencode-claude-max-proxy
76
- cd opencode-claude-max-proxy
77
- bun install
86
+ # Add to ~/.zshrc or ~/.bashrc
87
+ alias oc='ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode'
78
88
  ```
79
89
 
80
- ## Usage
90
+ ## Modes
81
91
 
82
- ### Start the Proxy
92
+ ### Passthrough Mode (recommended)
83
93
 
84
94
  ```bash
85
- bun run proxy
95
+ CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy
86
96
  ```
87
97
 
88
- ### Run OpenCode
98
+ All tool execution is forwarded to OpenCode. This enables:
99
+
100
+ - **Multi-model agent delegation** — oh-my-opencode routes each agent to its configured model
101
+ - **Full agent system prompts** — not abbreviated descriptions, the real prompts
102
+ - **OpenCode manages everything** — tools, agents, permissions, lifecycle
103
+
104
+ ### Internal Mode (default)
89
105
 
90
106
  ```bash
91
- ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
107
+ bun run proxy
92
108
  ```
93
109
 
94
- Select any `anthropic/claude-*` model (opus, sonnet, haiku).
110
+ 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.
95
111
 
96
- ### One-liner
112
+ | | Passthrough | Internal |
113
+ |---|---|---|
114
+ | Tool execution | OpenCode | Proxy (MCP) |
115
+ | Agent delegation | OpenCode → multi-model | SDK → Claude only |
116
+ | oh-my-opencode models | ✅ Respected | ❌ All Claude |
117
+ | Agent system prompts | ✅ Full | ⚠️ Description only |
118
+ | Setup complexity | Same | Same |
97
119
 
98
- ```bash
99
- bun run proxy & ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
100
- ```
120
+ ## Works With Any Agent Framework
101
121
 
102
- ## Auto-start on macOS
122
+ The proxy extracts agent definitions from the `Task` tool description that OpenCode sends in each request. This means it works automatically with:
103
123
 
104
- Set up the proxy to run automatically on login:
124
+ - **Native OpenCode** `build` and `plan` agents
125
+ - **oh-my-opencode** — `oracle`, `explore`, `librarian`, `sisyphus-junior`, `metis`, `momus`, etc.
126
+ - **Custom agents** — anything you define in `opencode.json`
105
127
 
106
- ```bash
107
- cat > ~/Library/LaunchAgents/com.claude-max-proxy.plist << EOF
108
- <?xml version="1.0" encoding="UTF-8"?>
109
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
110
- <plist version="1.0">
111
- <dict>
112
- <key>Label</key>
113
- <string>com.claude-max-proxy</string>
114
- <key>ProgramArguments</key>
115
- <array>
116
- <string>$(which bun)</string>
117
- <string>run</string>
118
- <string>proxy</string>
119
- </array>
120
- <key>WorkingDirectory</key>
121
- <string>$(pwd)</string>
122
- <key>RunAtLoad</key>
123
- <true/>
124
- <key>KeepAlive</key>
125
- <true/>
126
- </dict>
127
- </plist>
128
- EOF
128
+ In internal mode, a `PreToolUse` hook fuzzy-matches agent names as a safety net (e.g., `general-purpose` → `general`, `Explore` → `explore`, `code-reviewer` → `oracle`). In passthrough mode, OpenCode handles agent names directly.
129
129
 
130
- launchctl load ~/Library/LaunchAgents/com.claude-max-proxy.plist
131
- ```
130
+ ## Session Resume
131
+
132
+ The proxy tracks SDK session IDs and resumes conversations on follow-up requests:
132
133
 
133
- Then add an alias to `~/.zshrc`:
134
+ - **Faster responses** no re-processing of conversation history
135
+ - **Better context** — the SDK remembers tool results from previous turns
136
+
137
+ Session tracking works two ways:
138
+
139
+ 1. **Header-based** (recommended) — Add the included OpenCode plugin:
140
+ ```json
141
+ { "plugin": ["./path/to/opencode-claude-max-proxy/src/plugin/claude-max-headers.ts"] }
142
+ ```
143
+
144
+ 2. **Fingerprint-based** (automatic fallback) — hashes the first user message to identify returning conversations
145
+
146
+ Sessions are cached for 24 hours.
147
+
148
+ ## Configuration
149
+
150
+ | Variable | Default | Description |
151
+ |----------|---------|-------------|
152
+ | `CLAUDE_PROXY_PASSTHROUGH` | (unset) | Enable passthrough mode — forward all tools to OpenCode |
153
+ | `CLAUDE_PROXY_PORT` | 3456 | Proxy server port |
154
+ | `CLAUDE_PROXY_HOST` | 127.0.0.1 | Proxy server host |
155
+ | `CLAUDE_PROXY_WORKDIR` | (cwd) | Working directory for Claude and tools |
156
+ | `CLAUDE_PROXY_MAX_CONCURRENT` | 1 | Max concurrent SDK sessions (increase with caution) |
157
+ | `CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS` | 120 | Connection idle timeout |
158
+
159
+ ## Concurrency
160
+
161
+ The proxy supports multiple simultaneous OpenCode instances. Each request spawns its own independent SDK subprocess — run as many terminals as you want. All concurrent responses are delivered correctly.
162
+
163
+ **Use the auto-restart supervisor** (recommended):
134
164
 
135
165
  ```bash
136
- echo "alias oc='ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode'" >> ~/.zshrc
137
- source ~/.zshrc
166
+ CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy
167
+ # or directly:
168
+ CLAUDE_PROXY_PASSTHROUGH=1 ./bin/claude-proxy-supervisor.sh
138
169
  ```
139
170
 
140
- Now just run `oc` to start OpenCode with Claude Max.
171
+ > **⚠️ Known Issue: Bun SSE Crash ([oven-sh/bun#17947](https://github.com/oven-sh/bun/issues/17947))**
172
+ >
173
+ > The Claude Agent SDK's `cli.js` subprocess is compiled with Bun, which has a known segfault in `structuredCloneForStream` during cleanup of concurrent streaming responses. This affects all runtimes (Bun, Node.js via tsx) because the crash originates in the SDK's child process, not in the proxy itself.
174
+ >
175
+ > **What this means in practice:**
176
+ > - **Sequential requests (1 terminal):** No impact. Never crashes.
177
+ > - **Concurrent requests (2+ terminals):** All responses are delivered correctly. The crash occurs *after* responses complete, during stream cleanup. No work is lost.
178
+ > - **After a crash:** The supervisor restarts the proxy in ~1-3 seconds. If a new request arrives during this window, OpenCode shows "Unable to connect" — just retry.
179
+ >
180
+ > We are monitoring the upstream Bun issue for a fix. Once patched, the supervisor becomes optional.
141
181
 
142
182
  ## Model Mapping
143
183
 
144
184
  | OpenCode Model | Claude SDK |
145
185
  |----------------|------------|
146
186
  | `anthropic/claude-opus-*` | opus |
147
- | `anthropic/claude-sonnet-*` | sonnet |
187
+ | `anthropic/claude-sonnet-*` | sonnet (default) |
148
188
  | `anthropic/claude-haiku-*` | haiku |
149
189
 
150
- ## Configuration
151
-
152
- | Environment Variable | Default | Description |
153
- |---------------------|---------|-------------|
154
- | `CLAUDE_PROXY_PORT` | 3456 | Proxy server port |
155
- | `CLAUDE_PROXY_HOST` | 127.0.0.1 | Proxy server host |
190
+ ## Disclaimer
156
191
 
157
- ## How It Works
192
+ 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.
158
193
 
159
- 1. **OpenCode** sends a request to `http://127.0.0.1:3456/messages` (thinking it's the Anthropic API)
160
- 2. **Proxy** receives the request and extracts the messages
161
- 3. **Proxy** calls `query()` from the Claude Agent SDK with your prompt
162
- 4. **Claude Agent SDK** authenticates using your Claude CLI login (tied to your Max subscription)
163
- 5. **Claude** processes the request using your subscription
164
- 6. **Proxy** streams the response back in Anthropic SSE format
165
- 7. **OpenCode** receives the response as if it came from the real API
194
+ **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.
166
195
 
167
- The proxy is ~200 lines of TypeScript. No magic, no hacks.
196
+ 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.
168
197
 
169
198
  ## FAQ
170
199
 
171
- ### Why do I need `ANTHROPIC_API_KEY=dummy`?
200
+ <details>
201
+ <summary><strong>Why passthrough mode instead of handling tools internally?</strong></summary>
202
+
203
+ 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.
204
+ </details>
205
+
206
+ <details>
207
+ <summary><strong>Does this work without oh-my-opencode?</strong></summary>
172
208
 
173
- OpenCode requires an API key to be set, but we never actually use it. The Claude Agent SDK handles authentication through your Claude CLI login. Any non-empty string works.
209
+ 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.
210
+ </details>
174
211
 
175
- ### Does this work with other tools besides OpenCode?
212
+ <details>
213
+ <summary><strong>Why do I need ANTHROPIC_API_KEY=dummy?</strong></summary>
176
214
 
177
- Yes! Any tool that uses the Anthropic API format can use this proxy. Just point `ANTHROPIC_BASE_URL` to `http://127.0.0.1:3456`.
215
+ 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.
216
+ </details>
178
217
 
179
- ### What about rate limits?
218
+ <details>
219
+ <summary><strong>What about rate limits?</strong></summary>
180
220
 
181
- Your Claude Max subscription has its own usage limits. This proxy doesn't add any additional limits.
221
+ Your Claude Max subscription has its own usage limits. The proxy doesn't add any additional limits. Concurrent requests are supported.
222
+ </details>
182
223
 
183
- ### Is my data sent anywhere else?
224
+ <details>
225
+ <summary><strong>Is my data sent anywhere else?</strong></summary>
184
226
 
185
- No. The proxy runs locally on your machine. Your requests go directly to Claude through the official SDK.
227
+ 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.
228
+ </details>
229
+
230
+ <details>
231
+ <summary><strong>Why does internal mode use MCP tools?</strong></summary>
232
+
233
+ 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.
234
+ </details>
186
235
 
187
236
  ## Troubleshooting
188
237
 
189
- ### "Authentication failed"
238
+ | Problem | Solution |
239
+ |---------|----------|
240
+ | "Authentication failed" | Run `claude login` to authenticate |
241
+ | "Connection refused" | Make sure the proxy is running: `bun run proxy` |
242
+ | "Port 3456 is already in use" | `kill $(lsof -ti :3456)` or use `CLAUDE_PROXY_PORT=4567` |
243
+ | Title generation fails | Set `"small_model": "anthropic/claude-haiku-4-5"` in your OpenCode config |
190
244
 
191
- Run `claude login` to authenticate with the Claude CLI.
245
+ ## Auto-start (macOS)
192
246
 
193
- ### "Connection refused"
247
+ ```bash
248
+ cat > ~/Library/LaunchAgents/com.claude-max-proxy.plist << EOF
249
+ <?xml version="1.0" encoding="UTF-8"?>
250
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
251
+ <plist version="1.0">
252
+ <dict>
253
+ <key>Label</key>
254
+ <string>com.claude-max-proxy</string>
255
+ <key>ProgramArguments</key>
256
+ <array>
257
+ <string>$(pwd)/bin/claude-proxy-supervisor.sh</string>
258
+ </array>
259
+ <key>WorkingDirectory</key>
260
+ <string>$(pwd)</string>
261
+ <key>EnvironmentVariables</key>
262
+ <dict>
263
+ <key>CLAUDE_PROXY_PASSTHROUGH</key>
264
+ <string>1</string>
265
+ </dict>
266
+ <key>RunAtLoad</key>
267
+ <true/>
268
+ <key>KeepAlive</key>
269
+ <true/>
270
+ </dict>
271
+ </plist>
272
+ EOF
273
+
274
+ launchctl load ~/Library/LaunchAgents/com.claude-max-proxy.plist
275
+ ```
276
+
277
+ ## Testing
278
+
279
+ ```bash
280
+ bun test
281
+ ```
282
+
283
+ 106 tests across 13 files covering:
284
+ - Passthrough tool forwarding and tool_result acceptance
285
+ - PreToolUse hook interception and agent name fuzzy matching
286
+ - SDK agent definition extraction (native OpenCode + oh-my-opencode)
287
+ - MCP tool filtering (internal mode)
288
+ - Session resume (header-based and fingerprint-based)
289
+ - Streaming message deduplication
290
+ - Working directory propagation
291
+ - Concurrent request handling
292
+ - Error classification (auth, rate limit, billing, timeout)
194
293
 
195
- Make sure the proxy is running: `bun run proxy`
294
+ ### Health Endpoint
196
295
 
197
- ### Proxy keeps dying
296
+ ```bash
297
+ curl http://127.0.0.1:3456/health
298
+ ```
299
+
300
+ Returns auth status, subscription type, and proxy mode. Use this to verify the proxy is running and authenticated before connecting OpenCode.
198
301
 
199
- Use the launchd service (see Auto-start section) which automatically restarts the proxy.
302
+ ## Architecture
303
+
304
+ ```
305
+ src/
306
+ ├── proxy/
307
+ │ ├── server.ts # HTTP server, passthrough/internal modes, SSE streaming, session resume
308
+ │ ├── agentDefs.ts # Extract SDK agent definitions from OpenCode's Task tool
309
+ │ ├── agentMatch.ts # Fuzzy matching for agent names (6-level priority)
310
+ │ └── types.ts # ProxyConfig types and defaults
311
+ ├── mcpTools.ts # MCP tool definitions for internal mode (read, write, edit, bash, glob, grep)
312
+ ├── logger.ts # Structured logging with AsyncLocalStorage context
313
+ ├── plugin/
314
+ │ └── claude-max-headers.ts # OpenCode plugin for session header injection
315
+ └── __tests__/ # 106 tests across 13 files
316
+ ├── helpers.ts
317
+ ├── integration.test.ts
318
+ ├── proxy-agent-definitions.test.ts
319
+ ├── proxy-agent-fuzzy-match.test.ts
320
+ ├── proxy-error-handling.test.ts
321
+ ├── proxy-mcp-filtering.test.ts
322
+ ├── proxy-passthrough-concept.test.ts
323
+ ├── proxy-pretooluse-hook.test.ts
324
+ ├── proxy-session-resume.test.ts
325
+ ├── proxy-streaming-message.test.ts
326
+ ├── proxy-subagent-support.test.ts
327
+ ├── proxy-tool-forwarding.test.ts
328
+ ├── proxy-transparent-tools.test.ts
329
+ └── proxy-working-directory.test.ts
330
+ ```
200
331
 
201
332
  ## License
202
333
 
@@ -0,0 +1,62 @@
1
+ #!/bin/bash
2
+ # Auto-restart supervisor for claude-max-proxy
3
+ #
4
+ # The Claude Agent SDK's cli.js subprocess (compiled with Bun) can crash
5
+ # during cleanup of concurrent streaming responses — a known Bun bug
6
+ # (oven-sh/bun#17947). All responses are delivered correctly; the crash
7
+ # only occurs after response completion.
8
+ #
9
+ # This supervisor runs the proxy in a subshell with signal isolation,
10
+ # detects crashes, and restarts in ~1 second.
11
+
12
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ cd "$SCRIPT_DIR/.."
14
+
15
+ # Ignore signals that the child's crash might propagate
16
+ trap '' SIGPIPE
17
+
18
+ RESTART_COUNT=0
19
+ MAX_RAPID_RESTARTS=50
20
+ RAPID_WINDOW=60
21
+ LAST_START=0
22
+
23
+ while true; do
24
+ NOW=$(date +%s)
25
+
26
+ if [ $((NOW - LAST_START)) -gt $RAPID_WINDOW ]; then
27
+ RESTART_COUNT=0
28
+ fi
29
+
30
+ if [ $RESTART_COUNT -ge $MAX_RAPID_RESTARTS ]; then
31
+ echo "[supervisor] Too many restarts ($RESTART_COUNT in ${RAPID_WINDOW}s). Stopping."
32
+ exit 1
33
+ fi
34
+
35
+ LAST_START=$NOW
36
+ RESTART_COUNT=$((RESTART_COUNT + 1))
37
+
38
+ if [ $RESTART_COUNT -gt 1 ]; then
39
+ echo "[supervisor] Restarting proxy (restart #$RESTART_COUNT)..."
40
+ else
41
+ echo "[supervisor] Starting proxy..."
42
+ fi
43
+
44
+ # Run in subshell so crashes don't kill the supervisor
45
+ (exec bun run ./bin/claude-proxy.ts)
46
+ EXIT_CODE=$?
47
+
48
+ if [ $EXIT_CODE -eq 0 ]; then
49
+ echo "[supervisor] Proxy exited cleanly."
50
+ break
51
+ fi
52
+
53
+ # Signal-based exits (128+signal)
54
+ if [ $EXIT_CODE -gt 128 ]; then
55
+ SIG=$((EXIT_CODE - 128))
56
+ echo "[supervisor] Proxy killed by signal $SIG. Restarting in 1s..."
57
+ else
58
+ echo "[supervisor] Proxy exited (code $EXIT_CODE). Restarting in 1s..."
59
+ fi
60
+
61
+ sleep 1
62
+ done
@@ -1,8 +1,33 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { startProxyServer } from "../src/proxy/server"
4
+ import { execSync } from "child_process"
5
+
6
+ // Prevent SDK subprocess crashes from killing the proxy
7
+ process.on("uncaughtException", (err) => {
8
+ console.error(`[PROXY] Uncaught exception (recovered): ${err.message}`)
9
+ })
10
+ process.on("unhandledRejection", (reason) => {
11
+ console.error(`[PROXY] Unhandled rejection (recovered): ${reason instanceof Error ? reason.message : reason}`)
12
+ })
4
13
 
5
14
  const port = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10)
6
15
  const host = process.env.CLAUDE_PROXY_HOST || "127.0.0.1"
16
+ const idleTimeoutSeconds = parseInt(process.env.CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS || "120", 10)
17
+
18
+ // Pre-flight auth check
19
+ try {
20
+ const authJson = execSync("claude auth status", { encoding: "utf-8", timeout: 5000 })
21
+ const auth = JSON.parse(authJson)
22
+ if (!auth.loggedIn) {
23
+ console.error("\x1b[31m✗ Not logged in to Claude.\x1b[0m Run: claude login")
24
+ process.exit(1)
25
+ }
26
+ if (auth.subscriptionType !== "max") {
27
+ console.error(`\x1b[33m⚠ Claude subscription: ${auth.subscriptionType || "unknown"} (Max recommended)\x1b[0m`)
28
+ }
29
+ } catch {
30
+ console.error("\x1b[33m⚠ Could not verify Claude auth status. If requests fail, run: claude login\x1b[0m")
31
+ }
7
32
 
8
- await startProxyServer({ port, host })
33
+ await startProxyServer({ port, host, idleTimeoutSeconds })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-max-proxy",
3
- "version": "1.0.2",
3
+ "version": "1.7.3",
4
4
  "description": "Use your Claude Max subscription with OpenCode via proxy server",
5
5
  "type": "module",
6
6
  "main": "./src/proxy/server.ts",
@@ -11,14 +11,17 @@
11
11
  ".": "./src/proxy/server.ts"
12
12
  },
13
13
  "scripts": {
14
- "start": "bun run ./bin/claude-proxy.ts",
15
- "proxy": "bun run ./bin/claude-proxy.ts",
14
+ "start": "./bin/claude-proxy-supervisor.sh",
15
+ "proxy": "./bin/claude-proxy-supervisor.sh",
16
16
  "test": "bun test",
17
- "typecheck": "tsc --noEmit"
17
+ "typecheck": "tsc --noEmit",
18
+ "proxy:direct": "bun run ./bin/claude-proxy.ts"
18
19
  },
19
20
  "dependencies": {
20
- "@anthropic-ai/claude-agent-sdk": "^0.2.0",
21
- "hono": "^4.11.4"
21
+ "@anthropic-ai/claude-agent-sdk": "^0.2.80",
22
+ "glob": "^13.0.0",
23
+ "hono": "^4.11.4",
24
+ "p-queue": "^8.0.1"
22
25
  },
23
26
  "devDependencies": {
24
27
  "@types/bun": "^1.2.21",
package/src/logger.ts CHANGED
@@ -1,10 +1,72 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
2
+
3
+ type LogFields = Record<string, unknown>
4
+
5
+ const contextStore = new AsyncLocalStorage<LogFields>()
6
+
1
7
  const shouldLog = () => process.env["OPENCODE_CLAUDE_PROVIDER_DEBUG"]
8
+ const shouldLogStreamDebug = () => process.env["OPENCODE_CLAUDE_PROVIDER_STREAM_DEBUG"]
2
9
 
3
- export const claudeLog = (message: string, extra?: Record<string, unknown>) => {
4
- if (!shouldLog()) return
5
- const parts = ["[opencode-claude-code-provider]", message]
6
- if (extra && Object.keys(extra).length > 0) {
7
- parts.push(JSON.stringify(extra))
10
+ const isVerboseStreamEvent = (event: string): boolean => {
11
+ return event.startsWith("stream.") || event === "response.empty_stream"
12
+ }
13
+
14
+ const REDACTED_KEYS = new Set([
15
+ "authorization",
16
+ "cookie",
17
+ "x-api-key",
18
+ "apiKey",
19
+ "apikey",
20
+ "prompt",
21
+ "messages",
22
+ "content"
23
+ ])
24
+
25
+ const sanitize = (value: unknown): unknown => {
26
+ if (value === null || value === undefined) return value
27
+
28
+ if (typeof value === "string") {
29
+ if (value.length > 512) {
30
+ return `${value.slice(0, 512)}... [truncated=${value.length}]`
31
+ }
32
+ return value
33
+ }
34
+
35
+ if (Array.isArray(value)) {
36
+ return value.map(sanitize)
8
37
  }
9
- console.debug(parts.join(" "))
38
+
39
+ if (typeof value === "object") {
40
+ const out: Record<string, unknown> = {}
41
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
42
+ if (REDACTED_KEYS.has(k)) {
43
+ if (typeof v === "string") {
44
+ out[k] = `[redacted len=${v.length}]`
45
+ } else if (Array.isArray(v)) {
46
+ out[k] = `[redacted array len=${v.length}]`
47
+ } else {
48
+ out[k] = "[redacted]"
49
+ }
50
+ } else {
51
+ out[k] = sanitize(v)
52
+ }
53
+ }
54
+ return out
55
+ }
56
+
57
+ return value
58
+ }
59
+
60
+ export const withClaudeLogContext = <T>(context: LogFields, fn: () => T): T => {
61
+ return contextStore.run(context, fn)
62
+ }
63
+
64
+ export const claudeLog = (event: string, extra?: LogFields) => {
65
+ if (!shouldLog()) return
66
+ if (isVerboseStreamEvent(event) && !shouldLogStreamDebug()) return
67
+
68
+ const context = contextStore.getStore() || {}
69
+ const payload = sanitize({ ts: new Date().toISOString(), event, ...context, ...(extra || {}) })
70
+
71
+ console.debug(`[opencode-claude-code-provider] ${JSON.stringify(payload)}`)
10
72
  }