import fs from "node:fs"; 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"; // ── 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 { id: "contractor-agent", name: "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; const server = createBridgeServer({ port: config.bridgePort, apiKey: config.bridgeApiKey, permissionMode: config.permissionMode, resolveAgent, logger: api.logger, }); // EADDRINUSE → another gateway/CLI process already owns the port; that's // fine, we just don't double-bind. Any other error is logged but does // not crash registration. 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)}`); }); _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})`); }, };