opencode-claude-max-proxy 1.0.0 → 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,197 +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.
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
- ## The Problem
9
+ ## The Idea
10
10
 
11
- 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.
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
- Your options are:
14
- 1. Use Claude's official apps only (limited to their UI)
15
- 2. Pay again for API access on top of your Max subscription
16
- 3. **Use this proxy**
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."
17
14
 
18
- ## The Solution
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
- 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.
21
18
 
22
19
  ```
23
- 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
+ └──────────────┘
24
41
  ```
25
42
 
26
- 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
27
44
 
28
- **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:
29
46
 
30
- ## 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
31
53
 
32
- **Yes.** Here's why:
54
+ No monkey-patching. No forked SDKs. No fragile stream rewriting. Just a hook and a turn limit.
33
55
 
34
- | Concern | Reality |
35
- |---------|---------|
36
- | "Bypassing restrictions" | No. We use Anthropic's public SDK exactly as documented |
37
- | "Violating TOS" | No. The SDK is designed for programmatic Claude access |
38
- | "Unauthorized access" | No. You authenticate with `claude login` using your own account |
39
- | "Reverse engineering" | No. We call `query()` from their npm package, that's it |
56
+ ## Quick Start
40
57
 
41
- 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
42
59
 
43
- **~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`
44
63
 
45
- ## Features
64
+ ### Install & Run
46
65
 
47
- | Feature | Description |
48
- |---------|-------------|
49
- | **Zero API costs** | Uses your Claude Max subscription, not per-token billing |
50
- | **Full compatibility** | Works with any Anthropic model in OpenCode |
51
- | **Streaming support** | Real-time SSE streaming just like the real API |
52
- | **Auto-start** | Optional launchd service for macOS |
53
- | **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
54
70
 
55
- ## Prerequisites
71
+ # Start in passthrough mode (recommended)
72
+ CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy
73
+ ```
56
74
 
57
- 1. **Claude Max subscription** - [Subscribe here](https://claude.ai/settings/subscription)
75
+ ### Connect OpenCode
58
76
 
59
- 2. **Claude CLI** installed and authenticated:
60
- ```bash
61
- npm install -g @anthropic-ai/claude-code
62
- claude login
63
- ```
77
+ ```bash
78
+ ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
79
+ ```
64
80
 
65
- 3. **Bun** runtime:
66
- ```bash
67
- curl -fsSL https://bun.sh/install | bash
68
- ```
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.
69
82
 
70
- ## Installation
83
+ ### Shell Alias
71
84
 
72
85
  ```bash
73
- git clone https://github.com/rynfar/opencode-claude-max-proxy
74
- cd opencode-claude-max-proxy
75
- 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'
76
88
  ```
77
89
 
78
- ## Usage
90
+ ## Modes
79
91
 
80
- ### Start the Proxy
92
+ ### Passthrough Mode (recommended)
81
93
 
82
94
  ```bash
83
- bun run proxy
95
+ CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy
84
96
  ```
85
97
 
86
- ### 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)
87
105
 
88
106
  ```bash
89
- ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
107
+ bun run proxy
90
108
  ```
91
109
 
92
- 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.
93
111
 
94
- ### 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 |
95
119
 
96
- ```bash
97
- bun run proxy & ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
98
- ```
120
+ ## Works With Any Agent Framework
99
121
 
100
- ## 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:
101
123
 
102
- 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`
103
127
 
104
- ```bash
105
- cat > ~/Library/LaunchAgents/com.claude-max-proxy.plist << EOF
106
- <?xml version="1.0" encoding="UTF-8"?>
107
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
108
- <plist version="1.0">
109
- <dict>
110
- <key>Label</key>
111
- <string>com.claude-max-proxy</string>
112
- <key>ProgramArguments</key>
113
- <array>
114
- <string>$(which bun)</string>
115
- <string>run</string>
116
- <string>proxy</string>
117
- </array>
118
- <key>WorkingDirectory</key>
119
- <string>$(pwd)</string>
120
- <key>RunAtLoad</key>
121
- <true/>
122
- <key>KeepAlive</key>
123
- <true/>
124
- </dict>
125
- </plist>
126
- 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.
127
129
 
128
- launchctl load ~/Library/LaunchAgents/com.claude-max-proxy.plist
129
- ```
130
+ ## Session Resume
131
+
132
+ The proxy tracks SDK session IDs and resumes conversations on follow-up requests:
133
+
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.
130
147
 
131
- Then add an alias to `~/.zshrc`:
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):
132
164
 
133
165
  ```bash
134
- echo "alias oc='ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode'" >> ~/.zshrc
135
- source ~/.zshrc
166
+ CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy
167
+ # or directly:
168
+ CLAUDE_PROXY_PASSTHROUGH=1 ./bin/claude-proxy-supervisor.sh
136
169
  ```
137
170
 
138
- 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.
139
181
 
140
182
  ## Model Mapping
141
183
 
142
184
  | OpenCode Model | Claude SDK |
143
185
  |----------------|------------|
144
186
  | `anthropic/claude-opus-*` | opus |
145
- | `anthropic/claude-sonnet-*` | sonnet |
187
+ | `anthropic/claude-sonnet-*` | sonnet (default) |
146
188
  | `anthropic/claude-haiku-*` | haiku |
147
189
 
148
- ## Configuration
149
-
150
- | Environment Variable | Default | Description |
151
- |---------------------|---------|-------------|
152
- | `CLAUDE_PROXY_PORT` | 3456 | Proxy server port |
153
- | `CLAUDE_PROXY_HOST` | 127.0.0.1 | Proxy server host |
190
+ ## Disclaimer
154
191
 
155
- ## 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.
156
193
 
157
- 1. **OpenCode** sends a request to `http://127.0.0.1:3456/messages` (thinking it's the Anthropic API)
158
- 2. **Proxy** receives the request and extracts the messages
159
- 3. **Proxy** calls `query()` from the Claude Agent SDK with your prompt
160
- 4. **Claude Agent SDK** authenticates using your Claude CLI login (tied to your Max subscription)
161
- 5. **Claude** processes the request using your subscription
162
- 6. **Proxy** streams the response back in Anthropic SSE format
163
- 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.
164
195
 
165
- 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.
166
197
 
167
198
  ## FAQ
168
199
 
169
- ### 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>
170
208
 
171
- 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>
172
211
 
173
- ### Does this work with other tools besides OpenCode?
212
+ <details>
213
+ <summary><strong>Why do I need ANTHROPIC_API_KEY=dummy?</strong></summary>
174
214
 
175
- 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>
176
217
 
177
- ### What about rate limits?
218
+ <details>
219
+ <summary><strong>What about rate limits?</strong></summary>
178
220
 
179
- 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>
180
223
 
181
- ### Is my data sent anywhere else?
224
+ <details>
225
+ <summary><strong>Is my data sent anywhere else?</strong></summary>
182
226
 
183
- 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>
184
235
 
185
236
  ## Troubleshooting
186
237
 
187
- ### "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 |
188
244
 
189
- Run `claude login` to authenticate with the Claude CLI.
245
+ ## Auto-start (macOS)
190
246
 
191
- ### "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)
192
293
 
193
- Make sure the proxy is running: `bun run proxy`
294
+ ### Health Endpoint
194
295
 
195
- ### 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.
196
301
 
197
- 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
+ ```
198
331
 
199
332
  ## License
200
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.0",
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
  }