import fs from "node:fs"; import path from "node:path"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; 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"; // ── 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; 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 definePluginEntry({ id: "contractor-agent", name: "Contractor Agent", description: "Turns Claude Code into an OpenClaw-managed contractor agent", // OpenClaw requires register() to be synchronous — returning a Promise // surfaces as `Error: plugin register must be synchronous` and the plugin // ends up in `error` state. We avoid `await` here and instead let the // bridge server bind asynchronously, handling EADDRINUSE via the server's // `error` event when another gateway/CLI process already owns the port. register(api: OpenClawPluginApi): void { 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) ────── // 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; // Bind the bridge server only when the gateway boots, NOT eagerly at // register-time. register() also runs in one-shot CLI subprocesses // (e.g. `openclaw completion`, `openclaw doctor`); spawning a long- // lived listener there would prevent those commands from exiting. api.on("gateway_start", () => { const server = createBridgeServer({ port: config.bridgePort, apiKey: config.bridgeApiKey, permissionMode: config.permissionMode, resolveAgent, logger: api.logger, }); // EADDRINUSE → another gateway already owns the port; fine, skip bind. server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { api.logger.info( `[contractor-agent] bridge already running on port ${config.bridgePort}, skipping bind`, ); return; } api.logger.warn(`[contractor-agent] bridge server error: ${err.message ?? String(err)}`); }); // Defense in depth: even if this code path is somehow reached outside // the gateway, .unref() prevents the listener from pinning the host's // event loop and blocking process exit. server.unref(); _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})`); }, });