- Migrate src/ → plugin/ (plugin/core/, plugin/web/, plugin/commands/)
and src/mcp/ → services/ per OpenClaw plugin dev spec
- Add Gemini CLI backend (plugin/core/gemini/sdk-adapter.ts) with GEMINI.md
system-prompt injection
- Inject bootstrap as stateless system prompt on every turn instead of
first turn only: Claude via --system-prompt, Gemini via workspace/GEMINI.md;
eliminates isFirstTurn branch, keeps skills in sync with OpenClaw snapshots
- Fix session-map-store defensive parsing (sessions ?? []) to handle bare {}
reset files without crashing on .find()
- Add docs/TEST_FLOW.md with E2E test scenarios and expected outcomes
- Add docs/claude/BRIDGE_MODEL_FINDINGS.md with contractor-probe results
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
244 lines
7.3 KiB
TypeScript
244 lines
7.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;
|
|
};
|
|
|
|
/**
|
|
* 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 = "",
|
|
} = 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<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}` : ""}`,
|
|
};
|
|
}
|
|
}
|