opencode-claude-max-proxy 1.8.1 → 1.10.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
@@ -102,28 +102,41 @@ curl http://127.0.0.1:3456/health
102
102
 
103
103
  ### Connect OpenCode
104
104
 
105
- #### Environment Variables
105
+ #### Per-Terminal Launcher (recommended)
106
106
 
107
107
  ```bash
108
- ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
108
+ ./bin/oc.sh
109
109
  ```
110
110
 
111
- The `ANTHROPIC_API_KEY` can be any non-empty string the proxy doesn't use it. Authentication is handled by your `claude login` session.
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.
112
112
 
113
- #### Shell Alias
113
+ Add to your shell config for easy access:
114
114
 
115
115
  ```bash
116
- # Add to ~/.zshrc or ~/.bashrc or ~/.config/fish/config.fish
117
- alias oc='ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode'
116
+ # ~/.zshrc or ~/.bashrc
117
+ alias oc='/path/to/opencode-claude-max-proxy/bin/oc.sh'
118
118
  ```
119
119
 
120
- #### OpenCode Config File
120
+ #### Shared Proxy
121
+
122
+ If you prefer a single long-running proxy:
121
123
 
122
- Alternatively, the proxy URL and API key can be set globally in `~/.config/opencode/opencode.json`. This has the benefit of working in OpenCode Desktop as well.
124
+ ```bash
125
+ # Terminal 1: start the proxy
126
+ CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy
127
+
128
+ # Terminal 2+: connect OpenCode
129
+ ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
130
+ ```
131
+
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.
133
+
134
+ #### OpenCode Desktop / Config File
135
+
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.
123
137
 
124
138
  ```json
125
139
  {
126
- ...
127
140
  "provider": {
128
141
  "anthropic": {
129
142
  "options": {
@@ -132,10 +145,11 @@ Alternatively, the proxy URL and API key can be set globally in `~/.config/openc
132
145
  }
133
146
  }
134
147
  }
135
- ...
136
148
  }
137
149
  ```
138
150
 
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.
152
+
139
153
  ## Modes
140
154
 
141
155
  ### Passthrough Mode (recommended)
@@ -182,6 +196,7 @@ The proxy tracks SDK session IDs and resumes conversations on follow-up requests
182
196
 
183
197
  - **Faster responses** — no re-processing of conversation history
184
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`
185
200
 
186
201
  Session tracking works two ways:
187
202
 
@@ -192,7 +207,7 @@ Session tracking works two ways:
192
207
 
193
208
  2. **Fingerprint-based** (automatic fallback) — hashes the first user message to identify returning conversations
194
209
 
195
- Sessions are cached for 24 hours.
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.
196
211
 
197
212
  ## Configuration
198
213
 
@@ -207,26 +222,19 @@ Sessions are cached for 24 hours.
207
222
 
208
223
  ## Concurrency
209
224
 
210
- 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.
225
+ **Per-terminal proxies (`oc.sh`)** are the recommended approach for multiple terminals. Each OpenCode instance gets its own proxyno concurrency issues at all.
211
226
 
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
- ```
227
+ **Shared proxy** supports concurrent requests but has a known limitation:
219
228
 
220
229
  > **⚠️ Known Issue: Bun SSE Crash ([oven-sh/bun#17947](https://github.com/oven-sh/bun/issues/17947))**
221
230
  >
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.
231
+ > The Claude Agent SDK's `cli.js` subprocess (compiled with Bun) has a known segfault during cleanup of concurrent streaming responses.
223
232
  >
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.
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
228
236
  >
229
- > We are monitoring the upstream Bun issue for a fix. Once patched, the supervisor becomes optional.
237
+ > We are monitoring the upstream Bun issue for a fix.
230
238
 
231
239
  ## Model Mapping
232
240
 
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-max-proxy",
3
- "version": "1.8.1",
3
+ "version": "1.10.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",
@@ -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
- // Primary: use x-opencode-session header
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
- return sessionCache.get(opencodeSessionId)
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
- // Fallback: fingerprint (only when no header is present)
87
+
88
+ // No session ID — use fingerprint fallback
71
89
  const fp = getConversationFingerprint(messages)
72
- if (fp) return fingerprintCache.get(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)
87
122
  }
88
123
 
89
124
  /** Extract only the last user message (for resume — SDK already has history) */
@@ -0,0 +1,92 @@
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
+ }
22
+
23
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
24
+
25
+ function getStorePath(): string {
26
+ const dir = process.env.CLAUDE_PROXY_SESSION_DIR
27
+ || join(homedir(), ".cache", "opencode-claude-max-proxy")
28
+ if (!existsSync(dir)) {
29
+ mkdirSync(dir, { recursive: true })
30
+ }
31
+ return join(dir, "sessions.json")
32
+ }
33
+
34
+ function readStore(): Record<string, StoredSession> {
35
+ const path = getStorePath()
36
+ if (!existsSync(path)) return {}
37
+ try {
38
+ const data = readFileSync(path, "utf-8")
39
+ const store = JSON.parse(data) as Record<string, StoredSession>
40
+ // Prune expired entries
41
+ const now = Date.now()
42
+ const pruned: Record<string, StoredSession> = {}
43
+ for (const [key, session] of Object.entries(store)) {
44
+ if (now - session.lastUsedAt < SESSION_TTL_MS) {
45
+ pruned[key] = session
46
+ }
47
+ }
48
+ return pruned
49
+ } catch {
50
+ return {}
51
+ }
52
+ }
53
+
54
+ function writeStore(store: Record<string, StoredSession>): void {
55
+ const path = getStorePath()
56
+ const tmp = path + ".tmp"
57
+ try {
58
+ writeFileSync(tmp, JSON.stringify(store, null, 2))
59
+ renameSync(tmp, path) // atomic write
60
+ } catch {
61
+ // If rename fails, try direct write
62
+ try {
63
+ writeFileSync(path, JSON.stringify(store, null, 2))
64
+ } catch {}
65
+ }
66
+ }
67
+
68
+ export function lookupSharedSession(key: string): StoredSession | undefined {
69
+ const store = readStore()
70
+ const session = store[key]
71
+ if (!session) return undefined
72
+ if (Date.now() - session.lastUsedAt >= SESSION_TTL_MS) return undefined
73
+ return session
74
+ }
75
+
76
+ export function storeSharedSession(key: string, claudeSessionId: string): void {
77
+ const store = readStore()
78
+ const existing = store[key]
79
+ store[key] = {
80
+ claudeSessionId,
81
+ createdAt: existing?.createdAt || Date.now(),
82
+ lastUsedAt: Date.now(),
83
+ }
84
+ writeStore(store)
85
+ }
86
+
87
+ export function clearSharedSessions(): void {
88
+ const path = getStorePath()
89
+ try {
90
+ writeFileSync(path, "{}")
91
+ } catch {}
92
+ }