import path from "node:path"; import os from "node:os"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { IdentityRegistry } from "./core/identity-registry.js"; import { ChannelStore } from "./core/channel-store.js"; import { scanPaddedCell } from "./core/padded-cell.js"; import { startSideCar, stopSideCar } from "./core/sidecar-process.js"; import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js"; import { registerAgentEndHook } from "./hooks/agent-end.js"; import { registerMessageReceivedHook } from "./hooks/message-received.js"; import { registerDirigentTools } from "./tools/register-tools.js"; import { registerSetChannelModeCommand } from "./commands/set-channel-mode-command.js"; import { registerAddGuildCommand } from "./commands/add-guild-command.js"; import { registerControlPage } from "./web/control-page.js"; import { registerDirigentApi } from "./web/dirigent-api.js"; import { sendModeratorMessage, sendScheduleTrigger, getBotUserIdFromToken } from "./core/moderator-discord.js"; import { setSpeakerList, isCurrentSpeaker, isDormant, wakeFromDormant } from "./turn-manager.js"; import { fetchVisibleChannelBotAccountIds } from "./core/channel-members.js"; type PluginConfig = { moderatorBotToken?: string; scheduleIdentifier?: string; identityFilePath?: string; channelStoreFilePath?: string; debugMode?: boolean; noReplyProvider?: string; noReplyModel?: string; sideCarPort?: number; }; function normalizeConfig(api: OpenClawPluginApi): Required { const cfg = (api.pluginConfig ?? {}) as PluginConfig; return { moderatorBotToken: cfg.moderatorBotToken ?? "", scheduleIdentifier: cfg.scheduleIdentifier ?? "➡️", identityFilePath: cfg.identityFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-identity.json"), channelStoreFilePath: cfg.channelStoreFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-channels.json"), debugMode: cfg.debugMode ?? false, noReplyProvider: cfg.noReplyProvider ?? "dirigent", noReplyModel: cfg.noReplyModel ?? "no-reply", sideCarPort: cfg.sideCarPort ?? 8787, }; } function getGatewayPort(api: OpenClawPluginApi): number { try { return ((api.config as Record)?.gateway as Record)?.port as number ?? 18789; } catch { return 18789; } } /** * Gateway lifecycle events (gateway_start / gateway_stop) are global — fired once * when the gateway process starts/stops, not per agent session. We guard these on * globalThis so only the first register() call adds the lifecycle handlers. * * Agent-session events (before_model_resolve, agent_end, message_received) are * delivered via the api instance that belongs to each individual agent session. * OpenClaw creates a new VM context (and calls register() again) for each hot-reload * within a session. We register those handlers unconditionally — event-level dedup * (WeakSet / runId Set, also stored on globalThis) prevents double-processing. * * All VM contexts share the real globalThis because they run in the same Node.js * process as openclaw-gateway. */ const _G = globalThis as Record; const _GATEWAY_LIFECYCLE_KEY = "_dirigentGatewayLifecycleRegistered"; function isGatewayLifecycleRegistered(): boolean { return !!_G[_GATEWAY_LIFECYCLE_KEY]; } function markGatewayLifecycleRegistered(): void { _G[_GATEWAY_LIFECYCLE_KEY] = true; } export default { id: "dirigent", name: "Dirigent", register(api: OpenClawPluginApi) { const config = normalizeConfig(api); const pluginDir = path.dirname(new URL(import.meta.url).pathname); const openclawDir = path.join(os.homedir(), ".openclaw"); const identityRegistry = new IdentityRegistry(config.identityFilePath); const channelStore = new ChannelStore(config.channelStoreFilePath); const moderatorBotToken = config.moderatorBotToken || undefined; const moderatorBotUserId = moderatorBotToken ? getBotUserIdFromToken(moderatorBotToken) : undefined; const moderatorServiceUrl = `http://127.0.0.1:${config.sideCarPort}/moderator`; let paddedCellDetected = false; function hasPaddedCell(): boolean { return paddedCellDetected; } function tryAutoScanPaddedCell(): void { const count = scanPaddedCell(identityRegistry, openclawDir, api.logger); paddedCellDetected = count >= 0; if (paddedCellDetected) { api.logger.info(`dirigent: padded-cell detected — ${count} identity entries auto-registered`); } } // ── Gateway lifecycle (once per gateway process) ─────────────────────── if (!isGatewayLifecycleRegistered()) { markGatewayLifecycleRegistered(); const gatewayPort = getGatewayPort(api); // 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"); } tryAutoScanPaddedCell(); api.on("gateway_stop", () => { stopSideCar(api.logger); }); } // ── Hooks (registered on every api instance — event-level dedup handles duplicates) ── registerBeforeModelResolveHook({ api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier: config.scheduleIdentifier, debugMode: config.debugMode, noReplyProvider: config.noReplyProvider, noReplyModel: config.noReplyModel, }); const interruptTailMatch = registerAgentEndHook({ api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier: config.scheduleIdentifier, debugMode: config.debugMode, onDiscussionDormant: async (channelId: string) => { const live = normalizeConfig(api); if (!live.moderatorBotToken) return; const rec = channelStore.getRecord(channelId); if (!rec.discussion || rec.discussion.concluded) return; const initiatorEntry = identityRegistry.findByAgentId(rec.discussion.initiatorAgentId); const mention = initiatorEntry ? `<@${initiatorEntry.discordUserId}>` : rec.discussion.initiatorAgentId; await sendModeratorMessage( live.moderatorBotToken, channelId, `${mention} Discussion is idle. Please summarize the results and call \`discussion-complete\`.`, api.logger, ).catch(() => undefined); }, }); // Speaker-list init still handled via message_received (needs OpenClaw API for channel member lookup) registerMessageReceivedHook({ api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier: config.scheduleIdentifier, interruptTailMatch, debugMode: config.debugMode, // When moderator service is active it handles wake/interrupt via HTTP callback; // message_received only needs to run speaker-list initialization. moderatorHandlesMessages: !!moderatorBotToken, }); // ── Dirigent API (moderator service → plugin callbacks) ─────────────── registerDirigentApi({ api, channelStore, moderatorBotUserId, scheduleIdentifier: config.scheduleIdentifier, moderatorServiceUrl, moderatorServiceToken: undefined, debugMode: config.debugMode, onNewMessage: async ({ channelId, senderId }) => { const mode = channelStore.getMode(channelId); // Modes where agents don't participate if (mode === "none" || mode === "work" || mode === "report") return; // Skip messages from the moderator bot itself (schedule triggers, etc.) if (senderId === moderatorBotUserId) return; // Concluded discussion: send "closed" reply via moderator service if (mode === "discussion") { const rec = channelStore.getRecord(channelId); if (rec.discussion?.concluded && moderatorBotToken) { await sendModeratorMessage( moderatorBotToken, channelId, "This discussion is closed and no longer active.", api.logger, ).catch(() => undefined); return; } } // Identify sender — is it the current speaker? const senderEntry = identityRegistry.findByDiscordUserId(senderId); const currentSpeakerIsThisSender = senderEntry ? isCurrentSpeaker(channelId, senderEntry.agentId) : false; if (!currentSpeakerIsThisSender) { // Non-current-speaker: interrupt any ongoing tail-match poll interruptTailMatch(channelId); api.logger.info(`dirigent: moderator-callback interrupt tail-match channel=${channelId} senderId=${senderId}`); // Wake from dormant if needed if (isDormant(channelId) && moderatorBotToken) { const first = wakeFromDormant(channelId); if (first) { const msg = `<@${first.discordUserId}>${config.scheduleIdentifier}`; await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, config.debugMode); api.logger.info(`dirigent: moderator-callback woke dormant channel=${channelId} first=${first.agentId}`); } } } }, }); // ── Tools ────────────────────────────────────────────────────────────── registerDirigentTools({ api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier: config.scheduleIdentifier, onDiscussionCreate: async ({ channelId, initiatorAgentId, callbackGuildId, callbackChannelId, discussionGuide, participants }) => { const live = normalizeConfig(api); if (!live.moderatorBotToken) return; // Post discussion-guide to wake participants await sendModeratorMessage(live.moderatorBotToken, channelId, discussionGuide, api.logger) .catch(() => undefined); // Initialize speaker list const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry); const speakers = agentIds .map((aid) => { const entry = identityRegistry.findByAgentId(aid); return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null; }) .filter((s): s is NonNullable => s !== null); if (speakers.length > 0) { setSpeakerList(channelId, speakers); const first = speakers[0]; await sendScheduleTrigger( live.moderatorBotToken, channelId, `<@${first.discordUserId}>${live.scheduleIdentifier}`, api.logger, live.debugMode, ).catch(() => undefined); } }, }); // ── Commands ─────────────────────────────────────────────────────────── registerSetChannelModeCommand({ api, channelStore }); registerAddGuildCommand(api); // ── Control page ─────────────────────────────────────────────────────── registerControlPage({ api, channelStore, identityRegistry, moderatorBotToken, openclawDir, hasPaddedCell, }); api.logger.info("dirigent: plugin registered (v2)"); }, };