opencode-claude-max-proxy 1.0.2 → 1.8.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.
@@ -5,6 +5,250 @@ import type { Context } from "hono"
5
5
  import type { ProxyConfig } from "./types"
6
6
  import { DEFAULT_PROXY_CONFIG } from "./types"
7
7
  import { claudeLog } from "../logger"
8
+ import { execSync } from "child_process"
9
+ import { existsSync } from "fs"
10
+ import { fileURLToPath } from "url"
11
+ import { join, dirname } from "path"
12
+ import { opencodeMcpServer } from "../mcpTools"
13
+ import { randomUUID, createHash } from "crypto"
14
+ import { withClaudeLogContext } from "../logger"
15
+ import { fuzzyMatchAgentName } from "./agentMatch"
16
+ import { buildAgentDefinitions } from "./agentDefs"
17
+ import { createPassthroughMcpServer, stripMcpPrefix, PASSTHROUGH_MCP_NAME, PASSTHROUGH_MCP_PREFIX } from "./passthroughTools"
18
+
19
+ // --- Session Tracking ---
20
+ // Maps OpenCode session ID (or fingerprint) → Claude SDK session ID
21
+ interface SessionState {
22
+ claudeSessionId: string
23
+ lastAccess: number
24
+ messageCount: number
25
+ }
26
+
27
+ const sessionCache = new Map<string, SessionState>()
28
+ const fingerprintCache = new Map<string, SessionState>()
29
+
30
+ /** Clear all session caches (used in tests) */
31
+ export function clearSessionCache() {
32
+ sessionCache.clear()
33
+ fingerprintCache.clear()
34
+ }
35
+
36
+ // Clean stale sessions every hour — sessions survive a full workday
37
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
38
+ setInterval(() => {
39
+ const now = Date.now()
40
+ for (const [key, val] of sessionCache) {
41
+ if (now - val.lastAccess > SESSION_TTL_MS) sessionCache.delete(key)
42
+ }
43
+ for (const [key, val] of fingerprintCache) {
44
+ if (now - val.lastAccess > SESSION_TTL_MS) fingerprintCache.delete(key)
45
+ }
46
+ }, 60 * 60 * 1000)
47
+
48
+ /** Hash the first user message to fingerprint a conversation */
49
+ function getConversationFingerprint(messages: Array<{ role: string; content: any }>): string {
50
+ const firstUser = messages?.find((m) => m.role === "user")
51
+ if (!firstUser) return ""
52
+ const text = typeof firstUser.content === "string"
53
+ ? firstUser.content
54
+ : Array.isArray(firstUser.content)
55
+ ? firstUser.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
56
+ : ""
57
+ if (!text) return ""
58
+ return createHash("sha256").update(text.slice(0, 2000)).digest("hex").slice(0, 16)
59
+ }
60
+
61
+ /** Look up a cached session by header or fingerprint */
62
+ function lookupSession(
63
+ opencodeSessionId: string | undefined,
64
+ messages: Array<{ role: string; content: any }>
65
+ ): SessionState | undefined {
66
+ // Primary: use x-opencode-session header
67
+ if (opencodeSessionId) {
68
+ return sessionCache.get(opencodeSessionId)
69
+ }
70
+ // Fallback: fingerprint (only when no header is present)
71
+ const fp = getConversationFingerprint(messages)
72
+ if (fp) return fingerprintCache.get(fp)
73
+ return undefined
74
+ }
75
+
76
+ /** Store a session mapping */
77
+ function storeSession(
78
+ opencodeSessionId: string | undefined,
79
+ messages: Array<{ role: string; content: any }>,
80
+ claudeSessionId: string
81
+ ) {
82
+ if (!claudeSessionId) return
83
+ const state: SessionState = { claudeSessionId, lastAccess: Date.now(), messageCount: messages?.length || 0 }
84
+ if (opencodeSessionId) sessionCache.set(opencodeSessionId, state)
85
+ const fp = getConversationFingerprint(messages)
86
+ if (fp) fingerprintCache.set(fp, state)
87
+ }
88
+
89
+ /** Extract only the last user message (for resume — SDK already has history) */
90
+ function getLastUserMessage(messages: Array<{ role: string; content: any }>): Array<{ role: string; content: any }> {
91
+ for (let i = messages.length - 1; i >= 0; i--) {
92
+ if (messages[i]?.role === "user") return [messages[i]!]
93
+ }
94
+ return messages.slice(-1)
95
+ }
96
+
97
+ // --- Error Classification ---
98
+ // Detect specific SDK errors and return helpful messages to the client
99
+ function classifyError(errMsg: string): { status: number; type: string; message: string } {
100
+ const lower = errMsg.toLowerCase()
101
+
102
+ // Authentication failures
103
+ if (lower.includes("401") || lower.includes("authentication") || lower.includes("invalid auth") || lower.includes("credentials")) {
104
+ return {
105
+ status: 401,
106
+ type: "authentication_error",
107
+ message: "Claude authentication expired or invalid. Run 'claude login' in your terminal to re-authenticate, then restart the proxy."
108
+ }
109
+ }
110
+
111
+ // Rate limiting
112
+ if (lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests")) {
113
+ return {
114
+ status: 429,
115
+ type: "rate_limit_error",
116
+ message: "Claude Max rate limit reached. Wait a moment and try again."
117
+ }
118
+ }
119
+
120
+ // Billing / subscription
121
+ if (lower.includes("402") || lower.includes("billing") || lower.includes("subscription") || lower.includes("payment")) {
122
+ return {
123
+ status: 402,
124
+ type: "billing_error",
125
+ message: "Claude Max subscription issue. Check your subscription status at https://claude.ai/settings/subscription"
126
+ }
127
+ }
128
+
129
+ // SDK process crash
130
+ if (lower.includes("exited with code") || lower.includes("process exited")) {
131
+ const codeMatch = errMsg.match(/exited with code (\d+)/)
132
+ const code = codeMatch ? codeMatch[1] : "unknown"
133
+
134
+ // Code 1 with no other info is usually auth
135
+ if (code === "1" && !lower.includes("tool") && !lower.includes("mcp")) {
136
+ return {
137
+ status: 401,
138
+ type: "authentication_error",
139
+ message: "Claude Code process crashed (exit code 1). This usually means authentication expired. Run 'claude login' in your terminal to re-authenticate, then restart the proxy."
140
+ }
141
+ }
142
+
143
+ return {
144
+ status: 502,
145
+ type: "api_error",
146
+ message: `Claude Code process exited unexpectedly (code ${code}). Check proxy logs for details. If this persists, try 'claude login' to refresh authentication.`
147
+ }
148
+ }
149
+
150
+ // Timeout
151
+ if (lower.includes("timeout") || lower.includes("timed out")) {
152
+ return {
153
+ status: 504,
154
+ type: "timeout_error",
155
+ message: "Request timed out. The operation may have been too complex. Try a simpler request."
156
+ }
157
+ }
158
+
159
+ // Server errors from Anthropic
160
+ if (lower.includes("500") || lower.includes("server error") || lower.includes("internal error")) {
161
+ return {
162
+ status: 502,
163
+ type: "api_error",
164
+ message: "Claude API returned a server error. This is usually temporary — try again in a moment."
165
+ }
166
+ }
167
+
168
+ // Overloaded
169
+ if (lower.includes("503") || lower.includes("overloaded")) {
170
+ return {
171
+ status: 503,
172
+ type: "overloaded_error",
173
+ message: "Claude is temporarily overloaded. Try again in a few seconds."
174
+ }
175
+ }
176
+
177
+ // Default
178
+ return {
179
+ status: 500,
180
+ type: "api_error",
181
+ message: errMsg || "Unknown error"
182
+ }
183
+ }
184
+
185
+ // Block SDK built-in tools so Claude only uses MCP tools (which have correct param names)
186
+ const BLOCKED_BUILTIN_TOOLS = [
187
+ "Read", "Write", "Edit", "MultiEdit",
188
+ "Bash", "Glob", "Grep", "NotebookEdit",
189
+ "WebFetch", "WebSearch", "TodoWrite"
190
+ ]
191
+
192
+ // Claude Code SDK tools that have NO equivalent in OpenCode.
193
+ // Only block these — everything else either has an OpenCode equivalent
194
+ // or is handled by OpenCode's own tool system.
195
+ //
196
+ // Tools where OpenCode has an equivalent but with a DIFFERENT name/schema
197
+ // are blocked so Claude uses OpenCode's version instead of the SDK's.
198
+ // See schema-incompatible section below.
199
+ //
200
+ // These truly have NO OpenCode equivalent (BLOCKED):
201
+ const CLAUDE_CODE_ONLY_TOOLS = [
202
+ "ToolSearch", // Claude Code deferred tool loading (internal mechanism)
203
+ "CronCreate", // Claude Code cron jobs
204
+ "CronDelete", // Claude Code cron jobs
205
+ "CronList", // Claude Code cron jobs
206
+ "EnterPlanMode", // Claude Code mode switching (OpenCode uses plan agent instead)
207
+ "ExitPlanMode", // Claude Code mode switching
208
+ "EnterWorktree", // Claude Code git worktree management
209
+ "ExitWorktree", // Claude Code git worktree management
210
+ "NotebookEdit", // Jupyter notebook editing
211
+ // Schema-incompatible: SDK tool name differs from OpenCode's.
212
+ // If Claude calls the SDK version, OpenCode won't recognize it.
213
+ // Block the SDK's so Claude only sees OpenCode's definitions.
214
+ "TodoWrite", // OpenCode: todowrite (requires 'priority' field)
215
+ "AskUserQuestion", // OpenCode: question
216
+ "Skill", // OpenCode: skill / skill_mcp / slashcommand
217
+ "Agent", // OpenCode: delegate_task / task
218
+ "TaskOutput", // OpenCode: background_output
219
+ "TaskStop", // OpenCode: background_cancel
220
+ "WebSearch", // OpenCode: websearch_web_search_exa
221
+ ]
222
+
223
+ const MCP_SERVER_NAME = "opencode"
224
+
225
+ const ALLOWED_MCP_TOOLS = [
226
+ `mcp__${MCP_SERVER_NAME}__read`,
227
+ `mcp__${MCP_SERVER_NAME}__write`,
228
+ `mcp__${MCP_SERVER_NAME}__edit`,
229
+ `mcp__${MCP_SERVER_NAME}__bash`,
230
+ `mcp__${MCP_SERVER_NAME}__glob`,
231
+ `mcp__${MCP_SERVER_NAME}__grep`
232
+ ]
233
+
234
+ function resolveClaudeExecutable(): string {
235
+ // 1. Try the SDK's bundled cli.js (same dir as this module's SDK)
236
+ try {
237
+ const sdkPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk"))
238
+ const sdkCliJs = join(dirname(sdkPath), "cli.js")
239
+ if (existsSync(sdkCliJs)) return sdkCliJs
240
+ } catch {}
241
+
242
+ // 2. Try the system-installed claude binary
243
+ try {
244
+ const claudePath = execSync("which claude", { encoding: "utf-8" }).trim()
245
+ if (claudePath && existsSync(claudePath)) return claudePath
246
+ } catch {}
247
+
248
+ throw new Error("Could not find Claude Code executable. Install via: npm install -g @anthropic-ai/claude-code")
249
+ }
250
+
251
+ const claudeExecutable = resolveClaudeExecutable()
8
252
 
9
253
  function mapModelToClaudeModel(model: string): "sonnet" | "opus" | "haiku" {
10
254
  if (model.includes("opus")) return "opus"
@@ -12,6 +256,11 @@ function mapModelToClaudeModel(model: string): "sonnet" | "opus" | "haiku" {
12
256
  return "sonnet"
13
257
  }
14
258
 
259
+ function isClosedControllerError(error: unknown): boolean {
260
+ if (!(error instanceof Error)) return false
261
+ return error.message.includes("Controller is already closed")
262
+ }
263
+
15
264
  export function createProxyServer(config: Partial<ProxyConfig> = {}) {
16
265
  const finalConfig = { ...DEFAULT_PROXY_CONFIG, ...config }
17
266
  const app = new Hono()
@@ -28,25 +277,131 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
28
277
  })
29
278
  })
30
279
 
31
- const handleMessages = async (c: Context) => {
32
- try {
33
- const body = await c.req.json()
34
- const model = mapModelToClaudeModel(body.model || "sonnet")
35
- const stream = body.stream ?? true
280
+ // --- Concurrency Control ---
281
+ // Each request spawns an SDK subprocess (cli.js, ~11MB). Spawning multiple
282
+ // simultaneously can crash the process. Serialize SDK queries with a queue.
283
+ const MAX_CONCURRENT_SESSIONS = parseInt(process.env.CLAUDE_PROXY_MAX_CONCURRENT || "10", 10)
284
+ let activeSessions = 0
285
+ const sessionQueue: Array<{ resolve: () => void }> = []
286
+
287
+ async function acquireSession(): Promise<void> {
288
+ if (activeSessions < MAX_CONCURRENT_SESSIONS) {
289
+ activeSessions++
290
+ return
291
+ }
292
+ // Wait for a slot
293
+ return new Promise<void>((resolve) => {
294
+ sessionQueue.push({ resolve })
295
+ })
296
+ }
297
+
298
+ function releaseSession(): void {
299
+ activeSessions--
300
+ const next = sessionQueue.shift()
301
+ if (next) {
302
+ activeSessions++
303
+ next.resolve()
304
+ }
305
+ }
306
+
307
+ const handleMessages = async (
308
+ c: Context,
309
+ requestMeta: { requestId: string; endpoint: string; queueEnteredAt: number; queueStartedAt: number }
310
+ ) => {
311
+ const requestStartAt = Date.now()
312
+
313
+ return withClaudeLogContext({ requestId: requestMeta.requestId, endpoint: requestMeta.endpoint }, async () => {
314
+ try {
315
+ const body = await c.req.json()
316
+ const model = mapModelToClaudeModel(body.model || "sonnet")
317
+ const stream = body.stream ?? true
318
+ const workingDirectory = process.env.CLAUDE_PROXY_WORKDIR || process.cwd()
36
319
 
37
- claudeLog("proxy.anthropic.request", { model, stream, messageCount: body.messages?.length })
320
+ // Strip env vars that cause SDK subprocess to load unwanted plugins/features
321
+ const { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, ...cleanEnv } = process.env
322
+
323
+ // Session resume: look up cached Claude SDK session
324
+ const opencodeSessionId = c.req.header("x-opencode-session")
325
+ const cachedSession = lookupSession(opencodeSessionId, body.messages || [])
326
+ const resumeSessionId = cachedSession?.claudeSessionId
327
+ const isResume = Boolean(resumeSessionId)
328
+
329
+ // Debug: log request details
330
+ const msgSummary = body.messages?.map((m: any) => {
331
+ const contentTypes = Array.isArray(m.content)
332
+ ? m.content.map((b: any) => b.type).join(",")
333
+ : "string"
334
+ return `${m.role}[${contentTypes}]`
335
+ }).join(" → ")
336
+ console.error(`[PROXY] ${requestMeta.requestId} model=${model} stream=${stream} tools=${body.tools?.length ?? 0} resume=${isResume} active=${activeSessions}/${MAX_CONCURRENT_SESSIONS} msgs=${msgSummary}`)
337
+
338
+ claudeLog("request.received", {
339
+ model,
340
+ stream,
341
+ queueWaitMs: requestMeta.queueStartedAt - requestMeta.queueEnteredAt,
342
+ messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
343
+ hasSystemPrompt: Boolean(body.system)
344
+ })
345
+
346
+ // Build system context from the request's system prompt
347
+ let systemContext = ""
348
+ if (body.system) {
349
+ if (typeof body.system === "string") {
350
+ systemContext = body.system
351
+ } else if (Array.isArray(body.system)) {
352
+ systemContext = body.system
353
+ .filter((b: any) => b.type === "text" && b.text)
354
+ .map((b: any) => b.text)
355
+ .join("\n")
356
+ }
357
+ }
358
+
359
+ // Extract available agent types from the Task tool definition.
360
+ // Used for: 1) SDK agent definitions, 2) fuzzy matching in PreToolUse hook, 3) prompt hints
361
+ let validAgentNames: string[] = []
362
+ let sdkAgents: Record<string, any> = {}
363
+ if (Array.isArray(body.tools)) {
364
+ const taskTool = body.tools.find((t: any) => t.name === "task" || t.name === "Task")
365
+ if (taskTool?.description) {
366
+ // Build SDK agent definitions from the Task tool description.
367
+ // This makes the SDK's native Task handler recognize agent names
368
+ // from OpenCode (with or without oh-my-opencode).
369
+ sdkAgents = buildAgentDefinitions(taskTool.description, [...ALLOWED_MCP_TOOLS])
370
+ validAgentNames = Object.keys(sdkAgents)
371
+
372
+ if (process.env.CLAUDE_PROXY_DEBUG) {
373
+ claudeLog("debug.agents", { names: validAgentNames, count: validAgentNames.length })
374
+ }
375
+ if (validAgentNames.length > 0) {
376
+ systemContext += `\n\nIMPORTANT: When using the task/Task tool, the subagent_type parameter must be one of these exact values (case-sensitive, lowercase): ${validAgentNames.join(", ")}. Do NOT capitalize or modify these names.`
377
+ }
378
+ }
379
+ }
38
380
 
39
- const prompt = body.messages
40
- ?.map((m: { role: string; content: string | Array<{ type: string; text?: string }> }) => {
381
+
382
+
383
+ // When resuming, only send the last user message (SDK already has history)
384
+ const messagesToConvert = isResume
385
+ ? getLastUserMessage(body.messages || [])
386
+ : body.messages
387
+
388
+ // Convert messages to a text prompt, preserving all content types
389
+ const conversationParts = messagesToConvert
390
+ ?.map((m: { role: string; content: string | Array<{ type: string; text?: string; content?: string; tool_use_id?: string; name?: string; input?: unknown; id?: string }> }) => {
41
391
  const role = m.role === "assistant" ? "Assistant" : "Human"
42
392
  let content: string
43
393
  if (typeof m.content === "string") {
44
394
  content = m.content
45
395
  } else if (Array.isArray(m.content)) {
46
396
  content = m.content
47
- .filter((block) => block.type === "text" && block.text)
48
- .map((block) => block.text)
49
- .join("")
397
+ .map((block: any) => {
398
+ if (block.type === "text" && block.text) return block.text
399
+ if (block.type === "tool_use") return `[Tool Use: ${block.name}(${JSON.stringify(block.input)})]`
400
+ if (block.type === "tool_result") return `[Tool Result for ${block.tool_use_id}: ${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}]`
401
+ return ""
402
+ })
403
+ .filter(Boolean)
404
+ .join("\n")
50
405
  } else {
51
406
  content = String(m.content)
52
407
  }
@@ -54,136 +409,595 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
54
409
  })
55
410
  .join("\n\n") || ""
56
411
 
57
- if (!stream) {
58
- let fullContent = ""
59
- const response = query({
60
- prompt,
61
- options: { maxTurns: 1, model }
62
- })
412
+ // --- Passthrough mode ---
413
+ // When enabled, ALL tool execution is forwarded to OpenCode instead of
414
+ // being handled internally. This enables multi-model agent delegation
415
+ // (e.g., oracle on GPT-5.2, explore on Gemini via oh-my-opencode).
416
+ const passthrough = Boolean(process.env.CLAUDE_PROXY_PASSTHROUGH)
417
+ const capturedToolUses: Array<{ id: string; name: string; input: any }> = []
418
+
419
+ // In passthrough mode, register OpenCode's tools as MCP tools so Claude
420
+ // can actually call them (not just see them as text descriptions).
421
+ let passthroughMcp: ReturnType<typeof createPassthroughMcpServer> | undefined
422
+ if (passthrough && Array.isArray(body.tools) && body.tools.length > 0) {
423
+ passthroughMcp = createPassthroughMcpServer(body.tools)
424
+ }
425
+
426
+
427
+
428
+ // In passthrough mode: block ALL tools, capture them for forwarding
429
+ // In normal mode: only fix agent names on Task tool
430
+ const sdkHooks = passthrough
431
+ ? {
432
+ PreToolUse: [{
433
+ matcher: "", // Match ALL tools
434
+ hooks: [async (input: any) => {
435
+ capturedToolUses.push({
436
+ id: input.tool_use_id,
437
+ name: stripMcpPrefix(input.tool_name),
438
+ input: input.tool_input,
439
+ })
440
+ return {
441
+ decision: "block" as const,
442
+ reason: "Forwarding to client for execution",
443
+ }
444
+ }],
445
+ }],
446
+ }
447
+ : validAgentNames.length > 0
448
+ ? {
449
+ PreToolUse: [{
450
+ matcher: "Task",
451
+ hooks: [async (input: any) => ({
452
+ hookSpecificOutput: {
453
+ hookEventName: "PreToolUse" as const,
454
+ updatedInput: {
455
+ ...input.tool_input,
456
+ subagent_type: fuzzyMatchAgentName(
457
+ String(input.tool_input?.subagent_type || ""),
458
+ validAgentNames
459
+ ),
460
+ },
461
+ },
462
+ })],
463
+ }],
464
+ }
465
+ : undefined
466
+
467
+ // Combine system context with conversation
468
+ const prompt = systemContext
469
+ ? `${systemContext}\n\n${conversationParts}`
470
+ : conversationParts
471
+
472
+ if (!stream) {
473
+ const contentBlocks: Array<Record<string, unknown>> = []
474
+ let assistantMessages = 0
475
+ const upstreamStartAt = Date.now()
476
+ let firstChunkAt: number | undefined
477
+ let currentSessionId: string | undefined
478
+
479
+ claudeLog("upstream.start", { mode: "non_stream", model })
480
+
481
+ try {
482
+ const response = query({
483
+ prompt,
484
+ options: {
485
+ maxTurns: passthrough ? 1 : 100,
486
+ cwd: workingDirectory,
487
+ model,
488
+ pathToClaudeCodeExecutable: claudeExecutable,
489
+ permissionMode: "bypassPermissions",
490
+ allowDangerouslySkipPermissions: true,
491
+ // In passthrough mode: block ALL SDK built-in tools, use OpenCode's via MCP
492
+ // In normal mode: block built-ins, use our own MCP replacements
493
+ ...(passthrough
494
+ ? {
495
+ disallowedTools: [...BLOCKED_BUILTIN_TOOLS, ...CLAUDE_CODE_ONLY_TOOLS],
496
+ ...(passthroughMcp ? {
497
+ allowedTools: passthroughMcp.toolNames,
498
+ mcpServers: { [PASSTHROUGH_MCP_NAME]: passthroughMcp.server },
499
+ } : {}),
500
+ }
501
+ : {
502
+ disallowedTools: [...BLOCKED_BUILTIN_TOOLS],
503
+ allowedTools: [...ALLOWED_MCP_TOOLS],
504
+ mcpServers: { [MCP_SERVER_NAME]: opencodeMcpServer },
505
+ }),
506
+ plugins: [],
507
+ env: { ...cleanEnv, ENABLE_TOOL_SEARCH: "false" },
508
+ ...(Object.keys(sdkAgents).length > 0 ? { agents: sdkAgents } : {}),
509
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
510
+ ...(sdkHooks ? { hooks: sdkHooks } : {}),
511
+ }
512
+ })
513
+
514
+ for await (const message of response) {
515
+ // Capture session ID from SDK messages
516
+ if ((message as any).session_id) {
517
+ currentSessionId = (message as any).session_id
518
+ }
519
+ if (message.type === "assistant") {
520
+ assistantMessages += 1
521
+ if (!firstChunkAt) {
522
+ firstChunkAt = Date.now()
523
+ claudeLog("upstream.first_chunk", {
524
+ mode: "non_stream",
525
+ model,
526
+ ttfbMs: firstChunkAt - upstreamStartAt
527
+ })
528
+ }
529
+
530
+ // Preserve ALL content blocks (text, tool_use, thinking, etc.)
531
+ for (const block of message.message.content) {
532
+ const b = block as Record<string, unknown>
533
+ // In passthrough mode, strip MCP prefix from tool names
534
+ if (passthrough && b.type === "tool_use" && typeof b.name === "string") {
535
+ b.name = stripMcpPrefix(b.name as string)
536
+ }
537
+ contentBlocks.push(b)
538
+ }
539
+ }
540
+ }
541
+
542
+ claudeLog("upstream.completed", {
543
+ mode: "non_stream",
544
+ model,
545
+ assistantMessages,
546
+ durationMs: Date.now() - upstreamStartAt
547
+ })
548
+ } catch (error) {
549
+ claudeLog("upstream.failed", {
550
+ mode: "non_stream",
551
+ model,
552
+ durationMs: Date.now() - upstreamStartAt,
553
+ error: error instanceof Error ? error.message : String(error)
554
+ })
555
+ throw error
556
+ }
63
557
 
64
- for await (const message of response) {
65
- if (message.type === "assistant") {
66
- for (const block of message.message.content) {
67
- if (block.type === "text") {
68
- fullContent += block.text
558
+ // In passthrough mode, add captured tool_use blocks from the hook
559
+ // (the SDK may not include them in content after blocking)
560
+ if (passthrough && capturedToolUses.length > 0) {
561
+ for (const tu of capturedToolUses) {
562
+ // Only add if not already in contentBlocks
563
+ if (!contentBlocks.some((b) => b.type === "tool_use" && (b as any).id === tu.id)) {
564
+ contentBlocks.push({
565
+ type: "tool_use",
566
+ id: tu.id,
567
+ name: tu.name,
568
+ input: tu.input,
569
+ })
69
570
  }
70
571
  }
71
572
  }
573
+
574
+ // Determine stop_reason based on content: tool_use if any tool blocks, else end_turn
575
+ const hasToolUse = contentBlocks.some((b) => b.type === "tool_use")
576
+ const stopReason = hasToolUse ? "tool_use" : "end_turn"
577
+
578
+ // If no content at all, add a fallback text block
579
+ if (contentBlocks.length === 0) {
580
+ contentBlocks.push({
581
+ type: "text",
582
+ text: "I can help with that. Could you provide more details about what you'd like me to do?"
583
+ })
584
+ claudeLog("response.fallback_used", { mode: "non_stream", reason: "no_content_blocks" })
585
+ }
586
+
587
+ claudeLog("response.completed", {
588
+ mode: "non_stream",
589
+ model,
590
+ durationMs: Date.now() - requestStartAt,
591
+ contentBlocks: contentBlocks.length,
592
+ hasToolUse
593
+ })
594
+
595
+ // Store session for future resume
596
+ if (currentSessionId) {
597
+ storeSession(opencodeSessionId, body.messages || [], currentSessionId)
598
+ }
599
+
600
+ const responseSessionId = currentSessionId || resumeSessionId || `session_${Date.now()}`
601
+
602
+ return new Response(JSON.stringify({
603
+ id: `msg_${Date.now()}`,
604
+ type: "message",
605
+ role: "assistant",
606
+ content: contentBlocks,
607
+ model: body.model,
608
+ stop_reason: stopReason,
609
+ usage: { input_tokens: 0, output_tokens: 0 }
610
+ }), {
611
+ headers: {
612
+ "Content-Type": "application/json",
613
+ "X-Claude-Session-ID": responseSessionId,
614
+ }
615
+ })
72
616
  }
73
617
 
74
- return c.json({
75
- id: `msg_${Date.now()}`,
76
- type: "message",
77
- role: "assistant",
78
- content: [{ type: "text", text: fullContent }],
79
- model: body.model,
80
- stop_reason: "end_turn",
81
- usage: { input_tokens: 0, output_tokens: 0 }
82
- })
83
- }
618
+ const encoder = new TextEncoder()
619
+ const readable = new ReadableStream({
620
+ async start(controller) {
621
+ const upstreamStartAt = Date.now()
622
+ let firstChunkAt: number | undefined
623
+ let heartbeatCount = 0
624
+ let streamEventsSeen = 0
625
+ let eventsForwarded = 0
626
+ let textEventsForwarded = 0
627
+ let bytesSent = 0
628
+ let streamClosed = false
84
629
 
85
- const encoder = new TextEncoder()
86
- const readable = new ReadableStream({
87
- async start(controller) {
88
- try {
89
- controller.enqueue(encoder.encode(`event: message_start\ndata: ${JSON.stringify({
90
- type: "message_start",
91
- message: {
92
- id: `msg_${Date.now()}`,
93
- type: "message",
94
- role: "assistant",
95
- content: [],
96
- model: body.model,
97
- stop_reason: null,
98
- usage: { input_tokens: 0, output_tokens: 0 }
630
+ claudeLog("upstream.start", { mode: "stream", model })
631
+
632
+ const safeEnqueue = (payload: Uint8Array, source: string): boolean => {
633
+ if (streamClosed) return false
634
+ try {
635
+ controller.enqueue(payload)
636
+ bytesSent += payload.byteLength
637
+ return true
638
+ } catch (error) {
639
+ if (isClosedControllerError(error)) {
640
+ streamClosed = true
641
+ claudeLog("stream.client_closed", { source, streamEventsSeen, eventsForwarded })
642
+ return false
643
+ }
644
+
645
+ claudeLog("stream.enqueue_failed", {
646
+ source,
647
+ error: error instanceof Error ? error.message : String(error)
648
+ })
649
+ throw error
99
650
  }
100
- })}\n\n`))
651
+ }
101
652
 
102
- controller.enqueue(encoder.encode(`event: content_block_start\ndata: ${JSON.stringify({
103
- type: "content_block_start",
104
- index: 0,
105
- content_block: { type: "text", text: "" }
106
- })}\n\n`))
653
+ try {
654
+ let currentSessionId: string | undefined
655
+ const response = query({
656
+ prompt,
657
+ options: {
658
+ maxTurns: passthrough ? 1 : 100,
659
+ cwd: workingDirectory,
660
+ model,
661
+ pathToClaudeCodeExecutable: claudeExecutable,
662
+ includePartialMessages: true,
663
+ permissionMode: "bypassPermissions",
664
+ allowDangerouslySkipPermissions: true,
665
+ ...(passthrough
666
+ ? {
667
+ disallowedTools: [...BLOCKED_BUILTIN_TOOLS, ...CLAUDE_CODE_ONLY_TOOLS],
668
+ ...(passthroughMcp ? {
669
+ allowedTools: passthroughMcp.toolNames,
670
+ mcpServers: { [PASSTHROUGH_MCP_NAME]: passthroughMcp.server },
671
+ } : {}),
672
+ }
673
+ : {
674
+ disallowedTools: [...BLOCKED_BUILTIN_TOOLS],
675
+ allowedTools: [...ALLOWED_MCP_TOOLS],
676
+ mcpServers: { [MCP_SERVER_NAME]: opencodeMcpServer },
677
+ }),
678
+ plugins: [],
679
+ env: { ...cleanEnv, ENABLE_TOOL_SEARCH: "false" },
680
+ ...(Object.keys(sdkAgents).length > 0 ? { agents: sdkAgents } : {}),
681
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
682
+ ...(sdkHooks ? { hooks: sdkHooks } : {}),
683
+ }
684
+ })
107
685
 
108
- const response = query({
109
- prompt,
110
- options: { maxTurns: 1, model }
111
- })
686
+ const heartbeat = setInterval(() => {
687
+ heartbeatCount += 1
688
+ try {
689
+ const payload = encoder.encode(`: ping\n\n`)
690
+ if (!safeEnqueue(payload, "heartbeat")) {
691
+ clearInterval(heartbeat)
692
+ return
693
+ }
694
+ if (heartbeatCount % 5 === 0) {
695
+ claudeLog("stream.heartbeat", { count: heartbeatCount })
696
+ }
697
+ } catch (error) {
698
+ claudeLog("stream.heartbeat_failed", {
699
+ count: heartbeatCount,
700
+ error: error instanceof Error ? error.message : String(error)
701
+ })
702
+ clearInterval(heartbeat)
703
+ }
704
+ }, 15_000)
705
+
706
+ const skipBlockIndices = new Set<number>()
707
+ let messageStartEmitted = false // Track if we've sent a message_start to the client
112
708
 
113
- const heartbeat = setInterval(() => {
114
709
  try {
115
- controller.enqueue(encoder.encode(`: ping\n\n`))
116
- } catch {
710
+ for await (const message of response) {
711
+ if (streamClosed) {
712
+ break
713
+ }
714
+
715
+ // Capture session ID from any SDK message
716
+ if ((message as any).session_id) {
717
+ currentSessionId = (message as any).session_id
718
+ }
719
+
720
+ if (message.type === "stream_event") {
721
+ streamEventsSeen += 1
722
+ if (!firstChunkAt) {
723
+ firstChunkAt = Date.now()
724
+ claudeLog("upstream.first_chunk", {
725
+ mode: "stream",
726
+ model,
727
+ ttfbMs: firstChunkAt - upstreamStartAt
728
+ })
729
+ }
730
+
731
+ const event = message.event
732
+ const eventType = (event as any).type
733
+ const eventIndex = (event as any).index as number | undefined
734
+
735
+ // Track MCP tool blocks (mcp__opencode__*) — these are internal tools
736
+ // that the SDK executes. Don't forward them to OpenCode.
737
+ if (eventType === "message_start") {
738
+ skipBlockIndices.clear()
739
+ // Only emit the first message_start — subsequent ones are internal SDK turns
740
+ if (messageStartEmitted) {
741
+ continue
742
+ }
743
+ messageStartEmitted = true
744
+ }
745
+
746
+ // Skip intermediate message_stop events (SDK will start another turn)
747
+ // Only emit message_stop when the final message ends
748
+ if (eventType === "message_stop") {
749
+ // Peek: if there are more events coming, skip this message_stop
750
+ // We handle this by only emitting message_stop at the very end (after the loop)
751
+ continue
752
+ }
753
+
754
+ if (eventType === "content_block_start") {
755
+ const block = (event as any).content_block
756
+ if (block?.type === "tool_use" && typeof block.name === "string") {
757
+ if (passthrough && block.name.startsWith(PASSTHROUGH_MCP_PREFIX)) {
758
+ // Passthrough mode: strip prefix and forward to OpenCode
759
+ block.name = stripMcpPrefix(block.name)
760
+ } else if (block.name.startsWith("mcp__")) {
761
+ // Internal mode: skip all MCP tool blocks (internal execution)
762
+ if (eventIndex !== undefined) skipBlockIndices.add(eventIndex)
763
+ continue
764
+ }
765
+ }
766
+ }
767
+
768
+ // Skip deltas and stops for MCP tool blocks
769
+ if (eventIndex !== undefined && skipBlockIndices.has(eventIndex)) {
770
+ continue
771
+ }
772
+
773
+ // Skip intermediate message_delta with stop_reason: tool_use
774
+ // (SDK is about to execute MCP tools and continue)
775
+ if (eventType === "message_delta") {
776
+ const stopReason = (event as any).delta?.stop_reason
777
+ if (stopReason === "tool_use" && skipBlockIndices.size > 0) {
778
+ // All tool_use blocks in this turn were MCP — skip this delta
779
+ continue
780
+ }
781
+ }
782
+
783
+ // Forward all other events (text, non-MCP tool_use like Task, message events)
784
+ const payload = encoder.encode(`event: ${eventType}\ndata: ${JSON.stringify(event)}\n\n`)
785
+ if (!safeEnqueue(payload, `stream_event:${eventType}`)) {
786
+ break
787
+ }
788
+ eventsForwarded += 1
789
+
790
+ if (eventType === "content_block_delta") {
791
+ const delta = (event as any).delta
792
+ if (delta?.type === "text_delta") {
793
+ textEventsForwarded += 1
794
+ }
795
+ }
796
+ }
797
+ }
798
+ } finally {
117
799
  clearInterval(heartbeat)
118
800
  }
119
- }, 15_000)
120
801
 
121
- try {
122
- for await (const message of response) {
123
- if (message.type === "assistant") {
124
- for (const block of message.message.content) {
125
- if (block.type === "text") {
126
- controller.enqueue(encoder.encode(`event: content_block_delta\ndata: ${JSON.stringify({
802
+ claudeLog("upstream.completed", {
803
+ mode: "stream",
804
+ model,
805
+ durationMs: Date.now() - upstreamStartAt,
806
+ streamEventsSeen,
807
+ eventsForwarded,
808
+ textEventsForwarded
809
+ })
810
+
811
+ // Store session for future resume
812
+ if (currentSessionId) {
813
+ storeSession(opencodeSessionId, body.messages || [], currentSessionId)
814
+ }
815
+
816
+ if (!streamClosed) {
817
+ // In passthrough mode, emit captured tool_use blocks as stream events
818
+ if (passthrough && capturedToolUses.length > 0 && messageStartEmitted) {
819
+ for (let i = 0; i < capturedToolUses.length; i++) {
820
+ const tu = capturedToolUses[i]!
821
+ const blockIndex = eventsForwarded + i
822
+
823
+ // content_block_start
824
+ safeEnqueue(encoder.encode(
825
+ `event: content_block_start\ndata: ${JSON.stringify({
826
+ type: "content_block_start",
827
+ index: blockIndex,
828
+ content_block: { type: "tool_use", id: tu.id, name: tu.name, input: {} }
829
+ })}\n\n`
830
+ ), "passthrough_tool_block_start")
831
+
832
+ // input_json_delta with the full input
833
+ safeEnqueue(encoder.encode(
834
+ `event: content_block_delta\ndata: ${JSON.stringify({
127
835
  type: "content_block_delta",
128
- index: 0,
129
- delta: { type: "text_delta", text: block.text }
130
- })}\n\n`))
131
- }
836
+ index: blockIndex,
837
+ delta: { type: "input_json_delta", partial_json: JSON.stringify(tu.input) }
838
+ })}\n\n`
839
+ ), "passthrough_tool_input")
840
+
841
+ // content_block_stop
842
+ safeEnqueue(encoder.encode(
843
+ `event: content_block_stop\ndata: ${JSON.stringify({
844
+ type: "content_block_stop",
845
+ index: blockIndex
846
+ })}\n\n`
847
+ ), "passthrough_tool_block_stop")
132
848
  }
849
+
850
+ // Emit message_delta with stop_reason: "tool_use"
851
+ safeEnqueue(encoder.encode(
852
+ `event: message_delta\ndata: ${JSON.stringify({
853
+ type: "message_delta",
854
+ delta: { stop_reason: "tool_use", stop_sequence: null },
855
+ usage: { output_tokens: 0 }
856
+ })}\n\n`
857
+ ), "passthrough_message_delta")
858
+ }
859
+
860
+ // Emit the final message_stop (we skipped all intermediate ones)
861
+ if (messageStartEmitted) {
862
+ safeEnqueue(encoder.encode(`event: message_stop\ndata: {"type":"message_stop"}\n\n`), "final_message_stop")
863
+ }
864
+
865
+ try { controller.close() } catch {}
866
+ streamClosed = true
867
+
868
+ claudeLog("stream.ended", {
869
+ model,
870
+ streamEventsSeen,
871
+ eventsForwarded,
872
+ textEventsForwarded,
873
+ bytesSent,
874
+ durationMs: Date.now() - requestStartAt
875
+ })
876
+
877
+ claudeLog("response.completed", {
878
+ mode: "stream",
879
+ model,
880
+ durationMs: Date.now() - requestStartAt,
881
+ streamEventsSeen,
882
+ eventsForwarded,
883
+ textEventsForwarded
884
+ })
885
+
886
+ if (textEventsForwarded === 0) {
887
+ claudeLog("response.empty_stream", {
888
+ model,
889
+ streamEventsSeen,
890
+ eventsForwarded,
891
+ reason: "no_text_deltas_forwarded"
892
+ })
133
893
  }
134
894
  }
135
- } finally {
136
- clearInterval(heartbeat)
895
+ } catch (error) {
896
+ if (isClosedControllerError(error)) {
897
+ streamClosed = true
898
+ claudeLog("stream.client_closed", {
899
+ source: "stream_catch",
900
+ streamEventsSeen,
901
+ eventsForwarded,
902
+ textEventsForwarded,
903
+ durationMs: Date.now() - requestStartAt
904
+ })
905
+ return
906
+ }
907
+
908
+ claudeLog("upstream.failed", {
909
+ mode: "stream",
910
+ model,
911
+ durationMs: Date.now() - upstreamStartAt,
912
+ streamEventsSeen,
913
+ textEventsForwarded,
914
+ error: error instanceof Error ? error.message : String(error)
915
+ })
916
+ const streamErr = classifyError(error instanceof Error ? error.message : String(error))
917
+ claudeLog("proxy.anthropic.error", { error: error instanceof Error ? error.message : String(error), classified: streamErr.type })
918
+ safeEnqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({
919
+ type: "error",
920
+ error: { type: streamErr.type, message: streamErr.message }
921
+ })}\n\n`), "error_event")
922
+ if (!streamClosed) {
923
+ try { controller.close() } catch {}
924
+ streamClosed = true
925
+ }
137
926
  }
927
+ }
928
+ })
138
929
 
139
- controller.enqueue(encoder.encode(`event: content_block_stop\ndata: ${JSON.stringify({
140
- type: "content_block_stop",
141
- index: 0
142
- })}\n\n`))
930
+ const streamSessionId = resumeSessionId || `session_${Date.now()}`
931
+ return new Response(readable, {
932
+ headers: {
933
+ "Content-Type": "text/event-stream",
934
+ "Cache-Control": "no-cache",
935
+ Connection: "keep-alive",
936
+ "X-Claude-Session-ID": streamSessionId
937
+ }
938
+ })
939
+ } catch (error) {
940
+ const errMsg = error instanceof Error ? error.message : String(error)
941
+ claudeLog("error.unhandled", {
942
+ durationMs: Date.now() - requestStartAt,
943
+ error: errMsg
944
+ })
143
945
 
144
- controller.enqueue(encoder.encode(`event: message_delta\ndata: ${JSON.stringify({
145
- type: "message_delta",
146
- delta: { stop_reason: "end_turn" },
147
- usage: { output_tokens: 0 }
148
- })}\n\n`))
946
+ // Detect specific error types and return helpful messages
947
+ const classified = classifyError(errMsg)
149
948
 
150
- controller.enqueue(encoder.encode(`event: message_stop\ndata: ${JSON.stringify({
151
- type: "message_stop"
152
- })}\n\n`))
949
+ claudeLog("proxy.error", { error: errMsg, classified: classified.type })
950
+ return new Response(
951
+ JSON.stringify({ type: "error", error: { type: classified.type, message: classified.message } }),
952
+ { status: classified.status, headers: { "Content-Type": "application/json" } }
953
+ )
954
+ }
955
+ })
956
+ }
153
957
 
154
- controller.close()
155
- } catch (error) {
156
- claudeLog("proxy.anthropic.error", { error: error instanceof Error ? error.message : String(error) })
157
- controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({
158
- type: "error",
159
- error: { type: "api_error", message: error instanceof Error ? error.message : "Unknown error" }
160
- })}\n\n`))
161
- controller.close()
162
- }
163
- }
164
- })
958
+ app.post("/v1/messages", async (c) => {
959
+ const requestId = c.req.header("x-request-id") || randomUUID()
960
+ const startedAt = Date.now()
961
+ claudeLog("request.enter", { requestId, endpoint: "/v1/messages" })
962
+ return handleMessages(c, { requestId, endpoint: "/v1/messages", queueEnteredAt: startedAt, queueStartedAt: startedAt })
963
+ })
165
964
 
166
- return new Response(readable, {
167
- headers: {
168
- "Content-Type": "text/event-stream",
169
- "Cache-Control": "no-cache",
170
- Connection: "keep-alive"
171
- }
965
+ app.post("/messages", async (c) => {
966
+ const requestId = c.req.header("x-request-id") || randomUUID()
967
+ const startedAt = Date.now()
968
+ claudeLog("request.enter", { requestId, endpoint: "/messages" })
969
+ return handleMessages(c, { requestId, endpoint: "/messages", queueEnteredAt: startedAt, queueStartedAt: startedAt })
970
+ })
971
+
972
+ // Health check endpoint — verifies auth status
973
+ app.get("/health", (c) => {
974
+ try {
975
+ const authJson = execSync("claude auth status", { encoding: "utf-8", timeout: 5000 })
976
+ const auth = JSON.parse(authJson)
977
+ if (!auth.loggedIn) {
978
+ return c.json({
979
+ status: "unhealthy",
980
+ error: "Not logged in. Run: claude login",
981
+ auth: { loggedIn: false }
982
+ }, 503)
983
+ }
984
+ return c.json({
985
+ status: "healthy",
986
+ auth: {
987
+ loggedIn: true,
988
+ email: auth.email,
989
+ subscriptionType: auth.subscriptionType,
990
+ },
991
+ mode: process.env.CLAUDE_PROXY_PASSTHROUGH ? "passthrough" : "internal",
172
992
  })
173
- } catch (error) {
174
- claudeLog("proxy.error", { error: error instanceof Error ? error.message : String(error) })
993
+ } catch {
175
994
  return c.json({
176
- type: "error",
177
- error: {
178
- type: "api_error",
179
- message: error instanceof Error ? error.message : "Unknown error"
180
- }
181
- }, 500)
995
+ status: "degraded",
996
+ error: "Could not verify auth status",
997
+ mode: process.env.CLAUDE_PROXY_PASSTHROUGH ? "passthrough" : "internal",
998
+ })
182
999
  }
183
- }
184
-
185
- app.post("/v1/messages", handleMessages)
186
- app.post("/messages", handleMessages)
1000
+ })
187
1001
 
188
1002
  return { app, config: finalConfig }
189
1003
  }
@@ -191,11 +1005,26 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
191
1005
  export async function startProxyServer(config: Partial<ProxyConfig> = {}) {
192
1006
  const { app, config: finalConfig } = createProxyServer(config)
193
1007
 
194
- const server = Bun.serve({
195
- port: finalConfig.port,
196
- hostname: finalConfig.host,
197
- fetch: app.fetch
198
- })
1008
+ let server
1009
+ try {
1010
+ server = Bun.serve({
1011
+ port: finalConfig.port,
1012
+ hostname: finalConfig.host,
1013
+ idleTimeout: finalConfig.idleTimeoutSeconds,
1014
+ fetch: app.fetch
1015
+ })
1016
+ } catch (error: unknown) {
1017
+ if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") {
1018
+ console.error(`\nError: Port ${finalConfig.port} is already in use.`)
1019
+ console.error(`\nIs another instance of the proxy already running?`)
1020
+ console.error(` Check with: lsof -i :${finalConfig.port}`)
1021
+ console.error(` Kill it with: kill $(lsof -ti :${finalConfig.port})`)
1022
+ console.error(`\nOr use a different port:`)
1023
+ console.error(` CLAUDE_PROXY_PORT=4567 bun run proxy`)
1024
+ process.exit(1)
1025
+ }
1026
+ throw error
1027
+ }
199
1028
 
200
1029
  console.log(`Claude Max Proxy (Anthropic API) running at http://${finalConfig.host}:${finalConfig.port}`)
201
1030
  console.log(`\nTo use with OpenCode, run:`)