From 5fca8f5da100841d55988581ff9f4437cca7a082 Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 8 May 2026 07:54:11 +0000 Subject: [PATCH] fix: don't block one-shot openclaw subcommands; migrate to current plugin SDK Bridge server lifecycle: - Move createBridgeServer() out of register() into an api.on("gateway_start", ...) handler. register() runs in every CLI subprocess that loads plugins (e.g. `openclaw completion`, `openclaw doctor`); eagerly binding the bridge HTTP listener there could pin those processes when no gateway is already holding the port. - Call server.unref() so the listener never pins the host's event loop, even if startup somehow runs outside the gateway. Plugin SDK convention update: - Wrap default export with definePluginEntry({ id, name, description, register }) per the current openclaw plugin authoring contract. - Switch imports from the deprecated root barrel "openclaw/plugin-sdk" to focused "openclaw/plugin-sdk/core" / "openclaw/plugin-sdk/plugin-entry". - Modernize openclaw.plugin.json: drop version/main, add activation.onStartup so gateway_start fires for this plugin at boot, declare commandAliases for the contractor-agents CLI command. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugin/commands/register-cli.ts | 2 +- plugin/index.ts | 57 ++++++++++++++++++++------------- plugin/openclaw.plugin.json | 8 +++-- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/plugin/commands/register-cli.ts b/plugin/commands/register-cli.ts index 41daa33..b6a0f75 100644 --- a/plugin/commands/register-cli.ts +++ b/plugin/commands/register-cli.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { runContractorAgentsAdd } from "./contractor-agents-add.js"; export function registerCli(api: OpenClawPluginApi): void { diff --git a/plugin/index.ts b/plugin/index.ts index 35a121c..253e768 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +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"; @@ -19,9 +20,10 @@ const OPENCLAW_CONFIG_KEY = "_contractorOpenClawConfig"; // ── Plugin entry ───────────────────────────────────────────────────────────── -export default { +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 @@ -60,28 +62,37 @@ export default { if (!_G[LIFECYCLE_KEY]) { _G[LIFECYCLE_KEY] = true; - const server = createBridgeServer({ - port: config.bridgePort, - apiKey: config.bridgeApiKey, - permissionMode: config.permissionMode, - resolveAgent, - logger: api.logger, - }); + // 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/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)}`); - }); + // 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)}`); + }); - _G[SERVER_KEY] = server; + // 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; @@ -95,4 +106,4 @@ export default { api.logger.info(`[contractor-agent] plugin registered (bridge port: ${config.bridgePort})`); }, -}; +}); diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 7e5225c..0b37113 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -1,9 +1,13 @@ { "id": "contractor-agent", "name": "Contractor Agent", - "version": "0.1.0", "description": "Turns Claude Code into an OpenClaw-managed contractor agent", - "main": "index.ts", + "activation": { + "onStartup": true + }, + "commandAliases": [ + { "name": "contractor-agents" } + ], "configSchema": { "type": "object", "additionalProperties": false,