claude-code and gemini-cli are both Node binaries. When the parent
gateway is launched with `NODE_OPTIONS=--inspect=127.0.0.1:9229` (for
debugging), spawn(child).env = {...process.env} propagates the flag into
the child. The child Node then tries to bind the same inspector port,
fails EADDRINUSE, and exits SILENTLY (no stdout, no stderr).
Bridge sees an empty stream and reports `claude did not return a
session_id` with an empty stderr summary — extremely opaque diagnostic
that took non-trivial digging to root-cause.
Sanitize NODE_OPTIONS before spawn: keep everything except
`--inspect*` / `--inspect-brk*` / `--debug*`. Operators that legitimately
need other NODE_OPTIONS values (e.g. `--max-old-space-size`) keep them.
Verified end-user repro on prod-t2 2026-05-31: with
`Environment=NODE_OPTIONS=--inspect=127.0.0.1:9229` in the gateway
systemd drop-in, `claude -p "hi" --output-format stream-json --verbose`
spawned from the bridge returned ZERO bytes; running the exact same
command from a shell without the env var returned the full init →
assistant → result stream in ~6s. Surfaced recruiting developer1
(Cody, contractor-claude-bridge).
303 lines
9.3 KiB
TypeScript
303 lines
9.3 KiB
TypeScript
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;
|
|
/**
|
|
* Abort signal from the bridge. Mirror of dispatchToClaude's `signal` —
|
|
* see that file for rationale. When fired, we kill the gemini subprocess
|
|
* and break the iterator promptly so a stale process doesn't outlive
|
|
* the upstream request.
|
|
*/
|
|
signal?: AbortSignal;
|
|
};
|
|
|
|
/**
|
|
* 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<string, unknown> = {};
|
|
if (fs.existsSync(settingsPath)) {
|
|
try {
|
|
existing = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
|
|
} 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_<alias>_<toolname>).
|
|
// 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<ClaudeMessage> {
|
|
const {
|
|
prompt,
|
|
systemPrompt,
|
|
workspace,
|
|
agentId = "",
|
|
resumeSessionId,
|
|
model,
|
|
openclawTools,
|
|
bridgePort = 18800,
|
|
bridgeApiKey = "",
|
|
signal,
|
|
} = 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);
|
|
}
|
|
|
|
// Sanitize NODE_OPTIONS before spawning — same reason as the claude
|
|
// adapter: gemini-cli is a Node binary; inheriting a parent
|
|
// `NODE_OPTIONS=--inspect=...:9229` makes every child silently EADDRINUSE.
|
|
const childEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
if (childEnv.NODE_OPTIONS) {
|
|
const filtered = childEnv.NODE_OPTIONS
|
|
.split(/\s+/)
|
|
.filter((tok) => tok && !tok.startsWith("--inspect") && !tok.startsWith("--debug"))
|
|
.join(" ")
|
|
.trim();
|
|
if (filtered) childEnv.NODE_OPTIONS = filtered;
|
|
else delete childEnv.NODE_OPTIONS;
|
|
}
|
|
|
|
const child = spawn("gemini", args, {
|
|
cwd: workspace,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: childEnv,
|
|
});
|
|
|
|
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;
|
|
|
|
// Cleanup helper: SIGTERM the child first, then SIGKILL after a grace
|
|
// period if it hasn't exited. Idempotent — safe to call multiple times.
|
|
let cleanupScheduled = false;
|
|
const scheduleCleanup = (): void => {
|
|
if (cleanupScheduled) return;
|
|
cleanupScheduled = true;
|
|
if (child.exitCode !== null) return;
|
|
try { child.kill("SIGTERM"); } catch { /* already gone */ }
|
|
const killTimer = setTimeout(() => {
|
|
try { child.kill("SIGKILL"); } catch { /* already gone */ }
|
|
}, 5000);
|
|
killTimer.unref?.();
|
|
child.once("close", () => clearTimeout(killTimer));
|
|
};
|
|
|
|
const markDone = (): void => {
|
|
if (done) return;
|
|
done = true;
|
|
scheduleCleanup();
|
|
if (resolveNext) {
|
|
const r = resolveNext;
|
|
resolveNext = null;
|
|
r();
|
|
}
|
|
};
|
|
|
|
// Hook the upstream abort signal: when the bridge's HTTP client (OpenClaw)
|
|
// closes the socket, kill the gemini subprocess and break the iterator.
|
|
// See dispatchToClaude for the full rationale.
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
markDone();
|
|
} else {
|
|
signal.addEventListener("abort", () => markDone(), { once: true });
|
|
}
|
|
}
|
|
|
|
rl.on("line", (line: string) => {
|
|
if (!line.trim()) return;
|
|
let event: Record<string, unknown>;
|
|
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<void>((resolve) => {
|
|
resolveNext = resolve;
|
|
});
|
|
}
|
|
|
|
while (events.length > 0) {
|
|
yield events.shift()!;
|
|
}
|
|
|
|
await new Promise<void>((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}` : ""}`,
|
|
};
|
|
}
|
|
}
|