import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { spawn } from "node:child_process"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; export type ClaudeMessage = | { type: "text"; text: string } | { type: "done"; sessionId: string } | { type: "error"; message: string }; export type OpenAITool = { type: "function"; function: { name: string; description?: string; parameters?: unknown }; }; export type ClaudeDispatchOptions = { prompt: string; /** Appended to Claude Code's built-in system prompt via --append-system-prompt on every invocation. * Stateless: not persisted in session file, fully replaces any prior appended content on resume. */ systemPrompt?: string; workspace: string; agentId?: string; resumeSessionId?: string; permissionMode?: string; /** OpenClaw tool definitions to expose to Claude as MCP tools */ openclawTools?: OpenAITool[]; /** Bridge port for MCP proxy callbacks */ bridgePort?: number; /** Bridge API key for MCP proxy callbacks */ bridgeApiKey?: string; }; // Resolve the MCP server script path relative to this file. // Installed layout: plugin root / core / claude / sdk-adapter.ts // plugin root / services / openclaw-mcp-server.mjs const __dirname = path.dirname(fileURLToPath(import.meta.url)); const MCP_SERVER_SCRIPT = path.resolve(__dirname, "../../services/openclaw-mcp-server.mjs"); /** * Write OpenClaw tool definitions to a temp file and create an --mcp-config JSON * so Claude Code can call them as `mcp__openclaw__` tools. * * Returns the path to the mcp-config JSON file, or null if setup fails. */ function setupMcpConfig( tools: OpenAITool[], bridgePort: number, bridgeApiKey: string, workspace: string, agentId: string, ): string | null { if (!tools.length) return null; if (!fs.existsSync(MCP_SERVER_SCRIPT)) return null; try { const tmpDir = os.tmpdir(); const sessionId = `oc-${Date.now()}`; const toolDefsPath = path.join(tmpDir, `${sessionId}-tools.json`); const mcpConfigPath = path.join(tmpDir, `${sessionId}-mcp.json`); fs.writeFileSync(toolDefsPath, JSON.stringify(tools, null, 2), "utf8"); const mcpConfig = { mcpServers: { openclaw: { command: process.execPath, args: [MCP_SERVER_SCRIPT], env: { TOOL_DEFS_FILE: toolDefsPath, BRIDGE_EXECUTE_URL: `http://127.0.0.1:${bridgePort}/mcp/execute`, BRIDGE_API_KEY: bridgeApiKey, WORKSPACE: workspace, AGENT_ID: agentId, }, }, }, }; fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), "utf8"); return mcpConfigPath; } catch { return null; } } /** * Dispatch a turn to Claude Code using `claude -p --output-format stream-json --verbose`. * Returns an async iterable of ClaudeMessage events. */ export async function* dispatchToClaude( opts: ClaudeDispatchOptions, ): AsyncIterable { const { prompt, systemPrompt, workspace, agentId = "", resumeSessionId, permissionMode = "bypassPermissions", openclawTools, bridgePort = 18800, bridgeApiKey = "", } = opts; // NOTE: put prompt right after -p, before --mcp-config. // --mcp-config takes (multiple values) and would greedily // consume the prompt if it came after --mcp-config. const args: string[] = [ "-p", prompt, "--output-format", "stream-json", "--verbose", "--permission-mode", permissionMode, "--dangerously-skip-permissions", ]; // --append-system-prompt appends to Claude Code's built-in system prompt rather // than replacing it, preserving the full agent SDK instructions (tool use behavior, // memory management, etc.). The appended bootstrap (persona + skills) is stateless: // not persisted in the session file, takes effect every invocation including resumes. if (systemPrompt) { args.push("--append-system-prompt", systemPrompt); } if (resumeSessionId) { args.push("--resume", resumeSessionId); } // Set up MCP proxy every turn — the MCP server process exits with each `claude -p` // invocation, so --resume sessions also need --mcp-config to restart it. // Put --mcp-config after the prompt so its variadic doesn't consume the prompt. let mcpConfigPath: string | null = null; if (openclawTools?.length) { mcpConfigPath = setupMcpConfig(openclawTools, bridgePort, bridgeApiKey, workspace, agentId); if (mcpConfigPath) { args.push("--mcp-config", mcpConfigPath); } } const child = spawn("claude", args, { cwd: workspace, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env }, }); const stderrLines: string[] = []; child.stderr?.on("data", (chunk: Buffer) => { stderrLines.push(chunk.toString("utf8").trim()); }); const rl = createInterface({ input: child.stdout!, crlfDelay: Infinity }); let capturedSessionId = ""; const events: ClaudeMessage[] = []; let done = false; let resolveNext: (() => void) | null = null; rl.on("line", (line: string) => { if (!line.trim()) return; let event: Record; try { event = JSON.parse(line); } catch { return; } const type = event.type as string; if (type === "assistant") { const msg = event.message as { content?: Array<{ type: string; text?: string }> }; for (const block of msg?.content ?? []) { if (block.type === "text" && block.text) { events.push({ type: "text", text: block.text }); } } } if (type === "result") { const sessionId = (event.session_id as string) ?? ""; if (sessionId) capturedSessionId = sessionId; } if (resolveNext) { const r = resolveNext; resolveNext = null; r(); } }); rl.on("close", () => { done = true; if (resolveNext) { const r = resolveNext; resolveNext = null; r(); } }); while (true) { if (events.length > 0) { yield events.shift()!; continue; } if (done) break; await new Promise((resolve) => { resolveNext = resolve; }); } while (events.length > 0) { yield events.shift()!; } await new Promise((resolve) => { child.on("close", resolve); if (child.exitCode !== null) resolve(); }); // Clean up temp files if (mcpConfigPath) { try { fs.unlinkSync(mcpConfigPath); } catch { /* ignore */ } // tool defs file path is embedded in the config — leave it for now } if (capturedSessionId) { yield { type: "done", sessionId: capturedSessionId }; } else { const stderrSummary = stderrLines.join(" ").slice(0, 200); yield { type: "error", message: `claude did not return a session_id${stderrSummary ? `: ${stderrSummary}` : ""}`, }; } }