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:
53
plugin/commands/contractor-agents-add.ts
Normal file
53
plugin/commands/contractor-agents-add.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from "node:fs";
|
||||
import { createBaseAgent } from "../core/openclaw/agents-add-runner.js";
|
||||
import { markAgentAsClaudeContractor, markAgentAsGeminiContractor } from "../core/openclaw/agent-config-writer.js";
|
||||
import { ensureContractorStateDir } from "../core/contractor/runtime-state.js";
|
||||
import { initEmptySessionMap } from "../core/contractor/session-map-store.js";
|
||||
|
||||
export type AddArgs = {
|
||||
agentId: string;
|
||||
workspace: string;
|
||||
contractor: string;
|
||||
};
|
||||
|
||||
export async function runContractorAgentsAdd(args: AddArgs): Promise<void> {
|
||||
const { agentId, workspace, contractor } = args;
|
||||
|
||||
// Validate
|
||||
if (!agentId) throw new Error("--agent-id is required");
|
||||
if (!workspace) throw new Error("--workspace is required");
|
||||
if (!contractor) throw new Error("--contractor is required");
|
||||
if (contractor !== "claude" && contractor !== "gemini") {
|
||||
throw new Error(`--contractor ${contractor}: must be 'claude' or 'gemini'`);
|
||||
}
|
||||
|
||||
const bridgeModel =
|
||||
contractor === "gemini"
|
||||
? "contractor-agent/contractor-gemini-bridge"
|
||||
: "contractor-agent/contractor-claude-bridge";
|
||||
|
||||
// Ensure workspace exists
|
||||
if (!fs.existsSync(workspace)) {
|
||||
fs.mkdirSync(workspace, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`[contractor-agent] Creating base OpenClaw agent: ${agentId}`);
|
||||
createBaseAgent({ agentId, workspace, bridgeModel });
|
||||
|
||||
console.log(`[contractor-agent] Writing contractor metadata`);
|
||||
if (contractor === "gemini") {
|
||||
markAgentAsGeminiContractor(agentId, workspace);
|
||||
} else {
|
||||
markAgentAsClaudeContractor(agentId, workspace);
|
||||
}
|
||||
|
||||
console.log(`[contractor-agent] Initializing runtime state`);
|
||||
ensureContractorStateDir(workspace);
|
||||
initEmptySessionMap(workspace);
|
||||
|
||||
console.log(`[contractor-agent] Done.`);
|
||||
console.log(` Agent: ${agentId}`);
|
||||
console.log(` Workspace: ${workspace}`);
|
||||
console.log(` Model: ${bridgeModel}`);
|
||||
console.log(` State dir: ${workspace}/.openclaw/contractor-agent/`);
|
||||
}
|
||||
50
plugin/commands/register-cli.ts
Normal file
50
plugin/commands/register-cli.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { runContractorAgentsAdd } from "./contractor-agents-add.js";
|
||||
|
||||
export function registerCli(api: OpenClawPluginApi): void {
|
||||
api.registerCli(
|
||||
(ctx) => {
|
||||
// Use ctx.program.command() directly — do NOT import Commander separately.
|
||||
// Importing a different Commander version causes _prepareForParse failures.
|
||||
const contractorAgents = ctx.program.command("contractor-agents")
|
||||
.description("Manage Claude-backed contractor agents");
|
||||
|
||||
contractorAgents
|
||||
.command("add")
|
||||
.description("Provision a new Claude-backed contractor agent")
|
||||
.requiredOption("--agent-id <id>", "Agent id")
|
||||
.requiredOption("--workspace <path>", "Workspace directory")
|
||||
.requiredOption("--contractor <kind>", "Contractor kind (claude)")
|
||||
.action(async (opts: { agentId: string; workspace: string; contractor: string }) => {
|
||||
try {
|
||||
await runContractorAgentsAdd({
|
||||
agentId: opts.agentId,
|
||||
workspace: opts.workspace,
|
||||
contractor: opts.contractor,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[contractor-agents add] Error: ${String(err)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
contractorAgents
|
||||
.command("status")
|
||||
.description("Show status of a contractor agent (not yet implemented)")
|
||||
.requiredOption("--agent-id <id>", "Agent id")
|
||||
.action(() => {
|
||||
console.log("[contractor-agents status] not yet implemented");
|
||||
});
|
||||
},
|
||||
{
|
||||
commands: ["contractor-agents"],
|
||||
descriptors: [
|
||||
{
|
||||
name: "contractor-agents",
|
||||
description: "Manage Claude-backed contractor agents",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
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[];
|
||||
};
|
||||
101
plugin/index.ts
Normal file
101
plugin/index.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { normalizePluginConfig } from "./core/types/contractor.js";
|
||||
import { resolveContractorAgentMetadata } from "./core/contractor/metadata-resolver.js";
|
||||
import { createBridgeServer } from "./web/server.js";
|
||||
import { registerCli } from "./commands/register-cli.js";
|
||||
import type http from "node:http";
|
||||
|
||||
function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const tester = net.createServer();
|
||||
tester.once("error", () => resolve(false));
|
||||
tester.once("listening", () => tester.close(() => resolve(true)));
|
||||
tester.listen(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
// ── GlobalThis state ─────────────────────────────────────────────────────────
|
||||
// All persistent state lives on globalThis to survive OpenClaw hot-reloads.
|
||||
// See LESSONS_LEARNED.md items 1, 3, 11.
|
||||
|
||||
const _G = globalThis as Record<string, unknown>;
|
||||
const LIFECYCLE_KEY = "_contractorAgentLifecycleRegistered";
|
||||
const SERVER_KEY = "_contractorAgentBridgeServer";
|
||||
/** Key for the live OpenClaw config accessor (getter fn) shared via globalThis. */
|
||||
const OPENCLAW_CONFIG_KEY = "_contractorOpenClawConfig";
|
||||
|
||||
// ── Plugin entry ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default {
|
||||
id: "contractor-agent",
|
||||
name: "Contractor Agent",
|
||||
async register(api: OpenClawPluginApi) {
|
||||
const config = normalizePluginConfig(api.pluginConfig);
|
||||
|
||||
// Resolve agent metadata for the bridge server's resolveAgent callback.
|
||||
// We do this by reading openclaw.json — the bridge server calls this on every turn.
|
||||
function resolveAgent(agentId: string, _sessionKey: string) {
|
||||
try {
|
||||
const configPath = path.join(
|
||||
(process.env.HOME ?? "/root"),
|
||||
".openclaw",
|
||||
"openclaw.json",
|
||||
);
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as {
|
||||
agents?: { list?: Array<{ id: string; workspace?: string; model?: string }> };
|
||||
};
|
||||
const agent = raw.agents?.list?.find((a) => a.id === agentId);
|
||||
if (!agent) return null;
|
||||
const meta = resolveContractorAgentMetadata(agent, config.permissionMode);
|
||||
if (!meta) return null;
|
||||
return { workspace: meta.workspace };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway lifecycle (start bridge server once per gateway process) ──────
|
||||
// Guard with globalThis flag AND a port probe to handle the case where the
|
||||
// gateway is already running the server while a CLI subprocess is starting up.
|
||||
// (See LESSONS_LEARNED.md item 7 — lock file / port probe pattern)
|
||||
// Always update the config accessor so hot-reloads get fresh config.
|
||||
// server.ts reads this via globalThis to build tool execution context.
|
||||
_G[OPENCLAW_CONFIG_KEY] = api.config;
|
||||
|
||||
if (!_G[LIFECYCLE_KEY]) {
|
||||
_G[LIFECYCLE_KEY] = true;
|
||||
|
||||
// Only bind if port is not already in use (avoids EADDRINUSE in CLI mode)
|
||||
const portFree = await isPortFree(config.bridgePort);
|
||||
if (!portFree) {
|
||||
api.logger.info(
|
||||
`[contractor-agent] bridge already running on port ${config.bridgePort}, skipping bind`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const server = createBridgeServer({
|
||||
port: config.bridgePort,
|
||||
apiKey: config.bridgeApiKey,
|
||||
permissionMode: config.permissionMode,
|
||||
resolveAgent,
|
||||
logger: api.logger,
|
||||
});
|
||||
_G[SERVER_KEY] = server;
|
||||
|
||||
api.on("gateway_stop", () => {
|
||||
const s = _G[SERVER_KEY] as http.Server | undefined;
|
||||
if (s) s.close();
|
||||
api.logger.info("[contractor-agent] bridge server stopped");
|
||||
});
|
||||
}
|
||||
|
||||
// ── CLI ───────────────────────────────────────────────────────────────────
|
||||
registerCli(api);
|
||||
|
||||
api.logger.info(`[contractor-agent] plugin registered (bridge port: ${config.bridgePort})`);
|
||||
},
|
||||
};
|
||||
28
plugin/openclaw.plugin.json
Normal file
28
plugin/openclaw.plugin.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "contractor-agent",
|
||||
"name": "Contractor Agent",
|
||||
"version": "0.1.0",
|
||||
"description": "Turns Claude Code into an OpenClaw-managed contractor agent",
|
||||
"main": "index.ts",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"bridgePort": {
|
||||
"type": "number",
|
||||
"default": 18800,
|
||||
"description": "Port for the contractor bridge sidecar HTTP server"
|
||||
},
|
||||
"bridgeApiKey": {
|
||||
"type": "string",
|
||||
"default": "contractor-bridge-local",
|
||||
"description": "API key for the bridge sidecar (local use only)"
|
||||
},
|
||||
"permissionMode": {
|
||||
"type": "string",
|
||||
"default": "bypassPermissions",
|
||||
"description": "Claude Code permission mode for contractor sessions"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
plugin/package.json
Normal file
14
plugin/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "contractor-agent",
|
||||
"version": "0.1.0",
|
||||
"description": "OpenClaw plugin: turns Claude Code into an OpenClaw-managed contractor agent",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.101"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0",
|
||||
"openclaw": "*"
|
||||
}
|
||||
}
|
||||
117
plugin/web/bootstrap.ts
Normal file
117
plugin/web/bootstrap.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type BootstrapInput = {
|
||||
agentId: string;
|
||||
openclawSessionKey: string;
|
||||
workspace: string;
|
||||
/** Skills XML block extracted from the OpenClaw system prompt, if any */
|
||||
skillsBlock?: string;
|
||||
/** Subset of OpenClaw context files present in the workspace (for persona/identity) */
|
||||
workspaceContextFiles?: string[];
|
||||
};
|
||||
|
||||
/** Read a workspace file. Returns the content, or null if missing/unreadable. */
|
||||
function readWorkspaceFile(workspace: string, filename: string): string | null {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(workspace, filename), "utf8").trim();
|
||||
return content || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the one-time bootstrap message injected at the start of a new session.
|
||||
* Workspace context files (SOUL.md, IDENTITY.md, MEMORY.md, USER.md) are read
|
||||
* here and embedded inline — the agent must not need to fetch them separately.
|
||||
* Must NOT be re-injected on every turn.
|
||||
*/
|
||||
export function buildBootstrap(input: BootstrapInput): string {
|
||||
const contextFiles = input.workspaceContextFiles ?? [];
|
||||
|
||||
const lines = [
|
||||
`You are operating as a contractor agent inside OpenClaw.`,
|
||||
``,
|
||||
`## Context`,
|
||||
`- Agent ID: ${input.agentId}`,
|
||||
`- Session key: ${input.openclawSessionKey}`,
|
||||
`- Workspace: ${input.workspace}`,
|
||||
``,
|
||||
`## Role`,
|
||||
`You receive tasks from OpenClaw users and complete them using your tools.`,
|
||||
`You do not need to manage your own session context — OpenClaw handles session routing.`,
|
||||
`Your responses go directly back to the user through OpenClaw.`,
|
||||
``,
|
||||
`## Guidelines`,
|
||||
`- Work in the specified workspace directory.`,
|
||||
`- Be concise and action-oriented. Use tools to accomplish tasks rather than describing what you would do.`,
|
||||
`- Each message you receive contains the latest user request. Previous context is in your session memory.`,
|
||||
`- If a task is unclear, ask one focused clarifying question.`,
|
||||
];
|
||||
|
||||
// ── Persona (SOUL.md) ─────────────────────────────────────────────────────
|
||||
// Injected directly so the agent embodies the persona immediately without
|
||||
// needing to read files first.
|
||||
if (contextFiles.includes("SOUL.md")) {
|
||||
const soul = readWorkspaceFile(input.workspace, "SOUL.md");
|
||||
if (soul) {
|
||||
lines.push(``, `## Soul`, soul);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identity (IDENTITY.md) ────────────────────────────────────────────────
|
||||
if (contextFiles.includes("IDENTITY.md")) {
|
||||
const identity = readWorkspaceFile(input.workspace, "IDENTITY.md");
|
||||
if (identity) {
|
||||
lines.push(``, `## Identity`, identity);
|
||||
// If the file still looks like the default template, encourage the agent
|
||||
// to fill it in.
|
||||
if (identity.includes("Fill this in during your first conversation")) {
|
||||
lines.push(
|
||||
``,
|
||||
`_IDENTITY.md is still a template. Pick a name, creature type, and vibe for yourself`,
|
||||
`and update the file at ${input.workspace}/IDENTITY.md._`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── User profile (USER.md) ────────────────────────────────────────────────
|
||||
if (contextFiles.includes("USER.md")) {
|
||||
const user = readWorkspaceFile(input.workspace, "USER.md");
|
||||
if (user) {
|
||||
lines.push(``, `## User Profile`, user);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Memory index (MEMORY.md) ──────────────────────────────────────────────
|
||||
if (contextFiles.includes("MEMORY.md") || contextFiles.some(f => f.startsWith("memory/"))) {
|
||||
const memoryIndex = readWorkspaceFile(input.workspace, "MEMORY.md");
|
||||
lines.push(``, `## Memory`);
|
||||
if (memoryIndex) {
|
||||
lines.push(memoryIndex);
|
||||
}
|
||||
lines.push(
|
||||
``,
|
||||
`Memory files live in ${input.workspace}/memory/. Use the Read tool to fetch individual`,
|
||||
`memories and write new ones to ${input.workspace}/memory/<topic>.md.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Skills ────────────────────────────────────────────────────────────────
|
||||
if (input.skillsBlock) {
|
||||
lines.push(
|
||||
``,
|
||||
`## Skills`,
|
||||
`The following skills are available. When a task matches a skill's description:`,
|
||||
`1. Read the skill's SKILL.md using the Read tool (the <location> field is the absolute path).`,
|
||||
`2. Follow the instructions in SKILL.md. Replace \`{baseDir}\` with the directory containing SKILL.md.`,
|
||||
`3. Run scripts using the Bash tool, NOT the \`exec\` tool (you have Bash, not exec).`,
|
||||
``,
|
||||
input.skillsBlock,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
95
plugin/web/input-filter.ts
Normal file
95
plugin/web/input-filter.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { BridgeInboundRequest, OpenAIMessage } from "../core/types/model.js";
|
||||
|
||||
function messageText(m: OpenAIMessage): string {
|
||||
if (typeof m.content === "string") return m.content;
|
||||
return m.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the latest user message from the OpenClaw request.
|
||||
*
|
||||
* OpenClaw accumulates all user messages and sends the full array every turn,
|
||||
* but assistant messages may be missing if the previous response wasn't streamed
|
||||
* correctly. The bridge model only needs the latest user message to forward to
|
||||
* Claude — Claude maintains its own session context.
|
||||
*
|
||||
* OpenClaw prefixes user messages with a timestamp: "[Day YYYY-MM-DD HH:MM TZ] text"
|
||||
* We strip the timestamp prefix before forwarding.
|
||||
*/
|
||||
export function extractLatestUserMessage(req: BridgeInboundRequest): string {
|
||||
const userMessages = req.messages.filter((m) => m.role === "user");
|
||||
if (userMessages.length === 0) return "";
|
||||
|
||||
const raw = messageText(userMessages[userMessages.length - 1]);
|
||||
// Strip OpenClaw timestamp prefix: "[Sat 2026-04-11 08:32 GMT+1] "
|
||||
return raw.replace(/^\[[^\]]+\]\s*/, "").trim();
|
||||
}
|
||||
|
||||
export type RequestContext = {
|
||||
agentId: string;
|
||||
workspace: string;
|
||||
/** Raw <available_skills>...</available_skills> XML block from the system prompt */
|
||||
skillsBlock: string;
|
||||
/** OpenClaw context files present in the workspace (SOUL.md, IDENTITY.md, etc.) */
|
||||
workspaceContextFiles: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse agent ID and workspace path from the OpenClaw system prompt.
|
||||
*
|
||||
* OpenClaw does NOT send agent ID / session key as HTTP headers — it's embedded
|
||||
* in the system prompt as a "## Runtime" line:
|
||||
* Runtime: agent=contractor-e2e | host=... | repo=/tmp/contractor-e2e-workspace | ...
|
||||
*
|
||||
* We parse this line to extract `agent` (agent ID) and `repo` (workspace path).
|
||||
*/
|
||||
export function extractRequestContext(req: BridgeInboundRequest): RequestContext {
|
||||
const systemMsg = req.messages.find((m) => m.role === "system");
|
||||
if (!systemMsg) return { agentId: "", workspace: "", skillsBlock: "", workspaceContextFiles: [] };
|
||||
|
||||
const text = messageText(systemMsg);
|
||||
|
||||
// Match "Runtime: agent=<id> | ... | repo=<path> | ..."
|
||||
const runtimeMatch = text.match(/Runtime:\s*([^\n]+)/);
|
||||
if (!runtimeMatch) return { agentId: "", workspace: "", skillsBlock: "", workspaceContextFiles: [] };
|
||||
|
||||
const runtimeLine = runtimeMatch[1];
|
||||
const agentMatch = runtimeLine.match(/\bagent=([^|\s]+)/);
|
||||
const repoMatch = runtimeLine.match(/\brepo=([^|\s]+)/);
|
||||
|
||||
// Extract <available_skills>...</available_skills> XML block.
|
||||
// Expand leading "~/" in <location> paths to the actual home dir so Claude doesn't
|
||||
// try /root/.openclaw/... (which fails with EACCES).
|
||||
const skillsMatch = text.match(/<available_skills>[\s\S]*?<\/available_skills>/);
|
||||
const home = process.env.HOME ?? "/root";
|
||||
const skillsBlock = skillsMatch
|
||||
? skillsMatch[0].replace(/~\//g, `${home}/`)
|
||||
: "";
|
||||
|
||||
// Detect which OpenClaw context files are present in the workspace.
|
||||
// These tell us what persona/memory files to surface to Claude.
|
||||
const workspace = repoMatch?.[1] ?? "";
|
||||
const CONTEXT_FILES = ["SOUL.md", "IDENTITY.md", "MEMORY.md", "AGENTS.md", "USER.md"];
|
||||
const workspaceContextFiles: string[] = [];
|
||||
if (workspace) {
|
||||
for (const f of CONTEXT_FILES) {
|
||||
if (fs.existsSync(path.join(workspace, f))) workspaceContextFiles.push(f);
|
||||
}
|
||||
// Also check for memory/ directory
|
||||
if (fs.existsSync(path.join(workspace, "memory"))) {
|
||||
workspaceContextFiles.push("memory/");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentId: agentMatch?.[1] ?? "",
|
||||
workspace,
|
||||
skillsBlock,
|
||||
workspaceContextFiles,
|
||||
};
|
||||
}
|
||||
162
plugin/web/memory-handlers.ts
Normal file
162
plugin/web/memory-handlers.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Implementations of OpenClaw memory tools for the contractor bridge.
|
||||
*
|
||||
* OpenClaw's real memory_search uses vector/semantic search; we approximate
|
||||
* with keyword matching across workspace memory files. memory_get is a
|
||||
* line-range file read and is implemented exactly.
|
||||
*
|
||||
* These handlers are registered in globalThis._contractorToolHandlers so the
|
||||
* MCP proxy can call them via POST /mcp/execute.
|
||||
*/
|
||||
|
||||
type MemorySearchArgs = {
|
||||
query?: string;
|
||||
maxResults?: number;
|
||||
minScore?: number;
|
||||
corpus?: "memory" | "wiki" | "all";
|
||||
};
|
||||
|
||||
type MemoryGetArgs = {
|
||||
path?: string;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
corpus?: "memory" | "wiki" | "all";
|
||||
};
|
||||
|
||||
/** Collect all memory files in a workspace. */
|
||||
function collectMemoryFiles(workspace: string): string[] {
|
||||
const files: string[] = [];
|
||||
const memoryMd = path.join(workspace, "MEMORY.md");
|
||||
if (fs.existsSync(memoryMd)) files.push(memoryMd);
|
||||
|
||||
const memoryDir = path.join(workspace, "memory");
|
||||
if (fs.existsSync(memoryDir)) {
|
||||
for (const f of fs.readdirSync(memoryDir)) {
|
||||
if (f.endsWith(".md")) files.push(path.join(memoryDir, f));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Score a file's relevance to a query by counting keyword occurrences. */
|
||||
function scoreFile(content: string, query: string): { score: number; snippets: string[] } {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const lines = content.split("\n");
|
||||
const snippets: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].toLowerCase();
|
||||
const matches = words.filter((w) => line.includes(w)).length;
|
||||
if (matches > 0) {
|
||||
score += matches;
|
||||
// Include surrounding context (1 line before/after)
|
||||
const start = Math.max(0, i - 1);
|
||||
const end = Math.min(lines.length - 1, i + 1);
|
||||
const snippet = lines.slice(start, end + 1).join("\n").trim();
|
||||
if (snippet && !snippets.includes(snippet)) {
|
||||
snippets.push(snippet);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { score, snippets };
|
||||
}
|
||||
|
||||
export function makeMemorySearchHandler(workspace: string) {
|
||||
return async (args: unknown): Promise<string> => {
|
||||
const { query = "", maxResults = 5, corpus = "memory" } = args as MemorySearchArgs;
|
||||
|
||||
if (corpus === "wiki") {
|
||||
return JSON.stringify({ disabled: true, reason: "wiki corpus not available in contractor bridge" });
|
||||
}
|
||||
|
||||
const files = collectMemoryFiles(workspace);
|
||||
if (files.length === 0) {
|
||||
return JSON.stringify({
|
||||
results: [],
|
||||
note: "No memory files found in workspace. Memory is empty.",
|
||||
});
|
||||
}
|
||||
|
||||
const results: Array<{ file: string; score: number; snippets: string[] }> = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
const { score, snippets } = scoreFile(content, query);
|
||||
if (score > 0) {
|
||||
results.push({
|
||||
file: path.relative(workspace, filePath),
|
||||
score,
|
||||
snippets,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
const top = results.slice(0, maxResults);
|
||||
|
||||
if (top.length === 0) {
|
||||
return JSON.stringify({
|
||||
results: [],
|
||||
note: `No memory matches found for query: "${query}"`,
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({ results: top });
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMemoryGetHandler(workspace: string) {
|
||||
return async (args: unknown): Promise<string> => {
|
||||
const { path: filePath = "", from, lines, corpus = "memory" } = args as MemoryGetArgs;
|
||||
|
||||
if (corpus === "wiki") {
|
||||
return "[memory_get] wiki corpus not available in contractor bridge";
|
||||
}
|
||||
|
||||
if (!filePath) return "[memory_get] path is required";
|
||||
|
||||
// Resolve path relative to workspace (prevent path traversal)
|
||||
const resolved = path.resolve(workspace, filePath);
|
||||
if (!resolved.startsWith(workspace + path.sep) && resolved !== workspace) {
|
||||
return `[memory_get] path outside workspace: ${filePath}`;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(resolved)) {
|
||||
return `[memory_get] file not found: ${filePath}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(resolved, "utf8");
|
||||
const allLines = content.split("\n");
|
||||
|
||||
const startLine = from != null ? Math.max(0, from - 1) : 0; // convert 1-based to 0-based
|
||||
const endLine = lines != null ? startLine + lines : allLines.length;
|
||||
|
||||
return allLines.slice(startLine, endLine).join("\n");
|
||||
} catch (err) {
|
||||
return `[memory_get] error reading file: ${String(err)}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register memory tool handlers into the shared globalThis tool registry.
|
||||
* Called by the bridge server on startup.
|
||||
*/
|
||||
export function registerMemoryHandlers(workspace: string): void {
|
||||
const _G = globalThis as Record<string, unknown>;
|
||||
if (!(_G["_contractorToolHandlers"] instanceof Map)) {
|
||||
_G["_contractorToolHandlers"] = new Map<string, (args: unknown) => Promise<string>>();
|
||||
}
|
||||
const registry = _G["_contractorToolHandlers"] as Map<string, (args: unknown) => Promise<string>>;
|
||||
registry.set("memory_search", makeMemorySearchHandler(workspace));
|
||||
registry.set("memory_get", makeMemoryGetHandler(workspace));
|
||||
}
|
||||
384
plugin/web/server.ts
Normal file
384
plugin/web/server.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import http from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { BridgeInboundRequest } from "../core/types/model.js";
|
||||
import { extractLatestUserMessage, extractRequestContext } from "./input-filter.js";
|
||||
import { buildBootstrap } from "./bootstrap.js";
|
||||
import { dispatchToClaude } from "../core/claude/sdk-adapter.js";
|
||||
import { dispatchToGemini } from "../core/gemini/sdk-adapter.js";
|
||||
import { getGlobalPluginRegistry } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import {
|
||||
getSession,
|
||||
putSession,
|
||||
markOrphaned,
|
||||
} from "../core/contractor/session-map-store.js";
|
||||
|
||||
export type BridgeServerConfig = {
|
||||
port: number;
|
||||
apiKey: string;
|
||||
permissionMode: string;
|
||||
/** Fallback: resolve workspace from agent id if not parseable from system prompt */
|
||||
resolveAgent?: (
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
) => { workspace: string } | null;
|
||||
logger: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||
};
|
||||
|
||||
function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
|
||||
const json = JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": Buffer.byteLength(json),
|
||||
});
|
||||
res.end(json);
|
||||
}
|
||||
|
||||
/** Write a single SSE data line. */
|
||||
function sseWrite(res: http.ServerResponse, data: string): void {
|
||||
res.write(`data: ${data}\n\n`);
|
||||
}
|
||||
|
||||
/** Build an OpenAI streaming chunk for a text delta. */
|
||||
function buildChunk(id: string, text: string): string {
|
||||
return JSON.stringify({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: "contractor-claude-bridge",
|
||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the final stop chunk. */
|
||||
function buildStopChunk(id: string): string {
|
||||
return JSON.stringify({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: "contractor-claude-bridge",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
});
|
||||
}
|
||||
|
||||
function parseBodyRaw(req: http.IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString("utf8");
|
||||
if (body.length > 4_000_000) req.destroy(new Error("body too large"));
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function parseBody(req: http.IncomingMessage): Promise<BridgeInboundRequest> {
|
||||
return parseBodyRaw(req) as Promise<BridgeInboundRequest>;
|
||||
}
|
||||
|
||||
|
||||
const _G = globalThis as Record<string, unknown>;
|
||||
|
||||
export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
||||
const { port, apiKey, permissionMode, resolveAgent, logger } = config;
|
||||
|
||||
async function handleChatCompletions(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
): Promise<void> {
|
||||
let body: BridgeInboundRequest;
|
||||
try {
|
||||
body = await parseBody(req);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "invalid_json" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract agent ID and workspace from the system prompt's Runtime line.
|
||||
// OpenClaw does NOT send agent/session info as HTTP headers — it's in the system prompt.
|
||||
const { agentId: parsedAgentId, workspace: parsedWorkspace, skillsBlock, workspaceContextFiles } = extractRequestContext(body);
|
||||
const latestMessage = extractLatestUserMessage(body);
|
||||
|
||||
if (!latestMessage) {
|
||||
sendJson(res, 400, { error: "no user message found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Use agentId as session key — one persistent Claude session per agent (v1).
|
||||
const agentId = parsedAgentId;
|
||||
const sessionKey = agentId; // stable per-agent key
|
||||
|
||||
logger.info(
|
||||
`[contractor-bridge] turn agentId=${agentId} workspace=${parsedWorkspace} msg=${latestMessage.substring(0, 80)}`,
|
||||
);
|
||||
|
||||
// Resolve workspace: prefer what we parsed from the system prompt (most accurate);
|
||||
// fall back to openclaw.json lookup for validation.
|
||||
let workspace = parsedWorkspace;
|
||||
if (!workspace && agentId) {
|
||||
const agentMeta = resolveAgent?.(agentId, sessionKey);
|
||||
if (agentMeta) workspace = agentMeta.workspace;
|
||||
}
|
||||
if (!workspace) {
|
||||
logger.warn(`[contractor-bridge] could not resolve workspace agentId=${agentId}`);
|
||||
workspace = "/tmp";
|
||||
}
|
||||
|
||||
// Detect backend from body.model: "contractor-gemini-bridge" → Gemini, else → Claude
|
||||
const isGemini = typeof body.model === "string" && body.model.includes("gemini");
|
||||
|
||||
// Look up existing session (shared structure for both Claude and Gemini)
|
||||
let existingEntry = sessionKey ? getSession(workspace, sessionKey) : null;
|
||||
let resumeSessionId = existingEntry?.state === "active" ? existingEntry.claudeSessionId : null;
|
||||
|
||||
// Bootstrap is passed as the system prompt on every turn (stateless — not persisted in session files).
|
||||
// For Claude: --system-prompt fully replaces any prior system prompt each invocation.
|
||||
// For Gemini: written to workspace/GEMINI.md, read dynamically by Gemini CLI each turn.
|
||||
// This keeps persona and skills current without needing to track first-turn state.
|
||||
const systemPrompt = buildBootstrap({
|
||||
agentId,
|
||||
openclawSessionKey: sessionKey,
|
||||
workspace,
|
||||
skillsBlock: skillsBlock || undefined,
|
||||
workspaceContextFiles,
|
||||
});
|
||||
|
||||
// Start SSE response
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Transfer-Encoding": "chunked",
|
||||
});
|
||||
|
||||
const completionId = `chatcmpl-bridge-${randomUUID().slice(0, 8)}`;
|
||||
let newSessionId = "";
|
||||
let hasError = false;
|
||||
|
||||
const openclawTools = body.tools ?? [];
|
||||
|
||||
try {
|
||||
const dispatchIter = isGemini
|
||||
? dispatchToGemini({
|
||||
prompt: latestMessage,
|
||||
systemPrompt,
|
||||
workspace,
|
||||
agentId,
|
||||
resumeSessionId: resumeSessionId ?? undefined,
|
||||
openclawTools,
|
||||
bridgePort: port,
|
||||
bridgeApiKey: apiKey,
|
||||
})
|
||||
: dispatchToClaude({
|
||||
prompt: latestMessage,
|
||||
systemPrompt,
|
||||
workspace,
|
||||
agentId,
|
||||
resumeSessionId: resumeSessionId ?? undefined,
|
||||
permissionMode,
|
||||
openclawTools,
|
||||
bridgePort: port,
|
||||
bridgeApiKey: apiKey,
|
||||
});
|
||||
|
||||
for await (const event of dispatchIter) {
|
||||
if (event.type === "text") {
|
||||
sseWrite(res, buildChunk(completionId, event.text));
|
||||
} else if (event.type === "done") {
|
||||
newSessionId = event.sessionId;
|
||||
} else if (event.type === "error") {
|
||||
logger.warn(`[contractor-bridge] ${isGemini ? "gemini" : "claude"} error: ${event.message}`);
|
||||
hasError = true;
|
||||
sseWrite(res, buildChunk(completionId, `[contractor-bridge error: ${event.message}]`));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[contractor-bridge] dispatch error: ${String(err)}`);
|
||||
hasError = true;
|
||||
sseWrite(res, buildChunk(completionId, `[contractor-bridge dispatch failed: ${String(err)}]`));
|
||||
}
|
||||
|
||||
sseWrite(res, buildStopChunk(completionId));
|
||||
sseWrite(res, "[DONE]");
|
||||
res.end();
|
||||
|
||||
// Persist session mapping (shared for both Claude and Gemini)
|
||||
if (newSessionId && sessionKey && !hasError) {
|
||||
const now = new Date().toISOString();
|
||||
putSession(workspace, {
|
||||
openclawSessionKey: sessionKey,
|
||||
agentId,
|
||||
contractor: isGemini ? "gemini" : "claude",
|
||||
claudeSessionId: newSessionId,
|
||||
workspace,
|
||||
createdAt: existingEntry?.createdAt ?? now,
|
||||
lastActivityAt: now,
|
||||
state: "active",
|
||||
});
|
||||
logger.info(
|
||||
`[contractor-bridge] session mapped sessionKey=${sessionKey} contractor=${isGemini ? "gemini" : "claude"} sessionId=${newSessionId}`,
|
||||
);
|
||||
} else if (hasError && sessionKey && existingEntry) {
|
||||
markOrphaned(workspace, sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = req.url ?? "/";
|
||||
const method = req.method ?? "GET";
|
||||
|
||||
// Auth check
|
||||
if (apiKey) {
|
||||
const auth = req.headers.authorization ?? "";
|
||||
if (auth !== `Bearer ${apiKey}`) {
|
||||
sendJson(res, 401, { error: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (method === "GET" && url === "/health") {
|
||||
sendJson(res, 200, { ok: true, service: "contractor-bridge" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url === "/v1/models") {
|
||||
sendJson(res, 200, {
|
||||
object: "list",
|
||||
data: [
|
||||
{
|
||||
id: "contractor-claude-bridge",
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: "contractor-agent",
|
||||
},
|
||||
{
|
||||
id: "contractor-gemini-bridge",
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: "contractor-agent",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && url === "/v1/chat/completions") {
|
||||
try {
|
||||
await handleChatCompletions(req, res);
|
||||
} catch (err) {
|
||||
logger.warn(`[contractor-bridge] unhandled error: ${String(err)}`);
|
||||
if (!res.headersSent) {
|
||||
sendJson(res, 500, { error: "internal server error" });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug: list registered tools in the global plugin registry
|
||||
if (method === "GET" && url === "/debug/tools") {
|
||||
const pluginRegistry = getGlobalPluginRegistry();
|
||||
const liveConfig = _G["_contractorOpenClawConfig"] ?? null;
|
||||
if (!pluginRegistry) {
|
||||
sendJson(res, 200, { error: "registry not available" });
|
||||
} else {
|
||||
sendJson(res, 200, {
|
||||
configAvailable: liveConfig !== null,
|
||||
tools: pluginRegistry.tools.map((t) => ({ names: t.names, pluginId: t.pluginId })),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// MCP proxy tool execution callback.
|
||||
// Called by services/openclaw-mcp-server.mjs when Claude Code invokes an MCP tool.
|
||||
// Relays the call to OpenClaw's actual tool implementation via the global plugin registry.
|
||||
if (method === "POST" && url === "/mcp/execute") {
|
||||
let body: { tool?: string; args?: Record<string, unknown>; workspace?: string; agentId?: string };
|
||||
try {
|
||||
body = await parseBodyRaw(req) as { tool?: string; args?: Record<string, unknown>; workspace?: string; agentId?: string };
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "invalid_json" });
|
||||
return;
|
||||
}
|
||||
const toolName = body.tool ?? "";
|
||||
const toolArgs = body.args ?? {};
|
||||
const execWorkspace = body.workspace ?? "";
|
||||
const execAgentId = body.agentId ?? "";
|
||||
logger.info(`[contractor-bridge] mcp/execute tool=${toolName} workspace=${execWorkspace} agentId=${execAgentId}`);
|
||||
|
||||
// Relay to OpenClaw's actual tool implementation via the global plugin registry.
|
||||
// This avoids reimplementing tool logic in the bridge — we call the real tool factory.
|
||||
try {
|
||||
const pluginRegistry = getGlobalPluginRegistry();
|
||||
if (!pluginRegistry) {
|
||||
sendJson(res, 200, { error: `[mcp/execute] plugin registry not available (tool=${toolName})` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the tool registration by name
|
||||
const toolReg = pluginRegistry.tools.find((t) => t.names.includes(toolName));
|
||||
if (!toolReg) {
|
||||
sendJson(res, 200, { error: `Tool '${toolName}' not registered in OpenClaw plugin registry` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build tool execution context.
|
||||
// config is required by memory tools (resolveMemoryToolContext checks it).
|
||||
// Read from globalThis where index.ts stores api.config on every registration.
|
||||
// sessionKey must be in OpenClaw's canonical format "agent:<agentId>:<rest>" so that
|
||||
// parseAgentSessionKey() can extract the agentId for memory tool context resolution.
|
||||
const liveConfig = (_G["_contractorOpenClawConfig"] ?? null) as Record<string, unknown> | null;
|
||||
// Construct a canonical session key so memory tools can resolve the agentId from it.
|
||||
// Format: "agent:<agentId>:direct:bridge"
|
||||
const canonicalSessionKey = execAgentId ? `agent:${execAgentId}:direct:bridge` : undefined;
|
||||
const toolCtx = {
|
||||
config: liveConfig ?? undefined,
|
||||
workspaceDir: execWorkspace || undefined,
|
||||
agentDir: execWorkspace || undefined,
|
||||
agentId: execAgentId || undefined,
|
||||
sessionKey: canonicalSessionKey,
|
||||
};
|
||||
|
||||
// Instantiate the tool via its factory
|
||||
const toolOrTools = toolReg.factory(toolCtx);
|
||||
const toolInstance = Array.isArray(toolOrTools)
|
||||
? toolOrTools.find((t) => (t as { name?: string }).name === toolName)
|
||||
: toolOrTools;
|
||||
|
||||
if (!toolInstance || typeof (toolInstance as { execute?: unknown }).execute !== "function") {
|
||||
sendJson(res, 200, { error: `Tool '${toolName}' factory returned no executable tool` });
|
||||
return;
|
||||
}
|
||||
|
||||
const execFn = (toolInstance as { execute: (id: string, args: unknown) => Promise<{ content?: Array<{ type: string; text?: string }> }> }).execute;
|
||||
const toolResult = await execFn(randomUUID(), toolArgs);
|
||||
|
||||
// Extract text content from AgentToolResult
|
||||
const text = (toolResult.content ?? [])
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text as string)
|
||||
.join("\n");
|
||||
|
||||
sendJson(res, 200, { result: text || "(no result)" });
|
||||
} catch (err) {
|
||||
logger.warn(`[contractor-bridge] mcp/execute error tool=${toolName}: ${String(err)}`);
|
||||
sendJson(res, 200, { error: `Tool execution failed: ${String(err)}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: "not_found" });
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
logger.info(`[contractor-bridge] sidecar listening on 127.0.0.1:${port}`);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
Reference in New Issue
Block a user