refactor: restructure to plugin/ + services/ layout and add per-turn bootstrap injection
- 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>
This commit is contained in:
235
plugin/core/claude/sdk-adapter.ts
Normal file
235
plugin/core/claude/sdk-adapter.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
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;
|
||||
/** System prompt passed via --system-prompt on every invocation (stateless, not stored in session) */
|
||||
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__<toolname>` 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<ClaudeMessage> {
|
||||
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 <configs...> (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",
|
||||
];
|
||||
|
||||
// --system-prompt is stateless (not persisted in session file) and fully
|
||||
// replaces any prior system prompt on each invocation, including resumes.
|
||||
// We pass it every turn so skills/persona stay current.
|
||||
if (systemPrompt) {
|
||||
args.push("--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 <configs...> 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<string, unknown>;
|
||||
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<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();
|
||||
});
|
||||
|
||||
// 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}` : ""}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
33
plugin/core/contractor/metadata-resolver.ts
Normal file
33
plugin/core/contractor/metadata-resolver.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ContractorAgentMetadata } from "../types/contractor.js";
|
||||
|
||||
const CONTRACTOR_MODEL = "contractor-agent/contractor-claude-bridge";
|
||||
|
||||
type AgentConfig = {
|
||||
id: string;
|
||||
workspace?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine whether an agent is a contractor-backed Claude agent and return its metadata.
|
||||
* Contractor agents are identified by their model string — we do NOT use a custom
|
||||
* runtime.type because OpenClaw's schema only allows "embedded" or "acp".
|
||||
* Returns null if the agent is not contractor-backed.
|
||||
*/
|
||||
export function resolveContractorAgentMetadata(
|
||||
agentConfig: AgentConfig,
|
||||
permissionMode: string,
|
||||
): ContractorAgentMetadata | null {
|
||||
if (agentConfig.model !== CONTRACTOR_MODEL) return null;
|
||||
|
||||
const workspace = agentConfig.workspace;
|
||||
if (!workspace) return null;
|
||||
|
||||
return {
|
||||
agentId: agentConfig.id,
|
||||
contractor: "claude",
|
||||
bridgeModel: "contractor-claude-bridge",
|
||||
workspace,
|
||||
permissionMode,
|
||||
};
|
||||
}
|
||||
15
plugin/core/contractor/runtime-state.ts
Normal file
15
plugin/core/contractor/runtime-state.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
export function getContractorStateDir(workspace: string): string {
|
||||
return path.join(workspace, ".openclaw", "contractor-agent");
|
||||
}
|
||||
|
||||
export function getSessionMapPath(workspace: string): string {
|
||||
return path.join(getContractorStateDir(workspace), "session-map.json");
|
||||
}
|
||||
|
||||
export function ensureContractorStateDir(workspace: string): void {
|
||||
const dir = getContractorStateDir(workspace);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
69
plugin/core/contractor/session-map-store.ts
Normal file
69
plugin/core/contractor/session-map-store.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import fs from "node:fs";
|
||||
import { getSessionMapPath, ensureContractorStateDir } from "./runtime-state.js";
|
||||
import type { SessionMapEntry, SessionMapFile } from "../types/session-map.js";
|
||||
|
||||
function readFile(workspace: string): SessionMapFile {
|
||||
const p = getSessionMapPath(workspace);
|
||||
if (!fs.existsSync(p)) {
|
||||
return { version: 1, sessions: [] };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(p, "utf8")) as Partial<SessionMapFile>;
|
||||
return { version: parsed.version ?? 1, sessions: parsed.sessions ?? [] };
|
||||
} catch {
|
||||
return { version: 1, sessions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function writeFile(workspace: string, data: SessionMapFile): void {
|
||||
ensureContractorStateDir(workspace);
|
||||
fs.writeFileSync(getSessionMapPath(workspace), JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export function getSession(workspace: string, openclawSessionKey: string): SessionMapEntry | null {
|
||||
const data = readFile(workspace);
|
||||
return data.sessions.find((s) => s.openclawSessionKey === openclawSessionKey) ?? null;
|
||||
}
|
||||
|
||||
export function putSession(workspace: string, entry: SessionMapEntry): void {
|
||||
const data = readFile(workspace);
|
||||
const idx = data.sessions.findIndex((s) => s.openclawSessionKey === entry.openclawSessionKey);
|
||||
if (idx >= 0) {
|
||||
data.sessions[idx] = entry;
|
||||
} else {
|
||||
data.sessions.push(entry);
|
||||
}
|
||||
writeFile(workspace, data);
|
||||
}
|
||||
|
||||
export function updateActivity(workspace: string, openclawSessionKey: string): void {
|
||||
const data = readFile(workspace);
|
||||
const entry = data.sessions.find((s) => s.openclawSessionKey === openclawSessionKey);
|
||||
if (entry) {
|
||||
entry.lastActivityAt = new Date().toISOString();
|
||||
writeFile(workspace, data);
|
||||
}
|
||||
}
|
||||
|
||||
export function markOrphaned(workspace: string, openclawSessionKey: string): void {
|
||||
const data = readFile(workspace);
|
||||
const entry = data.sessions.find((s) => s.openclawSessionKey === openclawSessionKey);
|
||||
if (entry) {
|
||||
entry.state = "orphaned";
|
||||
writeFile(workspace, data);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSession(workspace: string, openclawSessionKey: string): void {
|
||||
const data = readFile(workspace);
|
||||
data.sessions = data.sessions.filter((s) => s.openclawSessionKey !== openclawSessionKey);
|
||||
writeFile(workspace, data);
|
||||
}
|
||||
|
||||
export function initEmptySessionMap(workspace: string): void {
|
||||
ensureContractorStateDir(workspace);
|
||||
const p = getSessionMapPath(workspace);
|
||||
if (!fs.existsSync(p)) {
|
||||
writeFile(workspace, { version: 1, sessions: [] });
|
||||
}
|
||||
}
|
||||
243
plugin/core/gemini/sdk-adapter.ts
Normal file
243
plugin/core/gemini/sdk-adapter.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
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}` : ""}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
58
plugin/core/openclaw/agent-config-writer.ts
Normal file
58
plugin/core/openclaw/agent-config-writer.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type OpenClawConfig = Record<string, unknown>;
|
||||
type AgentEntry = Record<string, unknown>;
|
||||
|
||||
const CLAUDE_CONTRACTOR_MODEL = "contractor-agent/contractor-claude-bridge";
|
||||
const GEMINI_CONTRACTOR_MODEL = "contractor-agent/contractor-gemini-bridge";
|
||||
|
||||
function readConfig(): { config: OpenClawConfig; configPath: string } {
|
||||
const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
|
||||
const raw = fs.readFileSync(configPath, "utf8");
|
||||
return { config: JSON.parse(raw) as OpenClawConfig, configPath };
|
||||
}
|
||||
|
||||
function writeConfig(configPath: string, config: OpenClawConfig): void {
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that an agent is configured with the contractor bridge model.
|
||||
* Called after createBaseAgent() — the model is already set by `openclaw agents add`,
|
||||
* so this is just a sanity check that throws if something went wrong.
|
||||
*
|
||||
* We intentionally do NOT write a custom `runtime.type` — OpenClaw's schema only
|
||||
* allows "embedded" or "acp", and contractor agents are identified by their model.
|
||||
*/
|
||||
function markContractorAgent(agentId: string, expectedModel: string, configPath: string, config: OpenClawConfig): void {
|
||||
const agents = (config.agents as { list?: AgentEntry[] } | undefined)?.list;
|
||||
if (!agents) throw new Error("agents.list not found in openclaw.json");
|
||||
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
if (!agent) throw new Error(`agent ${agentId} not found in openclaw.json`);
|
||||
|
||||
if (agent.model !== expectedModel) {
|
||||
throw new Error(
|
||||
`agent ${agentId} model is "${String(agent.model)}", expected "${expectedModel}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure tools are enabled so OpenClaw includes tool definitions in every
|
||||
// chat completions request — needed for the MCP proxy to expose them to Claude/Gemini.
|
||||
if (!agent.tools) {
|
||||
agent.tools = { profile: "full" };
|
||||
writeConfig(configPath, config);
|
||||
}
|
||||
}
|
||||
|
||||
export function markAgentAsClaudeContractor(agentId: string, _workspace: string): void {
|
||||
const { config, configPath } = readConfig();
|
||||
markContractorAgent(agentId, CLAUDE_CONTRACTOR_MODEL, configPath, config);
|
||||
}
|
||||
|
||||
export function markAgentAsGeminiContractor(agentId: string, _workspace: string): void {
|
||||
const { config, configPath } = readConfig();
|
||||
markContractorAgent(agentId, GEMINI_CONTRACTOR_MODEL, configPath, config);
|
||||
}
|
||||
20
plugin/core/openclaw/agents-add-runner.ts
Normal file
20
plugin/core/openclaw/agents-add-runner.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
export type AddBaseAgentInput = {
|
||||
agentId: string;
|
||||
workspace: string;
|
||||
bridgeModel: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the base OpenClaw agent using the `openclaw agents add` CLI.
|
||||
* This sets up routing and workspace; contractor metadata is written separately.
|
||||
*/
|
||||
export function createBaseAgent(input: AddBaseAgentInput): void {
|
||||
const { agentId, workspace, bridgeModel } = input;
|
||||
execFileSync(
|
||||
"openclaw",
|
||||
["agents", "add", agentId, "--workspace", workspace, "--model", bridgeModel, "--non-interactive"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
}
|
||||
24
plugin/core/types/contractor.ts
Normal file
24
plugin/core/types/contractor.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type ContractorKind = "claude" | "gemini";
|
||||
|
||||
export type ContractorAgentMetadata = {
|
||||
agentId: string;
|
||||
contractor: ContractorKind;
|
||||
bridgeModel: string;
|
||||
workspace: string;
|
||||
permissionMode: string;
|
||||
};
|
||||
|
||||
export type PluginConfig = {
|
||||
bridgePort: number;
|
||||
bridgeApiKey: string;
|
||||
permissionMode: string;
|
||||
};
|
||||
|
||||
export function normalizePluginConfig(raw: unknown): PluginConfig {
|
||||
const cfg = (raw ?? {}) as Partial<PluginConfig>;
|
||||
return {
|
||||
bridgePort: cfg.bridgePort ?? 18800,
|
||||
bridgeApiKey: cfg.bridgeApiKey ?? "contractor-bridge-local",
|
||||
permissionMode: cfg.permissionMode ?? "bypassPermissions",
|
||||
};
|
||||
}
|
||||
24
plugin/core/types/model.ts
Normal file
24
plugin/core/types/model.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Shape of the OpenAI chat completions request body that OpenClaw sends to the bridge sidecar.
|
||||
// Confirmed via contractor-probe testing: OpenClaw always sends stream:true.
|
||||
export type OpenAIMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string | Array<{ type: string; text?: string }>;
|
||||
};
|
||||
|
||||
export type BridgeInboundRequest = {
|
||||
model: string;
|
||||
messages: OpenAIMessage[];
|
||||
stream: boolean;
|
||||
max_completion_tokens?: number;
|
||||
tools?: Array<{ type: "function"; function: { name: string; description?: string; parameters?: unknown } }>;
|
||||
store?: boolean;
|
||||
};
|
||||
|
||||
// Internal payload passed from the HTTP layer to the Claude dispatch layer.
|
||||
export type ClaudeDispatchPayload = {
|
||||
agentId: string;
|
||||
openclawSessionKey: string;
|
||||
workspace: string;
|
||||
latestUserMessage: string;
|
||||
existingClaudeSessionId: string | null;
|
||||
};
|
||||
18
plugin/core/types/session-map.ts
Normal file
18
plugin/core/types/session-map.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type SessionMapState = "active" | "closed" | "orphaned";
|
||||
|
||||
export type SessionMapEntry = {
|
||||
openclawSessionKey: string;
|
||||
agentId: string;
|
||||
contractor: "claude" | "gemini";
|
||||
/** Opaque session ID used to resume the underlying CLI session (--resume). */
|
||||
claudeSessionId: string;
|
||||
workspace: string;
|
||||
createdAt: string;
|
||||
lastActivityAt: string;
|
||||
state: SessionMapState;
|
||||
};
|
||||
|
||||
export type SessionMapFile = {
|
||||
version: 1;
|
||||
sessions: SessionMapEntry[];
|
||||
};
|
||||
Reference in New Issue
Block a user