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 { 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; 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})`); }, };