opencode-claude-max-proxy 1.8.1 → 1.10.1
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 +114 -138
- package/bin/oc.sh +62 -0
- package/package.json +1 -1
- package/src/proxy/server.ts +39 -4
- package/src/proxy/sessionStore.ts +94 -0
package/README.md
CHANGED
|
@@ -4,61 +4,64 @@
|
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://github.com/rynfar/opencode-claude-max-proxy/stargazers)
|
|
6
6
|
|
|
7
|
-
A transparent proxy that
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
│ │
|
|
19
|
+
│ │ │ │ │ │
|
|
22
20
|
│ OpenCode │ ─────────────► │ Proxy │ ───────────► │ Claude Max │
|
|
23
|
-
│ │ │ (localhost) │ │
|
|
24
|
-
│ │
|
|
25
|
-
│ │
|
|
26
|
-
│ │ │
|
|
27
|
-
│ │
|
|
28
|
-
│ │
|
|
29
|
-
│ │
|
|
30
|
-
│ │
|
|
31
|
-
│ │
|
|
32
|
-
│ │ │
|
|
33
|
-
│ │
|
|
34
|
-
│ │
|
|
35
|
-
│ │
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
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/
|
|
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,32 +101,45 @@ docker compose exec proxy claude login
|
|
|
98
101
|
curl http://127.0.0.1:3456/health
|
|
99
102
|
```
|
|
100
103
|
|
|
101
|
-
> **Note:** On macOS,
|
|
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
|
|
|
105
|
-
####
|
|
108
|
+
#### Per-Terminal Launcher (recommended)
|
|
106
109
|
|
|
107
110
|
```bash
|
|
108
|
-
|
|
111
|
+
./bin/oc.sh
|
|
109
112
|
```
|
|
110
113
|
|
|
111
|
-
|
|
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
|
-
|
|
116
|
+
Shell alias:
|
|
114
117
|
|
|
115
118
|
```bash
|
|
116
|
-
#
|
|
117
|
-
alias oc='
|
|
119
|
+
# ~/.zshrc or ~/.bashrc
|
|
120
|
+
alias oc='/path/to/opencode-claude-max-proxy/bin/oc.sh'
|
|
118
121
|
```
|
|
119
122
|
|
|
120
|
-
####
|
|
123
|
+
#### Shared Proxy
|
|
124
|
+
|
|
125
|
+
For a single long-running proxy:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
# Terminal 1: start the proxy
|
|
129
|
+
CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy
|
|
130
|
+
|
|
131
|
+
# Terminal 2+: connect OpenCode
|
|
132
|
+
ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
|
|
133
|
+
```
|
|
121
134
|
|
|
122
|
-
|
|
135
|
+
`ANTHROPIC_API_KEY` can be any non-empty string. Authentication is handled by `claude login`.
|
|
136
|
+
|
|
137
|
+
#### OpenCode Desktop / Config File
|
|
138
|
+
|
|
139
|
+
Set the proxy URL in `~/.config/opencode/opencode.json` to use with Desktop or avoid env vars.
|
|
123
140
|
|
|
124
141
|
```json
|
|
125
142
|
{
|
|
126
|
-
...
|
|
127
143
|
"provider": {
|
|
128
144
|
"anthropic": {
|
|
129
145
|
"options": {
|
|
@@ -132,10 +148,11 @@ Alternatively, the proxy URL and API key can be set globally in `~/.config/openc
|
|
|
132
148
|
}
|
|
133
149
|
}
|
|
134
150
|
}
|
|
135
|
-
...
|
|
136
151
|
}
|
|
137
152
|
```
|
|
138
153
|
|
|
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.
|
|
155
|
+
|
|
139
156
|
## Modes
|
|
140
157
|
|
|
141
158
|
### Passthrough Mode (recommended)
|
|
@@ -158,13 +175,13 @@ bun run proxy
|
|
|
158
175
|
|
|
159
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.
|
|
160
177
|
|
|
161
|
-
|
|
|
162
|
-
|
|
163
|
-
| Tool execution
|
|
164
|
-
| Agent delegation
|
|
165
|
-
| oh-my-opencode models | ✅ Respected
|
|
166
|
-
| Agent system prompts
|
|
167
|
-
| Setup complexity
|
|
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 |
|
|
168
185
|
|
|
169
186
|
## Works With Any Agent Framework
|
|
170
187
|
|
|
@@ -178,118 +195,93 @@ In internal mode, a `PreToolUse` hook fuzzy-matches agent names as a safety net
|
|
|
178
195
|
|
|
179
196
|
## Session Resume
|
|
180
197
|
|
|
181
|
-
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).
|
|
182
199
|
|
|
183
|
-
|
|
184
|
-
- **Better context** — the SDK remembers tool results from previous turns
|
|
200
|
+
Lookup order:
|
|
185
201
|
|
|
186
|
-
|
|
202
|
+
1. **Header-based** — add the included OpenCode plugin to inject session headers:
|
|
187
203
|
|
|
188
|
-
1. **Header-based** (recommended) — Add the included OpenCode plugin:
|
|
189
204
|
```json
|
|
190
|
-
{
|
|
205
|
+
{
|
|
206
|
+
"plugin": [
|
|
207
|
+
"./path/to/opencode-claude-max-proxy/src/plugin/claude-max-headers.ts"
|
|
208
|
+
]
|
|
209
|
+
}
|
|
191
210
|
```
|
|
192
211
|
|
|
193
|
-
2. **Fingerprint-based** (automatic fallback) — hashes the first user message to
|
|
212
|
+
2. **Fingerprint-based** (automatic fallback) — hashes the first user message to match returning conversations
|
|
194
213
|
|
|
195
|
-
Sessions
|
|
214
|
+
Sessions expire after 24 hours.
|
|
196
215
|
|
|
197
216
|
## Configuration
|
|
198
217
|
|
|
199
|
-
| Variable
|
|
200
|
-
|
|
201
|
-
| `CLAUDE_PROXY_PASSTHROUGH`
|
|
202
|
-
| `CLAUDE_PROXY_PORT`
|
|
203
|
-
| `CLAUDE_PROXY_HOST`
|
|
204
|
-
| `CLAUDE_PROXY_WORKDIR`
|
|
205
|
-
| `CLAUDE_PROXY_MAX_CONCURRENT`
|
|
206
|
-
| `CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS` | 120
|
|
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 |
|
|
207
226
|
|
|
208
227
|
## Concurrency
|
|
209
228
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
**Use the auto-restart supervisor** (recommended):
|
|
213
|
-
|
|
214
|
-
```bash
|
|
215
|
-
CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy
|
|
216
|
-
# or directly:
|
|
217
|
-
CLAUDE_PROXY_PASSTHROUGH=1 ./bin/claude-proxy-supervisor.sh
|
|
218
|
-
```
|
|
229
|
+
Per-terminal proxies (`oc.sh`) avoid concurrency issues entirely. Each terminal gets its own proxy.
|
|
219
230
|
|
|
220
|
-
|
|
221
|
-
>
|
|
222
|
-
> 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.
|
|
223
|
-
>
|
|
224
|
-
> **What this means in practice:**
|
|
225
|
-
> - **Sequential requests (1 terminal):** No impact. Never crashes.
|
|
226
|
-
> - **Concurrent requests (2+ terminals):** All responses are delivered correctly. The crash occurs *after* responses complete, during stream cleanup. No work is lost.
|
|
227
|
-
> - **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.
|
|
228
|
-
>
|
|
229
|
-
> We are monitoring the upstream Bun issue for a fix. Once patched, the supervisor becomes optional.
|
|
230
|
-
|
|
231
|
-
## Model Mapping
|
|
232
|
-
|
|
233
|
-
| OpenCode Model | Claude SDK |
|
|
234
|
-
|----------------|------------|
|
|
235
|
-
| `anthropic/claude-opus-*` | opus |
|
|
236
|
-
| `anthropic/claude-sonnet-*` | sonnet (default) |
|
|
237
|
-
| `anthropic/claude-haiku-*` | haiku |
|
|
238
|
-
|
|
239
|
-
## Disclaimer
|
|
240
|
-
|
|
241
|
-
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.
|
|
242
|
-
|
|
243
|
-
**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.
|
|
244
|
-
|
|
245
|
-
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.
|
|
246
232
|
|
|
247
233
|
## FAQ
|
|
248
234
|
|
|
249
235
|
<details>
|
|
250
236
|
<summary><strong>Why passthrough mode instead of handling tools internally?</strong></summary>
|
|
251
237
|
|
|
252
|
-
|
|
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
|
+
|
|
253
240
|
</details>
|
|
254
241
|
|
|
255
242
|
<details>
|
|
256
243
|
<summary><strong>Does this work without oh-my-opencode?</strong></summary>
|
|
257
244
|
|
|
258
|
-
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
|
|
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
|
+
|
|
259
247
|
</details>
|
|
260
248
|
|
|
261
249
|
<details>
|
|
262
|
-
<summary><strong>Why do I need ANTHROPIC_API_KEY=dummy
|
|
250
|
+
<summary><strong>Why do I need `ANTHROPIC_API_KEY=dummy`?</strong></summary>
|
|
263
251
|
|
|
264
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
|
+
|
|
265
254
|
</details>
|
|
266
255
|
|
|
267
256
|
<details>
|
|
268
257
|
<summary><strong>What about rate limits?</strong></summary>
|
|
269
258
|
|
|
270
259
|
Your Claude Max subscription has its own usage limits. The proxy doesn't add any additional limits. Concurrent requests are supported.
|
|
260
|
+
|
|
271
261
|
</details>
|
|
272
262
|
|
|
273
263
|
<details>
|
|
274
264
|
<summary><strong>Is my data sent anywhere else?</strong></summary>
|
|
275
265
|
|
|
276
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
|
+
|
|
277
268
|
</details>
|
|
278
269
|
|
|
279
270
|
<details>
|
|
280
271
|
<summary><strong>Why does internal mode use MCP tools?</strong></summary>
|
|
281
272
|
|
|
282
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
|
+
|
|
283
275
|
</details>
|
|
284
276
|
|
|
285
277
|
## Troubleshooting
|
|
286
278
|
|
|
287
|
-
| Problem
|
|
288
|
-
|
|
289
|
-
| "Authentication failed"
|
|
290
|
-
| "Connection refused"
|
|
291
|
-
| "Port 3456 is already in use" | `kill $(lsof -ti :3456)` or use `CLAUDE_PROXY_PORT=4567`
|
|
292
|
-
| Title generation fails
|
|
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 |
|
|
293
285
|
|
|
294
286
|
## Auto-start (macOS)
|
|
295
287
|
|
|
@@ -323,23 +315,14 @@ EOF
|
|
|
323
315
|
launchctl load ~/Library/LaunchAgents/com.claude-max-proxy.plist
|
|
324
316
|
```
|
|
325
317
|
|
|
326
|
-
##
|
|
318
|
+
## Development
|
|
319
|
+
|
|
320
|
+
### Run tests
|
|
327
321
|
|
|
328
322
|
```bash
|
|
329
323
|
bun test
|
|
330
324
|
```
|
|
331
325
|
|
|
332
|
-
106 tests across 13 files covering:
|
|
333
|
-
- Passthrough tool forwarding and tool_result acceptance
|
|
334
|
-
- PreToolUse hook interception and agent name fuzzy matching
|
|
335
|
-
- SDK agent definition extraction (native OpenCode + oh-my-opencode)
|
|
336
|
-
- MCP tool filtering (internal mode)
|
|
337
|
-
- Session resume (header-based and fingerprint-based)
|
|
338
|
-
- Streaming message deduplication
|
|
339
|
-
- Working directory propagation
|
|
340
|
-
- Concurrent request handling
|
|
341
|
-
- Error classification (auth, rate limit, billing, timeout)
|
|
342
|
-
|
|
343
326
|
### Health Endpoint
|
|
344
327
|
|
|
345
328
|
```bash
|
|
@@ -348,7 +331,7 @@ curl http://127.0.0.1:3456/health
|
|
|
348
331
|
|
|
349
332
|
Returns auth status, subscription type, and proxy mode. Use this to verify the proxy is running and authenticated before connecting OpenCode.
|
|
350
333
|
|
|
351
|
-
|
|
334
|
+
### Architecture
|
|
352
335
|
|
|
353
336
|
```
|
|
354
337
|
src/
|
|
@@ -360,24 +343,17 @@ src/
|
|
|
360
343
|
├── mcpTools.ts # MCP tool definitions for internal mode (read, write, edit, bash, glob, grep)
|
|
361
344
|
├── logger.ts # Structured logging with AsyncLocalStorage context
|
|
362
345
|
├── plugin/
|
|
363
|
-
|
|
364
|
-
└── __tests__/ # 106 tests across 13 files
|
|
365
|
-
├── helpers.ts
|
|
366
|
-
├── integration.test.ts
|
|
367
|
-
├── proxy-agent-definitions.test.ts
|
|
368
|
-
├── proxy-agent-fuzzy-match.test.ts
|
|
369
|
-
├── proxy-error-handling.test.ts
|
|
370
|
-
├── proxy-mcp-filtering.test.ts
|
|
371
|
-
├── proxy-passthrough-concept.test.ts
|
|
372
|
-
├── proxy-pretooluse-hook.test.ts
|
|
373
|
-
├── proxy-session-resume.test.ts
|
|
374
|
-
├── proxy-streaming-message.test.ts
|
|
375
|
-
├── proxy-subagent-support.test.ts
|
|
376
|
-
├── proxy-tool-forwarding.test.ts
|
|
377
|
-
├── proxy-transparent-tools.test.ts
|
|
378
|
-
└── proxy-working-directory.test.ts
|
|
346
|
+
└── claude-max-headers.ts # OpenCode plugin for session header injection
|
|
379
347
|
```
|
|
380
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
|
+
|
|
381
357
|
## License
|
|
382
358
|
|
|
383
359
|
MIT
|
package/bin/oc.sh
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Per-terminal proxy launcher for OpenCode.
|
|
3
|
+
#
|
|
4
|
+
# Starts a dedicated proxy on a random port, launches OpenCode pointed at it,
|
|
5
|
+
# and cleans up when OpenCode exits. Each terminal gets its own proxy — no
|
|
6
|
+
# concurrent request issues, no shared port conflicts.
|
|
7
|
+
#
|
|
8
|
+
# Session resume works across terminals via the shared session file store.
|
|
9
|
+
#
|
|
10
|
+
# Usage: ./bin/oc.sh [opencode args...]
|
|
11
|
+
|
|
12
|
+
set -e
|
|
13
|
+
|
|
14
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
15
|
+
PROXY_SCRIPT="$SCRIPT_DIR/claude-proxy.ts"
|
|
16
|
+
|
|
17
|
+
if [ ! -f "$PROXY_SCRIPT" ]; then
|
|
18
|
+
echo "❌ Proxy script not found: $PROXY_SCRIPT" >&2
|
|
19
|
+
exit 1
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
# Pick a random free port
|
|
23
|
+
PORT=$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()' 2>/dev/null \
|
|
24
|
+
|| ruby -e 'require "socket"; s = TCPServer.new("127.0.0.1", 0); puts s.addr[1]; s.close' 2>/dev/null \
|
|
25
|
+
|| echo $((RANDOM + 10000)))
|
|
26
|
+
|
|
27
|
+
# Start proxy in background
|
|
28
|
+
CLAUDE_PROXY_PORT=$PORT \
|
|
29
|
+
CLAUDE_PROXY_WORKDIR="$PWD" \
|
|
30
|
+
CLAUDE_PROXY_PASSTHROUGH="${CLAUDE_PROXY_PASSTHROUGH:-1}" \
|
|
31
|
+
bun run "$PROXY_SCRIPT" > /dev/null 2>&1 &
|
|
32
|
+
PROXY_PID=$!
|
|
33
|
+
|
|
34
|
+
# Ensure proxy is cleaned up on exit
|
|
35
|
+
cleanup() {
|
|
36
|
+
kill $PROXY_PID 2>/dev/null
|
|
37
|
+
wait $PROXY_PID 2>/dev/null
|
|
38
|
+
}
|
|
39
|
+
trap cleanup EXIT INT TERM
|
|
40
|
+
|
|
41
|
+
# Wait for proxy to be ready (up to 10 seconds)
|
|
42
|
+
for i in $(seq 1 100); do
|
|
43
|
+
if curl -sf "http://127.0.0.1:$PORT/health" > /dev/null 2>&1; then
|
|
44
|
+
break
|
|
45
|
+
fi
|
|
46
|
+
if ! kill -0 $PROXY_PID 2>/dev/null; then
|
|
47
|
+
echo "❌ Proxy failed to start" >&2
|
|
48
|
+
exit 1
|
|
49
|
+
fi
|
|
50
|
+
sleep 0.1
|
|
51
|
+
done
|
|
52
|
+
|
|
53
|
+
# Verify proxy is healthy
|
|
54
|
+
if ! curl -sf "http://127.0.0.1:$PORT/health" > /dev/null 2>&1; then
|
|
55
|
+
echo "❌ Proxy didn't become healthy within 10 seconds" >&2
|
|
56
|
+
exit 1
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# Launch OpenCode
|
|
60
|
+
ANTHROPIC_API_KEY=dummy \
|
|
61
|
+
ANTHROPIC_BASE_URL="http://127.0.0.1:$PORT" \
|
|
62
|
+
opencode "$@"
|
package/package.json
CHANGED
package/src/proxy/server.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { withClaudeLogContext } from "../logger"
|
|
|
15
15
|
import { fuzzyMatchAgentName } from "./agentMatch"
|
|
16
16
|
import { buildAgentDefinitions } from "./agentDefs"
|
|
17
17
|
import { createPassthroughMcpServer, stripMcpPrefix, PASSTHROUGH_MCP_NAME, PASSTHROUGH_MCP_PREFIX } from "./passthroughTools"
|
|
18
|
+
import { lookupSharedSession, storeSharedSession, clearSharedSessions } from "./sessionStore"
|
|
18
19
|
|
|
19
20
|
// --- Session Tracking ---
|
|
20
21
|
// Maps OpenCode session ID (or fingerprint) → Claude SDK session ID
|
|
@@ -31,6 +32,8 @@ const fingerprintCache = new Map<string, SessionState>()
|
|
|
31
32
|
export function clearSessionCache() {
|
|
32
33
|
sessionCache.clear()
|
|
33
34
|
fingerprintCache.clear()
|
|
35
|
+
// Also clear shared file store
|
|
36
|
+
try { clearSharedSessions() } catch {}
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
// Clean stale sessions every hour — sessions survive a full workday
|
|
@@ -63,13 +66,41 @@ function lookupSession(
|
|
|
63
66
|
opencodeSessionId: string | undefined,
|
|
64
67
|
messages: Array<{ role: string; content: any }>
|
|
65
68
|
): SessionState | undefined {
|
|
66
|
-
//
|
|
69
|
+
// When a session ID is provided, only match by that ID — don't fall through
|
|
70
|
+
// to fingerprint. A different session ID means a different session.
|
|
67
71
|
if (opencodeSessionId) {
|
|
68
|
-
|
|
72
|
+
const cached = sessionCache.get(opencodeSessionId)
|
|
73
|
+
if (cached) return cached
|
|
74
|
+
// Check shared file store
|
|
75
|
+
const shared = lookupSharedSession(opencodeSessionId)
|
|
76
|
+
if (shared) {
|
|
77
|
+
const state: SessionState = {
|
|
78
|
+
claudeSessionId: shared.claudeSessionId,
|
|
79
|
+
lastAccess: shared.lastUsedAt,
|
|
80
|
+
messageCount: 0,
|
|
81
|
+
}
|
|
82
|
+
sessionCache.set(opencodeSessionId, state)
|
|
83
|
+
return state
|
|
84
|
+
}
|
|
85
|
+
return undefined
|
|
69
86
|
}
|
|
70
|
-
|
|
87
|
+
|
|
88
|
+
// No session ID — use fingerprint fallback
|
|
71
89
|
const fp = getConversationFingerprint(messages)
|
|
72
|
-
if (fp)
|
|
90
|
+
if (fp) {
|
|
91
|
+
const cached = fingerprintCache.get(fp)
|
|
92
|
+
if (cached) return cached
|
|
93
|
+
const shared = lookupSharedSession(fp)
|
|
94
|
+
if (shared) {
|
|
95
|
+
const state: SessionState = {
|
|
96
|
+
claudeSessionId: shared.claudeSessionId,
|
|
97
|
+
lastAccess: shared.lastUsedAt,
|
|
98
|
+
messageCount: 0,
|
|
99
|
+
}
|
|
100
|
+
fingerprintCache.set(fp, state)
|
|
101
|
+
return state
|
|
102
|
+
}
|
|
103
|
+
}
|
|
73
104
|
return undefined
|
|
74
105
|
}
|
|
75
106
|
|
|
@@ -81,9 +112,13 @@ function storeSession(
|
|
|
81
112
|
) {
|
|
82
113
|
if (!claudeSessionId) return
|
|
83
114
|
const state: SessionState = { claudeSessionId, lastAccess: Date.now(), messageCount: messages?.length || 0 }
|
|
115
|
+
// In-memory cache
|
|
84
116
|
if (opencodeSessionId) sessionCache.set(opencodeSessionId, state)
|
|
85
117
|
const fp = getConversationFingerprint(messages)
|
|
86
118
|
if (fp) fingerprintCache.set(fp, state)
|
|
119
|
+
// Shared file store (cross-proxy resume)
|
|
120
|
+
const key = opencodeSessionId || fp
|
|
121
|
+
if (key) storeSharedSession(key, claudeSessionId, state.messageCount)
|
|
87
122
|
}
|
|
88
123
|
|
|
89
124
|
/** Extract only the last user message (for resume — SDK already has history) */
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based session store for cross-proxy session resume.
|
|
3
|
+
*
|
|
4
|
+
* When running per-terminal proxies (each on a different port),
|
|
5
|
+
* sessions need to be shared so you can resume a conversation
|
|
6
|
+
* started in one terminal from another. This stores session
|
|
7
|
+
* mappings in a JSON file that all proxy instances read/write.
|
|
8
|
+
*
|
|
9
|
+
* Format: { [key]: { claudeSessionId, createdAt, lastUsedAt } }
|
|
10
|
+
* Keys are either OpenCode session IDs or conversation fingerprints.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "fs"
|
|
14
|
+
import { join, dirname } from "path"
|
|
15
|
+
import { homedir } from "os"
|
|
16
|
+
|
|
17
|
+
export interface StoredSession {
|
|
18
|
+
claudeSessionId: string
|
|
19
|
+
createdAt: number
|
|
20
|
+
lastUsedAt: number
|
|
21
|
+
messageCount: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SESSION_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
|
|
25
|
+
|
|
26
|
+
function getStorePath(): string {
|
|
27
|
+
const dir = process.env.CLAUDE_PROXY_SESSION_DIR
|
|
28
|
+
|| join(homedir(), ".cache", "opencode-claude-max-proxy")
|
|
29
|
+
if (!existsSync(dir)) {
|
|
30
|
+
mkdirSync(dir, { recursive: true })
|
|
31
|
+
}
|
|
32
|
+
return join(dir, "sessions.json")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readStore(): Record<string, StoredSession> {
|
|
36
|
+
const path = getStorePath()
|
|
37
|
+
if (!existsSync(path)) return {}
|
|
38
|
+
try {
|
|
39
|
+
const data = readFileSync(path, "utf-8")
|
|
40
|
+
const store = JSON.parse(data) as Record<string, StoredSession>
|
|
41
|
+
// Prune expired entries
|
|
42
|
+
const now = Date.now()
|
|
43
|
+
const pruned: Record<string, StoredSession> = {}
|
|
44
|
+
for (const [key, session] of Object.entries(store)) {
|
|
45
|
+
if (now - session.lastUsedAt < SESSION_TTL_MS) {
|
|
46
|
+
pruned[key] = session
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return pruned
|
|
50
|
+
} catch {
|
|
51
|
+
return {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeStore(store: Record<string, StoredSession>): void {
|
|
56
|
+
const path = getStorePath()
|
|
57
|
+
const tmp = path + ".tmp"
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(tmp, JSON.stringify(store, null, 2))
|
|
60
|
+
renameSync(tmp, path) // atomic write
|
|
61
|
+
} catch {
|
|
62
|
+
// If rename fails, try direct write
|
|
63
|
+
try {
|
|
64
|
+
writeFileSync(path, JSON.stringify(store, null, 2))
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function lookupSharedSession(key: string): StoredSession | undefined {
|
|
70
|
+
const store = readStore()
|
|
71
|
+
const session = store[key]
|
|
72
|
+
if (!session) return undefined
|
|
73
|
+
if (Date.now() - session.lastUsedAt >= SESSION_TTL_MS) return undefined
|
|
74
|
+
return session
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function storeSharedSession(key: string, claudeSessionId: string, messageCount?: number): void {
|
|
78
|
+
const store = readStore()
|
|
79
|
+
const existing = store[key]
|
|
80
|
+
store[key] = {
|
|
81
|
+
claudeSessionId,
|
|
82
|
+
createdAt: existing?.createdAt || Date.now(),
|
|
83
|
+
lastUsedAt: Date.now(),
|
|
84
|
+
messageCount: messageCount ?? existing?.messageCount ?? 0,
|
|
85
|
+
}
|
|
86
|
+
writeStore(store)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function clearSharedSessions(): void {
|
|
90
|
+
const path = getStorePath()
|
|
91
|
+
try {
|
|
92
|
+
writeFileSync(path, "{}")
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|