From 5b05e91d4e0f47395dcc39f37d205fe6826bc856 Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 8 May 2026 07:36:00 +0000 Subject: [PATCH] fix: don't block one-shot openclaw subcommands; migrate to current plugin SDK Sidecar lifecycle: - Move startSideCar() 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 spawning a long-lived process there hung `openclaw update`'s post-update steps. - Spawn the sidecar with detached: true, stdio routed to a log file fd, and call .unref() so the host's event loop is never held by the child. Even if a future caller invokes startSideCar in a non-gateway context, it can no longer block that host from exiting. - Sidecar logs now go to ~/.openclaw/logs/dirigent-sidecar.log instead of being piped through the host logger. Plugin SDK convention update: - Wrap default export with definePluginEntry({ id, name, description, register }) per the current openclaw plugin authoring contract. - Switch all imports from the deprecated root barrel "openclaw/plugin-sdk" to focused subpaths "openclaw/plugin-sdk/core" and "openclaw/plugin-sdk/plugin-entry". - Modernize openclaw.plugin.json: drop entry/version, add description, declare contracts.tools[] for the 6 tools, set activation.onStartup: true so gateway_start fires for this plugin at boot. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugin/commands/add-guild-command.ts | 2 +- plugin/commands/set-channel-mode-command.ts | 2 +- plugin/core/channel-members.ts | 2 +- plugin/core/moderator-discord.ts | 2 +- plugin/core/sidecar-process.ts | 28 ++++++++++---- plugin/hooks/agent-end.ts | 2 +- plugin/hooks/before-model-resolve.ts | 2 +- plugin/hooks/message-received.ts | 2 +- plugin/index.ts | 42 ++++++++++++--------- plugin/openclaw.plugin.json | 15 +++++++- plugin/tools/register-tools.ts | 2 +- plugin/web/control-page.ts | 2 +- plugin/web/dirigent-api.ts | 2 +- 13 files changed, 67 insertions(+), 38 deletions(-) diff --git a/plugin/commands/add-guild-command.ts b/plugin/commands/add-guild-command.ts index e3825ee..b990267 100644 --- a/plugin/commands/add-guild-command.ts +++ b/plugin/commands/add-guild-command.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { execFileSync } from "node:child_process"; import path from "node:path"; import os from "node:os"; diff --git a/plugin/commands/set-channel-mode-command.ts b/plugin/commands/set-channel-mode-command.ts index 32f8f45..2ce5abc 100644 --- a/plugin/commands/set-channel-mode-command.ts +++ b/plugin/commands/set-channel-mode-command.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import type { ChannelStore, ChannelMode } from "../core/channel-store.js"; import { parseDiscordChannelIdFromCommand } from "./command-utils.js"; diff --git a/plugin/core/channel-members.ts b/plugin/core/channel-members.ts index 70bfb19..0b9ae72 100644 --- a/plugin/core/channel-members.ts +++ b/plugin/core/channel-members.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import type { IdentityRegistry } from "./identity-registry.js"; const PERM_VIEW_CHANNEL = 1n << 10n; diff --git a/plugin/core/moderator-discord.ts b/plugin/core/moderator-discord.ts index 0a8989f..b83f233 100644 --- a/plugin/core/moderator-discord.ts +++ b/plugin/core/moderator-discord.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; type Logger = { info: (m: string) => void; warn: (m: string) => void }; diff --git a/plugin/core/sidecar-process.ts b/plugin/core/sidecar-process.ts index 4d1820e..cfbef3c 100644 --- a/plugin/core/sidecar-process.ts +++ b/plugin/core/sidecar-process.ts @@ -6,6 +6,7 @@ import { spawn, type ChildProcess } from "node:child_process"; let noReplyProcess: ChildProcess | null = null; const LOCK_FILE = path.join(os.tmpdir(), "dirigent-sidecar.lock"); +const LOG_FILE = path.join(os.homedir(), ".openclaw", "logs", "dirigent-sidecar.log"); function readLock(): { pid: number } | null { try { @@ -88,18 +89,29 @@ export function startSideCar( env.DEBUG_MODE = debugMode ? "true" : "false"; } - noReplyProcess = spawn(process.execPath, [serverPath], { - env, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }); + // Spawn so the host process is never blocked from exiting: + // - detached: sidecar is its own process group leader + // - stdio piped to a log file fd (no parent-side pipes to drain) + // - unref(): Node's event loop will not wait on this child + // stopSideCar() still works because we keep a ChildProcess handle for + // signalling, but the OS lifecycle of the sidecar is decoupled from us. + fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true }); + const logFd = fs.openSync(LOG_FILE, "a"); + try { + noReplyProcess = spawn(process.execPath, [serverPath], { + env, + stdio: ["ignore", logFd, logFd], + detached: true, + }); + } finally { + fs.closeSync(logFd); + } if (noReplyProcess.pid) { writeLock(noReplyProcess.pid); } - noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: services: ${d.toString().trim()}`)); - noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: services: ${d.toString().trim()}`)); + noReplyProcess.unref(); noReplyProcess.on("exit", (code, signal) => { logger.info(`dirigent: services exited (code=${code}, signal=${signal})`); @@ -107,7 +119,7 @@ export function startSideCar( noReplyProcess = null; }); - logger.info(`dirigent: services started (pid=${noReplyProcess.pid}, port=${port})`); + logger.info(`dirigent: services started (pid=${noReplyProcess.pid}, port=${port}, log=${LOG_FILE})`); } export function stopSideCar(logger: { info: (m: string) => void }): void { diff --git a/plugin/hooks/agent-end.ts b/plugin/hooks/agent-end.ts index 3c01724..9f36308 100644 --- a/plugin/hooks/agent-end.ts +++ b/plugin/hooks/agent-end.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import type { ChannelStore } from "../core/channel-store.js"; import type { IdentityRegistry } from "../core/identity-registry.js"; import { parseDiscordChannelId } from "./before-model-resolve.js"; diff --git a/plugin/hooks/before-model-resolve.ts b/plugin/hooks/before-model-resolve.ts index f092e12..0d49dd4 100644 --- a/plugin/hooks/before-model-resolve.ts +++ b/plugin/hooks/before-model-resolve.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import type { ChannelStore } from "../core/channel-store.js"; import type { IdentityRegistry } from "../core/identity-registry.js"; import { isCurrentSpeaker, isTurnPending, setAnchor, hasSpeakers, isDormant, setSpeakerList, markTurnStarted, incrementBlockedPending, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js"; diff --git a/plugin/hooks/message-received.ts b/plugin/hooks/message-received.ts index cc7e898..42ad27b 100644 --- a/plugin/hooks/message-received.ts +++ b/plugin/hooks/message-received.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import type { ChannelStore } from "../core/channel-store.js"; import type { IdentityRegistry } from "../core/identity-registry.js"; import { parseDiscordChannelId } from "./before-model-resolve.js"; diff --git a/plugin/index.ts b/plugin/index.ts index 63762f2..55af848 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,6 +1,7 @@ import path from "node:path"; import os from "node:os"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { IdentityRegistry } from "./core/identity-registry.js"; import { ChannelStore } from "./core/channel-store.js"; import { scanPaddedCell } from "./core/padded-cell.js"; @@ -75,9 +76,10 @@ function markGatewayLifecycleRegistered(): void { _G[_GATEWAY_LIFECYCLE_KEY] = true; } -export default { +export default definePluginEntry({ id: "dirigent", name: "Dirigent", + description: "Rule-based no-reply gate with provider/model override and turn management", register(api: OpenClawPluginApi) { const config = normalizeConfig(api); const pluginDir = path.dirname(new URL(import.meta.url).pathname); @@ -108,22 +110,26 @@ export default { if (!isGatewayLifecycleRegistered()) { markGatewayLifecycleRegistered(); - const gatewayPort = getGatewayPort(api); + // Sidecar must only be started when the gateway actually boots. + // register() also runs inside one-shot CLI subprocesses that load + // plugins (e.g. `openclaw completion`, `openclaw doctor`); spawning a + // long-lived service from those would block them from exiting. + api.on("gateway_start", () => { + const gatewayPort = getGatewayPort(api); + startSideCar( + api.logger, + pluginDir, + config.sideCarPort, + moderatorBotToken, + undefined, // pluginApiToken — gateway handles auth for plugin routes + gatewayPort, + config.debugMode, + ); - // Start unified services (no-reply API + moderator bot) - startSideCar( - api.logger, - pluginDir, - config.sideCarPort, - moderatorBotToken, - undefined, // pluginApiToken — gateway handles auth for plugin routes - gatewayPort, - config.debugMode, - ); - - if (!moderatorBotToken) { - api.logger.info("dirigent: moderatorBotToken not set — moderator features disabled"); - } + if (!moderatorBotToken) { + api.logger.info("dirigent: moderatorBotToken not set — moderator features disabled"); + } + }); tryAutoScanPaddedCell(); @@ -296,4 +302,4 @@ export default { api.logger.info("dirigent: plugin registered (v2)"); }, -}; +}); diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index cab417c..353c56b 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -1,9 +1,20 @@ { "id": "dirigent", "name": "Dirigent", - "version": "0.3.0", "description": "Rule-based no-reply gate with provider/model override and turn management", - "entry": "./index.ts", + "activation": { + "onStartup": true + }, + "contracts": { + "tools": [ + "dirigent-register", + "create-chat-channel", + "create-report-channel", + "create-work-channel", + "create-discussion-channel", + "discussion-complete" + ] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/plugin/tools/register-tools.ts b/plugin/tools/register-tools.ts index 03f4a43..d6379f2 100644 --- a/plugin/tools/register-tools.ts +++ b/plugin/tools/register-tools.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import type { ChannelStore } from "../core/channel-store.js"; import type { IdentityRegistry } from "../core/identity-registry.js"; import { createDiscordChannel, getBotUserIdFromToken } from "../core/moderator-discord.js"; diff --git a/plugin/web/control-page.ts b/plugin/web/control-page.ts index ce65a1f..055b373 100644 --- a/plugin/web/control-page.ts +++ b/plugin/web/control-page.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import type { ChannelStore, ChannelMode } from "../core/channel-store.js"; import type { IdentityRegistry } from "../core/identity-registry.js"; import { fetchAdminGuilds, fetchGuildChannels } from "../core/moderator-discord.js"; diff --git a/plugin/web/dirigent-api.ts b/plugin/web/dirigent-api.ts index 61ba949..c017a56 100644 --- a/plugin/web/dirigent-api.ts +++ b/plugin/web/dirigent-api.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import type { ChannelStore } from "../core/channel-store.js"; type Deps = {