opencode-claude-max-proxy 1.10.1 → 1.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-max-proxy",
3
- "version": "1.10.1",
3
+ "version": "1.11.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",
@@ -32,7 +32,9 @@
32
32
  "bin/",
33
33
  "src/proxy/",
34
34
  "src/logger.ts",
35
- "README.md"
35
+ "src/mcpTools.ts",
36
+ "README.md",
37
+ "src/plugin/"
36
38
  ],
37
39
  "keywords": [
38
40
  "opencode",
@@ -0,0 +1,185 @@
1
+ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk"
2
+ import { z } from "zod"
3
+ import * as fs from "node:fs/promises"
4
+ import * as path from "node:path"
5
+ import { exec } from "node:child_process"
6
+ import { promisify } from "node:util"
7
+ import { glob as globLib } from "glob"
8
+
9
+ const execAsync = promisify(exec)
10
+
11
+ const getCwd = () => process.env.CLAUDE_PROXY_WORKDIR || process.cwd()
12
+
13
+ export const opencodeMcpServer = createSdkMcpServer({
14
+ name: "opencode",
15
+ version: "1.0.0",
16
+ tools: [
17
+ tool(
18
+ "read",
19
+ "Read the contents of a file at the specified path",
20
+ {
21
+ path: z.string().describe("Absolute or relative path to the file"),
22
+ encoding: z.string().optional().describe("File encoding, defaults to utf-8")
23
+ },
24
+ async (args) => {
25
+ try {
26
+ const filePath = path.isAbsolute(args.path)
27
+ ? args.path
28
+ : path.resolve(getCwd(), args.path)
29
+ const content = await fs.readFile(filePath, (args.encoding || "utf-8") as BufferEncoding)
30
+ return {
31
+ content: [{ type: "text", text: content }]
32
+ }
33
+ } catch (error) {
34
+ return {
35
+ content: [{ type: "text", text: `Error reading file: ${error instanceof Error ? error.message : String(error)}` }],
36
+ isError: true
37
+ }
38
+ }
39
+ }
40
+ ),
41
+
42
+ tool(
43
+ "write",
44
+ "Write content to a file, creating directories if needed",
45
+ {
46
+ path: z.string().describe("Path to write to"),
47
+ content: z.string().describe("Content to write")
48
+ },
49
+ async (args) => {
50
+ try {
51
+ const filePath = path.isAbsolute(args.path)
52
+ ? args.path
53
+ : path.resolve(getCwd(), args.path)
54
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
55
+ await fs.writeFile(filePath, args.content, "utf-8")
56
+ return {
57
+ content: [{ type: "text", text: `Successfully wrote to ${args.path}` }]
58
+ }
59
+ } catch (error) {
60
+ return {
61
+ content: [{ type: "text", text: `Error writing file: ${error instanceof Error ? error.message : String(error)}` }],
62
+ isError: true
63
+ }
64
+ }
65
+ }
66
+ ),
67
+
68
+ tool(
69
+ "edit",
70
+ "Edit a file by replacing oldString with newString",
71
+ {
72
+ path: z.string().describe("Path to the file to edit"),
73
+ oldString: z.string().describe("The text to replace"),
74
+ newString: z.string().describe("The replacement text")
75
+ },
76
+ async (args) => {
77
+ try {
78
+ const filePath = path.isAbsolute(args.path)
79
+ ? args.path
80
+ : path.resolve(getCwd(), args.path)
81
+ const content = await fs.readFile(filePath, "utf-8")
82
+ if (!content.includes(args.oldString)) {
83
+ return {
84
+ content: [{ type: "text", text: `Error: oldString not found in file` }],
85
+ isError: true
86
+ }
87
+ }
88
+ const newContent = content.replace(args.oldString, args.newString)
89
+ await fs.writeFile(filePath, newContent, "utf-8")
90
+ return {
91
+ content: [{ type: "text", text: `Successfully edited ${args.path}` }]
92
+ }
93
+ } catch (error) {
94
+ return {
95
+ content: [{ type: "text", text: `Error editing file: ${error instanceof Error ? error.message : String(error)}` }],
96
+ isError: true
97
+ }
98
+ }
99
+ }
100
+ ),
101
+
102
+ tool(
103
+ "bash",
104
+ "Execute a bash command and return the output",
105
+ {
106
+ command: z.string().describe("The command to execute"),
107
+ cwd: z.string().optional().describe("Working directory for the command")
108
+ },
109
+ async (args) => {
110
+ try {
111
+ const options = {
112
+ cwd: args.cwd || getCwd(),
113
+ timeout: 120000
114
+ }
115
+ const { stdout, stderr } = await execAsync(args.command, options)
116
+ const output = stdout || stderr || "(no output)"
117
+ return {
118
+ content: [{ type: "text", text: output }]
119
+ }
120
+ } catch (error: unknown) {
121
+ const execError = error as { stdout?: string; stderr?: string; message?: string }
122
+ const output = execError.stdout || execError.stderr || execError.message || String(error)
123
+ return {
124
+ content: [{ type: "text", text: output }],
125
+ isError: true
126
+ }
127
+ }
128
+ }
129
+ ),
130
+
131
+ tool(
132
+ "glob",
133
+ "Find files matching a glob pattern",
134
+ {
135
+ pattern: z.string().describe("Glob pattern like **/*.ts"),
136
+ cwd: z.string().optional().describe("Base directory for the search")
137
+ },
138
+ async (args) => {
139
+ try {
140
+ const files = await globLib(args.pattern, {
141
+ cwd: args.cwd || getCwd(),
142
+ nodir: true,
143
+ ignore: ["**/node_modules/**", "**/.git/**"]
144
+ })
145
+ return {
146
+ content: [{ type: "text", text: files.join("\n") || "(no matches)" }]
147
+ }
148
+ } catch (error) {
149
+ return {
150
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
151
+ isError: true
152
+ }
153
+ }
154
+ }
155
+ ),
156
+
157
+ tool(
158
+ "grep",
159
+ "Search for a pattern in files",
160
+ {
161
+ pattern: z.string().describe("Regex pattern to search for"),
162
+ path: z.string().optional().describe("Directory or file to search in"),
163
+ include: z.string().optional().describe("File pattern to include, e.g., *.ts")
164
+ },
165
+ async (args) => {
166
+ try {
167
+ const searchPath = args.path || getCwd()
168
+ const includePattern = args.include || "*"
169
+
170
+ let cmd = `grep -rn --include="${includePattern}" "${args.pattern}" "${searchPath}" 2>/dev/null || true`
171
+ const { stdout } = await execAsync(cmd, { maxBuffer: 10 * 1024 * 1024 })
172
+
173
+ return {
174
+ content: [{ type: "text", text: stdout || "(no matches)" }]
175
+ }
176
+ } catch (error) {
177
+ return {
178
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
179
+ isError: true
180
+ }
181
+ }
182
+ }
183
+ )
184
+ ]
185
+ })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * OpenCode plugin that injects session tracking headers into Anthropic API requests.
3
+ *
4
+ * This enables the claude-max-proxy to reliably track sessions and resume
5
+ * Claude Agent SDK conversations instead of starting fresh every time.
6
+ *
7
+ * Installation:
8
+ * Add to your opencode.json:
9
+ * { "plugin": ["./path/to/claude-max-headers.ts"] }
10
+ *
11
+ * Or copy to your project's .opencode/plugin/ directory.
12
+ *
13
+ * What it does:
14
+ * Adds x-opencode-session and x-opencode-request headers to requests
15
+ * sent to the Anthropic provider. The proxy uses these to map OpenCode
16
+ * sessions to Claude SDK sessions for conversation resumption.
17
+ *
18
+ * Without this plugin:
19
+ * The proxy falls back to fingerprint-based session matching (hashing
20
+ * the first user message). This works but is less reliable.
21
+ */
22
+
23
+ type ChatHeadersHook = (
24
+ incoming: {
25
+ sessionID: string
26
+ agent: any
27
+ model: { providerID: string }
28
+ provider: any
29
+ message: { id: string }
30
+ },
31
+ output: { headers: Record<string, string> }
32
+ ) => Promise<void>
33
+
34
+ type PluginHooks = {
35
+ "chat.headers"?: ChatHeadersHook
36
+ }
37
+
38
+ type PluginFn = (input: any) => Promise<PluginHooks>
39
+
40
+ export const ClaudeMaxHeadersPlugin: PluginFn = async (_input) => {
41
+ return {
42
+ "chat.headers": async (incoming, output) => {
43
+ // Only inject headers for Anthropic provider requests
44
+ if (incoming.model.providerID !== "anthropic") return
45
+
46
+ output.headers["x-opencode-session"] = incoming.sessionID
47
+ output.headers["x-opencode-request"] = incoming.message.id
48
+ },
49
+ }
50
+ }
51
+
52
+ export default ClaudeMaxHeadersPlugin
@@ -415,34 +415,118 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
415
415
 
416
416
 
417
417
 
418
- // When resuming, only send the last user message (SDK already has history)
419
- const messagesToConvert = isResume
420
- ? getLastUserMessage(body.messages || [])
421
- : body.messages
422
-
423
- // Convert messages to a text prompt, preserving all content types
424
- const conversationParts = messagesToConvert
425
- ?.map((m: { role: string; content: string | Array<{ type: string; text?: string; content?: string; tool_use_id?: string; name?: string; input?: unknown; id?: string }> }) => {
426
- const role = m.role === "assistant" ? "Assistant" : "Human"
427
- let content: string
418
+ // When resuming, only send new messages the SDK doesn't have.
419
+ const allMessages = body.messages || []
420
+ let messagesToConvert: typeof allMessages
421
+
422
+ if (isResume && cachedSession) {
423
+ const knownCount = cachedSession.messageCount || 0
424
+ if (knownCount > 0 && knownCount < allMessages.length) {
425
+ messagesToConvert = allMessages.slice(knownCount)
426
+ } else {
427
+ messagesToConvert = getLastUserMessage(allMessages)
428
+ }
429
+ } else {
430
+ messagesToConvert = allMessages
431
+ }
432
+
433
+ // Check if any messages contain multimodal content (images, documents, files)
434
+ const MULTIMODAL_TYPES = new Set(["image", "document", "file"])
435
+ const hasMultimodal = messagesToConvert?.some((m: any) =>
436
+ Array.isArray(m.content) && m.content.some((b: any) => MULTIMODAL_TYPES.has(b.type))
437
+ )
438
+
439
+ // Strip cache_control from content blocks — the SDK manages its own caching
440
+ // and OpenCode's ttl='1h' blocks conflict with the SDK's ttl='5m' blocks
441
+ function stripCacheControl(content: any): any {
442
+ if (!Array.isArray(content)) return content
443
+ return content.map((block: any) => {
444
+ if (block.cache_control) {
445
+ const { cache_control, ...rest } = block
446
+ return rest
447
+ }
448
+ return block
449
+ })
450
+ }
451
+
452
+ // Build the prompt — either structured (multimodal) or text
453
+ let prompt: string | AsyncIterable<any>
454
+
455
+ if (hasMultimodal) {
456
+ // Structured messages preserve image/document/file blocks for Claude to see.
457
+ // The SDK only accepts role:"user" in SDKUserMessage, so assistant messages
458
+ // are converted to text summaries wrapped as user messages.
459
+ const structured = messagesToConvert.map((m: any) => {
460
+ if (m.role === "user") {
461
+ return {
462
+ type: "user" as const,
463
+ message: { role: "user" as const, content: stripCacheControl(m.content) },
464
+ parent_tool_use_id: null,
465
+ }
466
+ }
467
+ // Convert assistant/tool messages to text summary
468
+ let text: string
428
469
  if (typeof m.content === "string") {
429
- content = m.content
470
+ text = `[Assistant: ${m.content}]`
430
471
  } else if (Array.isArray(m.content)) {
431
- content = m.content
432
- .map((block: any) => {
433
- if (block.type === "text" && block.text) return block.text
434
- if (block.type === "tool_use") return `[Tool Use: ${block.name}(${JSON.stringify(block.input)})]`
435
- if (block.type === "tool_result") return `[Tool Result for ${block.tool_use_id}: ${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}]`
436
- return ""
437
- })
438
- .filter(Boolean)
439
- .join("\n")
472
+ text = m.content.map((b: any) => {
473
+ if (b.type === "text" && b.text) return `[Assistant: ${b.text}]`
474
+ if (b.type === "tool_use") return `[Tool Use: ${b.name}(${JSON.stringify(b.input)})]`
475
+ if (b.type === "tool_result") return `[Tool Result: ${typeof b.content === "string" ? b.content : JSON.stringify(b.content)}]`
476
+ return ""
477
+ }).filter(Boolean).join("\n")
440
478
  } else {
441
- content = String(m.content)
479
+ text = `[Assistant: ${String(m.content)}]`
480
+ }
481
+ return {
482
+ type: "user" as const,
483
+ message: { role: "user" as const, content: text },
484
+ parent_tool_use_id: null,
442
485
  }
443
- return `${role}: ${content}`
444
486
  })
445
- .join("\n\n") || ""
487
+
488
+ // Prepend system context as a text message
489
+ if (systemContext) {
490
+ structured.unshift({
491
+ type: "user" as const,
492
+ message: { role: "user", content: systemContext },
493
+ parent_tool_use_id: null,
494
+ })
495
+ }
496
+
497
+ prompt = (async function* () { for (const msg of structured) yield msg })()
498
+ } else {
499
+ // Text prompt — convert messages to string
500
+ const conversationParts = messagesToConvert
501
+ ?.map((m: { role: string; content: string | Array<{ type: string; text?: string; content?: string; tool_use_id?: string; name?: string; input?: unknown; id?: string }> }) => {
502
+ const role = m.role === "assistant" ? "Assistant" : "Human"
503
+ let content: string
504
+ if (typeof m.content === "string") {
505
+ content = m.content
506
+ } else if (Array.isArray(m.content)) {
507
+ content = m.content
508
+ .map((block: any) => {
509
+ if (block.type === "text" && block.text) return block.text
510
+ if (block.type === "tool_use") return `[Tool Use: ${block.name}(${JSON.stringify(block.input)})]`
511
+ if (block.type === "tool_result") return `[Tool Result for ${block.tool_use_id}: ${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}]`
512
+ if (block.type === "image") return "[Image attached]"
513
+ if (block.type === "document") return "[Document attached]"
514
+ if (block.type === "file") return "[File attached]"
515
+ return ""
516
+ })
517
+ .filter(Boolean)
518
+ .join("\n")
519
+ } else {
520
+ content = String(m.content)
521
+ }
522
+ return `${role}: ${content}`
523
+ })
524
+ .join("\n\n") || ""
525
+
526
+ prompt = systemContext
527
+ ? `${systemContext}\n\n${conversationParts}`
528
+ : conversationParts
529
+ }
446
530
 
447
531
  // --- Passthrough mode ---
448
532
  // When enabled, ALL tool execution is forwarded to OpenCode instead of
@@ -499,10 +583,7 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
499
583
  }
500
584
  : undefined
501
585
 
502
- // Combine system context with conversation
503
- const prompt = systemContext
504
- ? `${systemContext}\n\n${conversationParts}`
505
- : conversationParts
586
+
506
587
 
507
588
  if (!stream) {
508
589
  const contentBlocks: Array<Record<string, unknown>> = []
@@ -1034,6 +1115,12 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
1034
1115
  }
1035
1116
  })
1036
1117
 
1118
+ // Catch-all: log unhandled requests
1119
+ app.all("*", (c) => {
1120
+ console.error(`[PROXY] UNHANDLED ${c.req.method} ${c.req.url}`)
1121
+ return c.json({ error: { type: "not_found", message: `Endpoint not supported: ${c.req.method} ${new URL(c.req.url).pathname}` } }, 404)
1122
+ })
1123
+
1037
1124
  return { app, config: finalConfig }
1038
1125
  }
1039
1126