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"; import type { ClaudeMessage, OpenAITool } from "../claude/sdk-adapter.js"; // Resolve the MCP server script path relative to this file. // Installed layout: plugin root / core / gemini / 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"); export type GeminiDispatchOptions = { prompt: string; /** * System-level instructions written to workspace/GEMINI.md before each invocation. * Gemini CLI reads GEMINI.md from cwd on every turn (including resumes) — it is NOT * stored in the session file — so updating it takes effect immediately. */ systemPrompt?: string; workspace: string; agentId?: string; resumeSessionId?: string; /** Gemini model override (e.g. "gemini-2.5-flash"). Defaults to gemini-cli's configured default. */ model?: string; /** OpenClaw tool definitions to expose via MCP */ openclawTools?: OpenAITool[]; bridgePort?: number; bridgeApiKey?: string; }; /** * Write the OpenClaw MCP server config to the workspace's .gemini/settings.json. * * Unlike Claude's --mcp-config flag, Gemini CLI reads MCP configuration from the * project settings file (.gemini/settings.json in the cwd). We write it before * each invocation to keep the tool list in sync with the current turn's tools. * * The file is merged with any existing settings to preserve user/project config. */ function setupGeminiMcpSettings( tools: OpenAITool[], bridgePort: number, bridgeApiKey: string, workspace: string, agentId: string, ): void { const geminiDir = path.join(workspace, ".gemini"); const settingsPath = path.join(geminiDir, "settings.json"); fs.mkdirSync(geminiDir, { recursive: true }); // Preserve existing non-MCP settings let existing: Record = {}; if (fs.existsSync(settingsPath)) { try { existing = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as Record; } catch { // corrupt settings — start fresh } } if (!tools.length || !fs.existsSync(MCP_SERVER_SCRIPT)) { delete existing.mcpServers; } else { const tmpDir = os.tmpdir(); const sessionId = `oc-${Date.now()}`; const toolDefsPath = path.join(tmpDir, `${sessionId}-tools.json`); fs.writeFileSync(toolDefsPath, JSON.stringify(tools, null, 2), "utf8"); // Gemini MCP server config — server alias must not contain underscores // (Gemini uses underscores as FQN separator: mcp__). // We use "openclaw" (no underscores) to keep names clean. existing.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, }, trust: true, // auto-approve MCP tool calls (equivalent to Claude's bypassPermissions) }, }; } fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2), "utf8"); } /** * Dispatch a turn to Gemini CLI using `gemini -p --output-format stream-json`. * Returns an async iterable of ClaudeMessage events (same interface as Claude adapter). * * Stream-json event types from Gemini CLI: * init → { session_id, model } * message → { role: "assistant", content: string, delta: true } (streaming text) * tool_use → informational (Gemini handles internally) * tool_result → informational (Gemini handles internally) * result → { status, stats } */ export async function* dispatchToGemini( opts: GeminiDispatchOptions, ): AsyncIterable { const { prompt, systemPrompt, workspace, agentId = "", resumeSessionId, model, openclawTools, bridgePort = 18800, bridgeApiKey = "", } = opts; // Write system-level instructions to workspace/GEMINI.md every turn. // Gemini CLI reads GEMINI.md from cwd on every invocation (including resumes) // and does NOT store it in the session file, so this acts as a stateless system prompt. if (systemPrompt) { fs.writeFileSync(path.join(workspace, "GEMINI.md"), systemPrompt, "utf8"); } // Write MCP config to workspace .gemini/settings.json every turn. // Gemini CLI restarts the MCP server process each invocation (like Claude), // so we must re-inject the config on every turn including resumes. if (openclawTools?.length) { setupGeminiMcpSettings(openclawTools, bridgePort, bridgeApiKey, workspace, agentId); } // NOTE: prompt goes right after -p before other flags to avoid ambiguity. const args: string[] = [ "-p", prompt, "--output-format", "stream-json", "--approval-mode", "yolo", ]; if (model) { args.push("-m", model); } if (resumeSessionId) { args.push("--resume", resumeSessionId); } const child = spawn("gemini", 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; // ignore non-JSON lines (e.g. "YOLO mode is enabled." warning) } const type = event.type as string; // init event carries the session_id for future --resume if (type === "init") { const sid = event.session_id as string; if (sid) capturedSessionId = sid; } // Streaming assistant text: message events with delta:true if (type === "message" && event.role === "assistant" && event.delta) { const content = event.content as string; if (content) { events.push({ type: "text", text: content }); } } 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(); }); if (capturedSessionId) { yield { type: "done", sessionId: capturedSessionId }; } else { const stderrSummary = stderrLines .join(" ") .replace(/YOLO mode is enabled\./g, "") .trim() .slice(0, 200); yield { type: "error", message: `gemini did not return a session_id${stderrSummary ? `: ${stderrSummary}` : ""}`, }; } }