From 8e267441624336da706052f1b65eb5481a1edc15 Mon Sep 17 00:00:00 2001 From: zhi Date: Tue, 3 Mar 2026 16:01:34 +0000 Subject: [PATCH] feat: lifecycle management - no-reply API + moderator bot follow gateway start/stop - Added gateway_start hook: spawns no-reply API as child process, then starts moderator bot - Added gateway_stop hook: kills no-reply API process, stops moderator bot - No-reply API server.mjs is located relative to plugin dir via import.meta.url - Moderator presence moved from register() to gateway_start for proper lifecycle --- plugin/index.ts | 64 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 6df4b4c..eb2748e 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,10 +1,50 @@ import fs from "node:fs"; import path from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } from "./rules.js"; import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js"; import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; +// ── No-Reply API child process lifecycle ────────────────────────────── +let noReplyProcess: ChildProcess | null = null; + +function startNoReplyApi(logger: { info: (m: string) => void; warn: (m: string) => void }, pluginDir: string, port = 8787): void { + if (noReplyProcess) { + logger.info("dirigent: no-reply API already running, skipping"); + return; + } + + const serverPath = path.resolve(pluginDir, "..", "no-reply-api", "server.mjs"); + if (!fs.existsSync(serverPath)) { + logger.warn(`dirigent: no-reply API server not found at ${serverPath}, skipping`); + return; + } + + noReplyProcess = spawn(process.execPath, [serverPath], { + env: { ...process.env, PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + + noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: no-reply-api: ${d.toString().trim()}`)); + noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: no-reply-api: ${d.toString().trim()}`)); + + noReplyProcess.on("exit", (code, signal) => { + logger.info(`dirigent: no-reply API exited (code=${code}, signal=${signal})`); + noReplyProcess = null; + }); + + logger.info(`dirigent: no-reply API started (pid=${noReplyProcess.pid}, port=${port})`); +} + +function stopNoReplyApi(logger: { info: (m: string) => void }): void { + if (!noReplyProcess) return; + logger.info("dirigent: stopping no-reply API"); + noReplyProcess.kill("SIGTERM"); + noReplyProcess = null; +} + type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; type DecisionRecord = { @@ -467,11 +507,25 @@ export default { const liveAtRegister = getLivePluginConfig(api, baseConfig as DirigentConfig); ensurePolicyStateLoaded(api, liveAtRegister); - // Start moderator bot presence (keep it "online" on Discord) - if (liveAtRegister.moderatorBotToken) { - startModeratorPresence(liveAtRegister.moderatorBotToken, api.logger); - api.logger.info("dirigent: moderator bot presence starting"); - } + // Resolve plugin directory for locating sibling modules (no-reply-api/) + const pluginDir = path.dirname(new URL(import.meta.url).pathname); + + // Gateway lifecycle: start/stop no-reply API and moderator bot with the gateway + api.on("gateway_start", () => { + startNoReplyApi(api.logger, pluginDir); + + const live = getLivePluginConfig(api, baseConfig as DirigentConfig); + if (live.moderatorBotToken) { + startModeratorPresence(live.moderatorBotToken, api.logger); + api.logger.info("dirigent: moderator bot presence starting"); + } + }); + + api.on("gateway_stop", () => { + stopNoReplyApi(api.logger); + stopModeratorPresence(); + api.logger.info("dirigent: gateway stopping, services shut down"); + }); api.registerTool( {