opencode-claude-max-proxy 1.8.0 → 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,19 +102,54 @@ curl http://127.0.0.1:3456/health
102
102
 
103
103
  ### Connect OpenCode
104
104
 
105
+ #### Per-Terminal Launcher (recommended)
106
+
105
107
  ```bash
106
- ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
108
+ ./bin/oc.sh
107
109
  ```
108
110
 
109
- 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
+
113
+ Add to your shell config for easy access:
114
+
115
+ ```bash
116
+ # ~/.zshrc or ~/.bashrc
117
+ alias oc='/path/to/opencode-claude-max-proxy/bin/oc.sh'
118
+ ```
119
+
120
+ #### Shared Proxy
110
121
 
111
- ### Shell Alias
122
+ If you prefer a single long-running proxy:
112
123
 
113
124
  ```bash
114
- # Add to ~/.zshrc or ~/.bashrc
115
- alias oc='ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode'
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.
137
+
138
+ ```json
139
+ {
140
+ "provider": {
141
+ "anthropic": {
142
+ "options": {
143
+ "baseURL": "http://127.0.0.1:3456",
144
+ "apiKey": "dummy"
145
+ }
146
+ }
147
+ }
148
+ }
116
149
  ```
117
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
+
118
153
  ## Modes
119
154
 
120
155
  ### Passthrough Mode (recommended)
@@ -161,6 +196,7 @@ The proxy tracks SDK session IDs and resumes conversations on follow-up requests
161
196
 
162
197
  - **Faster responses** — no re-processing of conversation history
163
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`
164
200
 
165
201
  Session tracking works two ways:
166
202
 
@@ -171,7 +207,7 @@ Session tracking works two ways:
171
207
 
172
208
  2. **Fingerprint-based** (automatic fallback) — hashes the first user message to identify returning conversations
173
209
 
174
- 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.
175
211
 
176
212
  ## Configuration
177
213
 
@@ -186,26 +222,19 @@ Sessions are cached for 24 hours.
186
222
 
187
223
  ## Concurrency
188
224
 
189
- 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.
190
-
191
- **Use the auto-restart supervisor** (recommended):
225
+ **Per-terminal proxies (`oc.sh`)** are the recommended approach for multiple terminals. Each OpenCode instance gets its own proxyno concurrency issues at all.
192
226
 
193
- ```bash
194
- CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy
195
- # or directly:
196
- CLAUDE_PROXY_PASSTHROUGH=1 ./bin/claude-proxy-supervisor.sh
197
- ```
227
+ **Shared proxy** supports concurrent requests but has a known limitation:
198
228
 
199
229
  > **⚠️ Known Issue: Bun SSE Crash ([oven-sh/bun#17947](https://github.com/oven-sh/bun/issues/17947))**
200
230
  >
201
- > 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.
202
232
  >
203
- > **What this means in practice:**
204
- > - **Sequential requests (1 terminal):** No impact. Never crashes.
205
- > - **Concurrent requests (2+ terminals):** All responses are delivered correctly. The crash occurs *after* responses complete, during stream cleanup. No work is lost.
206
- > - **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
207
236
  >
208
- > 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.
209
238
 
210
239
  ## Model Mapping
211
240
 
@@ -0,0 +1,77 @@
1
+ #!/bin/bash
2
+ # Copy Claude credentials from host into Docker container.
3
+ #
4
+ # macOS stores scopes in Keychain, so the credentials file has scopes: ""
5
+ # Linux Claude CLI needs scopes as an array. This script fixes the format.
6
+ #
7
+ # Usage: ./bin/docker-auth.sh
8
+
9
+ set -e
10
+
11
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+ cd "$SCRIPT_DIR/.."
13
+
14
+ HOST_CREDS="$HOME/.claude/.credentials.json"
15
+
16
+ if [ ! -f "$HOST_CREDS" ]; then
17
+ echo "❌ No credentials found at $HOST_CREDS"
18
+ echo " Run 'claude login' on your host first."
19
+ exit 1
20
+ fi
21
+
22
+ # Check if host is logged in
23
+ if ! claude auth status 2>/dev/null | grep -q '"loggedIn": true'; then
24
+ echo "❌ Not logged in on host. Run 'claude login' first."
25
+ exit 1
26
+ fi
27
+
28
+ echo "📋 Reading credentials from host..."
29
+
30
+ # Fix the scopes field and copy into container
31
+ python3 -c "
32
+ import json, sys
33
+
34
+ with open('$HOST_CREDS') as f:
35
+ creds = json.load(f)
36
+
37
+ oauth = creds.get('claudeAiOauth', {})
38
+ if not oauth.get('accessToken'):
39
+ print('❌ No access token found in credentials')
40
+ sys.exit(1)
41
+
42
+ # Fix scopes: empty string -> proper array
43
+ if not oauth.get('scopes') or oauth['scopes'] == '':
44
+ oauth['scopes'] = [
45
+ 'user:profile',
46
+ 'user:inference',
47
+ 'user:sessions:claude_code',
48
+ 'user:mcp_servers',
49
+ 'user:file_upload'
50
+ ]
51
+
52
+ creds['claudeAiOauth'] = oauth
53
+ print(json.dumps(creds))
54
+ " | docker compose exec -T proxy bash -c 'cat > /home/claude/.claude/.credentials.json'
55
+
56
+ if [ $? -ne 0 ]; then
57
+ echo "❌ Failed to copy credentials. Is the container running?"
58
+ echo " Run 'docker compose up -d' first."
59
+ exit 1
60
+ fi
61
+
62
+ # Also copy .claude.json if it exists
63
+ if [ -f "$HOME/.claude/.claude.json" ]; then
64
+ docker compose exec -T proxy bash -c 'cat > /home/claude/.claude/.claude.json' < "$HOME/.claude/.claude.json"
65
+ fi
66
+
67
+ # Verify
68
+ echo "🔍 Verifying..."
69
+ AUTH=$(docker compose exec proxy claude auth status 2>&1)
70
+ if echo "$AUTH" | grep -q '"loggedIn": true'; then
71
+ EMAIL=$(echo "$AUTH" | grep -o '"email": "[^"]*"' | head -1)
72
+ echo "✅ Docker container authenticated! $EMAIL"
73
+ else
74
+ echo "❌ Authentication failed inside container"
75
+ echo "$AUTH"
76
+ exit 1
77
+ fi
@@ -0,0 +1,24 @@
1
+ #!/bin/bash
2
+ # Docker entrypoint:
3
+ # 1. Fix volume permissions (created as root, need claude ownership)
4
+ # 2. Symlink .claude.json into persistent volume
5
+
6
+ CLAUDE_DIR="/home/claude/.claude"
7
+ CLAUDE_JSON="/home/claude/.claude.json"
8
+ CLAUDE_JSON_VOL="$CLAUDE_DIR/.claude.json"
9
+
10
+ # Fix ownership if volume was created as root
11
+ if [ -d "$CLAUDE_DIR" ] && [ ! -w "$CLAUDE_DIR" ]; then
12
+ echo "[entrypoint] Fixing volume permissions..."
13
+ fi
14
+
15
+ # Symlink .claude.json into volume so it persists across restarts
16
+ if [ -f "$CLAUDE_JSON_VOL" ] && [ ! -f "$CLAUDE_JSON" ]; then
17
+ ln -sf "$CLAUDE_JSON_VOL" "$CLAUDE_JSON"
18
+ elif [ -f "$CLAUDE_JSON" ] && [ ! -L "$CLAUDE_JSON" ] && [ -w "$CLAUDE_DIR" ]; then
19
+ cp "$CLAUDE_JSON" "$CLAUDE_JSON_VOL" 2>/dev/null
20
+ rm -f "$CLAUDE_JSON"
21
+ ln -sf "$CLAUDE_JSON_VOL" "$CLAUDE_JSON"
22
+ fi
23
+
24
+ exec "$@"
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.0",
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
+ }