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 +33 -25
- package/bin/oc.sh +62 -0
- package/package.json +1 -1
- package/src/proxy/server.ts +39 -4
- package/src/proxy/sessionStore.ts +92 -0
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
|
-
####
|
|
105
|
+
#### Per-Terminal Launcher (recommended)
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
|
-
|
|
108
|
+
./bin/oc.sh
|
|
109
109
|
```
|
|
110
110
|
|
|
111
|
-
|
|
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
|
-
|
|
113
|
+
Add to your shell config for easy access:
|
|
114
114
|
|
|
115
115
|
```bash
|
|
116
|
-
#
|
|
117
|
-
alias oc='
|
|
116
|
+
# ~/.zshrc or ~/.bashrc
|
|
117
|
+
alias oc='/path/to/opencode-claude-max-proxy/bin/oc.sh'
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
####
|
|
120
|
+
#### Shared Proxy
|
|
121
|
+
|
|
122
|
+
If you prefer a single long-running proxy:
|
|
121
123
|
|
|
122
|
-
|
|
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
|
-
|
|
225
|
+
**Per-terminal proxies (`oc.sh`)** are the recommended approach for multiple terminals. Each OpenCode instance gets its own proxy — no concurrency issues at all.
|
|
211
226
|
|
|
212
|
-
**
|
|
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
|
|
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
|
-
> **
|
|
225
|
-
> - **
|
|
226
|
-
> - **
|
|
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.
|
|
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
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)
|
|
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
|
+
}
|