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.
- package/README.md +272 -113
- package/bin/claude-proxy-supervisor.sh +62 -0
- package/bin/claude-proxy.ts +26 -1
- package/package.json +9 -6
- package/src/logger.ts +68 -6
- package/src/proxy/agentDefs.ts +102 -0
- package/src/proxy/agentMatch.ts +93 -0
- package/src/proxy/passthroughTools.ts +108 -0
- package/src/proxy/server.ts +947 -118
- package/src/proxy/types.ts +3 -1
package/src/logger.ts
CHANGED
|
@@ -1,10 +1,72 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
2
|
+
|
|
3
|
+
type LogFields = Record<string, unknown>
|
|
4
|
+
|
|
5
|
+
const contextStore = new AsyncLocalStorage<LogFields>()
|
|
6
|
+
|
|
1
7
|
const shouldLog = () => process.env["OPENCODE_CLAUDE_PROVIDER_DEBUG"]
|
|
8
|
+
const shouldLogStreamDebug = () => process.env["OPENCODE_CLAUDE_PROVIDER_STREAM_DEBUG"]
|
|
2
9
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
10
|
+
const isVerboseStreamEvent = (event: string): boolean => {
|
|
11
|
+
return event.startsWith("stream.") || event === "response.empty_stream"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const REDACTED_KEYS = new Set([
|
|
15
|
+
"authorization",
|
|
16
|
+
"cookie",
|
|
17
|
+
"x-api-key",
|
|
18
|
+
"apiKey",
|
|
19
|
+
"apikey",
|
|
20
|
+
"prompt",
|
|
21
|
+
"messages",
|
|
22
|
+
"content"
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
const sanitize = (value: unknown): unknown => {
|
|
26
|
+
if (value === null || value === undefined) return value
|
|
27
|
+
|
|
28
|
+
if (typeof value === "string") {
|
|
29
|
+
if (value.length > 512) {
|
|
30
|
+
return `${value.slice(0, 512)}... [truncated=${value.length}]`
|
|
31
|
+
}
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
return value.map(sanitize)
|
|
8
37
|
}
|
|
9
|
-
|
|
38
|
+
|
|
39
|
+
if (typeof value === "object") {
|
|
40
|
+
const out: Record<string, unknown> = {}
|
|
41
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
42
|
+
if (REDACTED_KEYS.has(k)) {
|
|
43
|
+
if (typeof v === "string") {
|
|
44
|
+
out[k] = `[redacted len=${v.length}]`
|
|
45
|
+
} else if (Array.isArray(v)) {
|
|
46
|
+
out[k] = `[redacted array len=${v.length}]`
|
|
47
|
+
} else {
|
|
48
|
+
out[k] = "[redacted]"
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
out[k] = sanitize(v)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return value
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const withClaudeLogContext = <T>(context: LogFields, fn: () => T): T => {
|
|
61
|
+
return contextStore.run(context, fn)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const claudeLog = (event: string, extra?: LogFields) => {
|
|
65
|
+
if (!shouldLog()) return
|
|
66
|
+
if (isVerboseStreamEvent(event) && !shouldLogStreamDebug()) return
|
|
67
|
+
|
|
68
|
+
const context = contextStore.getStore() || {}
|
|
69
|
+
const payload = sanitize({ ts: new Date().toISOString(), event, ...context, ...(extra || {}) })
|
|
70
|
+
|
|
71
|
+
console.debug(`[opencode-claude-code-provider] ${JSON.stringify(payload)}`)
|
|
10
72
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract SDK AgentDefinition objects from OpenCode's Task tool description.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode (via oh-my-opencode or other frameworks) sends a Task tool with
|
|
5
|
+
* descriptions of each available agent. We parse these and convert them into
|
|
6
|
+
* Claude Agent SDK `AgentDefinition` objects so the SDK's native Task handler
|
|
7
|
+
* routes to properly-configured subagents.
|
|
8
|
+
*
|
|
9
|
+
* This means whatever agents the user configures in their framework
|
|
10
|
+
* automatically become available as SDK subagents — with descriptions,
|
|
11
|
+
* model tiers, and tool access.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** SDK-compatible agent definition */
|
|
15
|
+
export interface AgentDefinition {
|
|
16
|
+
description: string
|
|
17
|
+
prompt: string
|
|
18
|
+
model?: "sonnet" | "opus" | "haiku" | "inherit"
|
|
19
|
+
tools?: string[]
|
|
20
|
+
disallowedTools?: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse agent entries from the Task tool description text.
|
|
25
|
+
*
|
|
26
|
+
* Expected format (from OpenCode):
|
|
27
|
+
* - agent-name: Description of what the agent does
|
|
28
|
+
*
|
|
29
|
+
* @returns Map of agent name → description
|
|
30
|
+
*/
|
|
31
|
+
export function parseAgentDescriptions(taskDescription: string): Map<string, string> {
|
|
32
|
+
const agents = new Map<string, string>()
|
|
33
|
+
|
|
34
|
+
const agentSection = taskDescription.match(
|
|
35
|
+
/Available agent types.*?:\n((?:- [\w][\w-]*:.*\n?)+)/s
|
|
36
|
+
)
|
|
37
|
+
if (!agentSection) return agents
|
|
38
|
+
|
|
39
|
+
const entries = agentSection[1]!.matchAll(/^- ([\w][\w-]*):\s*(.+)/gm)
|
|
40
|
+
for (const match of entries) {
|
|
41
|
+
agents.set(match[1]!, match[2]!.trim())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return agents
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Map an OpenCode model string to an SDK model tier.
|
|
49
|
+
*
|
|
50
|
+
* The SDK only accepts 'sonnet' | 'opus' | 'haiku' | 'inherit'.
|
|
51
|
+
* We map based on the model name pattern, defaulting to 'inherit'
|
|
52
|
+
* for non-Anthropic models (they'll use the parent session's model).
|
|
53
|
+
*/
|
|
54
|
+
export function mapModelTier(model?: string): "sonnet" | "opus" | "haiku" | "inherit" {
|
|
55
|
+
if (!model) return "inherit"
|
|
56
|
+
const lower = model.toLowerCase()
|
|
57
|
+
if (lower.includes("opus")) return "opus"
|
|
58
|
+
if (lower.includes("haiku")) return "haiku"
|
|
59
|
+
if (lower.includes("sonnet")) return "sonnet"
|
|
60
|
+
return "inherit"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build SDK AgentDefinition objects from the Task tool description.
|
|
65
|
+
*
|
|
66
|
+
* Each agent gets:
|
|
67
|
+
* - description: from the Task tool text (user-configured)
|
|
68
|
+
* - prompt: instructional prompt incorporating the description
|
|
69
|
+
* - model: 'inherit' (uses parent session model — all requests go through our proxy)
|
|
70
|
+
* - tools: undefined (inherit all tools from parent)
|
|
71
|
+
*
|
|
72
|
+
* @param taskDescription - The full Task tool description text from OpenCode
|
|
73
|
+
* @param mcpToolNames - Optional list of MCP tool names to make available to agents
|
|
74
|
+
*/
|
|
75
|
+
export function buildAgentDefinitions(
|
|
76
|
+
taskDescription: string,
|
|
77
|
+
mcpToolNames?: string[]
|
|
78
|
+
): Record<string, AgentDefinition> {
|
|
79
|
+
const descriptions = parseAgentDescriptions(taskDescription)
|
|
80
|
+
const agents: Record<string, AgentDefinition> = {}
|
|
81
|
+
|
|
82
|
+
for (const [name, description] of descriptions) {
|
|
83
|
+
agents[name] = {
|
|
84
|
+
description,
|
|
85
|
+
prompt: buildAgentPrompt(name, description),
|
|
86
|
+
model: "inherit",
|
|
87
|
+
// Give agents access to MCP tools if provided
|
|
88
|
+
...(mcpToolNames?.length ? { tools: [...mcpToolNames] } : {}),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return agents
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build a system prompt for an agent based on its name and description.
|
|
97
|
+
*/
|
|
98
|
+
function buildAgentPrompt(name: string, description: string): string {
|
|
99
|
+
return `You are the "${name}" agent. ${description}
|
|
100
|
+
|
|
101
|
+
Focus on your specific role and complete the task thoroughly. Return a clear, concise result.`
|
|
102
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzzy matching for agent names.
|
|
3
|
+
*
|
|
4
|
+
* When Claude sends an invalid subagent_type, this tries to map it
|
|
5
|
+
* to the closest valid agent name. This is deterministic string matching,
|
|
6
|
+
* not LLM guessing.
|
|
7
|
+
*
|
|
8
|
+
* Matching priority:
|
|
9
|
+
* 1. Exact match (case-insensitive)
|
|
10
|
+
* 2. Known aliases (e.g., "general-purpose" → "general")
|
|
11
|
+
* 3. Prefix match (e.g., "lib" → "librarian")
|
|
12
|
+
* 4. Substring match (e.g., "junior" → "sisyphus-junior")
|
|
13
|
+
* 5. Suffix-stripped match (e.g., "explore-agent" → "explore")
|
|
14
|
+
* 6. Semantic aliases (e.g., "search" → "explore")
|
|
15
|
+
* 7. Fallback: return lowercased original
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Known aliases for common SDK mistakes
|
|
19
|
+
const KNOWN_ALIASES: Record<string, string> = {
|
|
20
|
+
"general-purpose": "general",
|
|
21
|
+
"default": "general",
|
|
22
|
+
"code-reviewer": "oracle",
|
|
23
|
+
"reviewer": "oracle",
|
|
24
|
+
"code-review": "oracle",
|
|
25
|
+
"review": "oracle",
|
|
26
|
+
"consultation": "oracle",
|
|
27
|
+
"analyzer": "oracle",
|
|
28
|
+
"debugger": "oracle",
|
|
29
|
+
"search": "explore",
|
|
30
|
+
"grep": "explore",
|
|
31
|
+
"find": "explore",
|
|
32
|
+
"codebase-search": "explore",
|
|
33
|
+
"research": "librarian",
|
|
34
|
+
"docs": "librarian",
|
|
35
|
+
"documentation": "librarian",
|
|
36
|
+
"lookup": "librarian",
|
|
37
|
+
"reference": "librarian",
|
|
38
|
+
"consult": "oracle",
|
|
39
|
+
"architect": "oracle",
|
|
40
|
+
"image-analyzer": "multimodal-looker",
|
|
41
|
+
"image": "multimodal-looker",
|
|
42
|
+
"pdf": "multimodal-looker",
|
|
43
|
+
"visual": "multimodal-looker",
|
|
44
|
+
"planner": "plan",
|
|
45
|
+
"planning": "plan",
|
|
46
|
+
"builder": "build",
|
|
47
|
+
"coder": "build",
|
|
48
|
+
"developer": "build",
|
|
49
|
+
"writer": "build",
|
|
50
|
+
"executor": "build",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Common suffixes to strip
|
|
54
|
+
const STRIP_SUFFIXES = ["-agent", "-tool", "-worker", "-task", " agent", " tool"]
|
|
55
|
+
|
|
56
|
+
export function fuzzyMatchAgentName(input: string, validAgents: string[]): string {
|
|
57
|
+
if (!input) return input
|
|
58
|
+
if (validAgents.length === 0) return input.toLowerCase()
|
|
59
|
+
|
|
60
|
+
const lowered = input.toLowerCase()
|
|
61
|
+
|
|
62
|
+
// 1. Exact match (case-insensitive)
|
|
63
|
+
const exact = validAgents.find(a => a.toLowerCase() === lowered)
|
|
64
|
+
if (exact) return exact
|
|
65
|
+
|
|
66
|
+
// 2. Known aliases
|
|
67
|
+
const alias = KNOWN_ALIASES[lowered]
|
|
68
|
+
if (alias && validAgents.includes(alias)) return alias
|
|
69
|
+
|
|
70
|
+
// 3. Prefix match
|
|
71
|
+
const prefixMatch = validAgents.find(a => a.toLowerCase().startsWith(lowered))
|
|
72
|
+
if (prefixMatch) return prefixMatch
|
|
73
|
+
|
|
74
|
+
// 4. Substring match
|
|
75
|
+
const substringMatch = validAgents.find(a => a.toLowerCase().includes(lowered))
|
|
76
|
+
if (substringMatch) return substringMatch
|
|
77
|
+
|
|
78
|
+
// 5. Suffix-stripped match
|
|
79
|
+
for (const suffix of STRIP_SUFFIXES) {
|
|
80
|
+
if (lowered.endsWith(suffix)) {
|
|
81
|
+
const stripped = lowered.slice(0, -suffix.length)
|
|
82
|
+
const strippedMatch = validAgents.find(a => a.toLowerCase() === stripped)
|
|
83
|
+
if (strippedMatch) return strippedMatch
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 6. Reverse substring (input contains a valid agent name)
|
|
88
|
+
const reverseMatch = validAgents.find(a => lowered.includes(a.toLowerCase()))
|
|
89
|
+
if (reverseMatch) return reverseMatch
|
|
90
|
+
|
|
91
|
+
// 7. Fallback
|
|
92
|
+
return lowered
|
|
93
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic MCP tool registration for passthrough mode.
|
|
3
|
+
*
|
|
4
|
+
* In passthrough mode, OpenCode's tools need to be real callable tools
|
|
5
|
+
* (not just text descriptions in the prompt). We create an MCP server
|
|
6
|
+
* that registers each tool from OpenCode's request with the exact
|
|
7
|
+
* name and schema, so Claude generates proper tool_use blocks.
|
|
8
|
+
*
|
|
9
|
+
* Tool handlers are no-ops — the PreToolUse hook blocks execution.
|
|
10
|
+
* We just need the definitions so Claude can call them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk"
|
|
14
|
+
import { z } from "zod"
|
|
15
|
+
|
|
16
|
+
export const PASSTHROUGH_MCP_NAME = "oc"
|
|
17
|
+
export const PASSTHROUGH_MCP_PREFIX = `mcp__${PASSTHROUGH_MCP_NAME}__`
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert a JSON Schema object to a Zod schema (simplified).
|
|
21
|
+
* Handles the common types OpenCode sends. Falls back to z.any() for complex types.
|
|
22
|
+
*/
|
|
23
|
+
function jsonSchemaToZod(schema: any): z.ZodTypeAny {
|
|
24
|
+
if (!schema || typeof schema !== "object") return z.any()
|
|
25
|
+
|
|
26
|
+
if (schema.type === "string") {
|
|
27
|
+
let s = z.string()
|
|
28
|
+
if (schema.description) s = s.describe(schema.description)
|
|
29
|
+
if (schema.enum) return z.enum(schema.enum as [string, ...string[]])
|
|
30
|
+
return s
|
|
31
|
+
}
|
|
32
|
+
if (schema.type === "number" || schema.type === "integer") {
|
|
33
|
+
let n = z.number()
|
|
34
|
+
if (schema.description) n = n.describe(schema.description)
|
|
35
|
+
return n
|
|
36
|
+
}
|
|
37
|
+
if (schema.type === "boolean") return z.boolean()
|
|
38
|
+
if (schema.type === "array") {
|
|
39
|
+
const items = schema.items ? jsonSchemaToZod(schema.items) : z.any()
|
|
40
|
+
return z.array(items)
|
|
41
|
+
}
|
|
42
|
+
if (schema.type === "object" && schema.properties) {
|
|
43
|
+
const shape: Record<string, z.ZodTypeAny> = {}
|
|
44
|
+
const required = new Set(schema.required || [])
|
|
45
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
46
|
+
const zodProp = jsonSchemaToZod(propSchema as any)
|
|
47
|
+
shape[key] = required.has(key) ? zodProp : zodProp.optional()
|
|
48
|
+
}
|
|
49
|
+
return z.object(shape)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return z.any()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create an MCP server with tool definitions matching OpenCode's request.
|
|
57
|
+
*/
|
|
58
|
+
export function createPassthroughMcpServer(
|
|
59
|
+
tools: Array<{ name: string; description?: string; input_schema?: any }>
|
|
60
|
+
) {
|
|
61
|
+
const server = createSdkMcpServer({ name: PASSTHROUGH_MCP_NAME })
|
|
62
|
+
const toolNames: string[] = []
|
|
63
|
+
|
|
64
|
+
for (const tool of tools) {
|
|
65
|
+
try {
|
|
66
|
+
// Convert OpenCode's JSON Schema to Zod for MCP registration
|
|
67
|
+
const zodSchema = tool.input_schema?.properties
|
|
68
|
+
? jsonSchemaToZod(tool.input_schema)
|
|
69
|
+
: z.object({})
|
|
70
|
+
|
|
71
|
+
// The raw shape for the tool() call needs to be a record of Zod types
|
|
72
|
+
const shape: Record<string, z.ZodTypeAny> =
|
|
73
|
+
zodSchema instanceof z.ZodObject
|
|
74
|
+
? (zodSchema as any).shape
|
|
75
|
+
: { input: z.any() }
|
|
76
|
+
|
|
77
|
+
server.instance.tool(
|
|
78
|
+
tool.name,
|
|
79
|
+
tool.description || tool.name,
|
|
80
|
+
shape,
|
|
81
|
+
async () => ({ content: [{ type: "text" as const, text: "passthrough" }] })
|
|
82
|
+
)
|
|
83
|
+
toolNames.push(`${PASSTHROUGH_MCP_PREFIX}${tool.name}`)
|
|
84
|
+
} catch {
|
|
85
|
+
// If schema conversion fails, register with permissive schema
|
|
86
|
+
server.instance.tool(
|
|
87
|
+
tool.name,
|
|
88
|
+
tool.description || tool.name,
|
|
89
|
+
{ input: z.string().optional() },
|
|
90
|
+
async () => ({ content: [{ type: "text" as const, text: "passthrough" }] })
|
|
91
|
+
)
|
|
92
|
+
toolNames.push(`${PASSTHROUGH_MCP_PREFIX}${tool.name}`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { server, toolNames }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Strip the MCP prefix from a tool name to get the OpenCode tool name.
|
|
101
|
+
* e.g., "mcp__oc__todowrite" → "todowrite"
|
|
102
|
+
*/
|
|
103
|
+
export function stripMcpPrefix(toolName: string): string {
|
|
104
|
+
if (toolName.startsWith(PASSTHROUGH_MCP_PREFIX)) {
|
|
105
|
+
return toolName.slice(PASSTHROUGH_MCP_PREFIX.length)
|
|
106
|
+
}
|
|
107
|
+
return toolName
|
|
108
|
+
}
|