Complete rewrite of the Dirigent plugin turn management system to work correctly with OpenClaw's VM-context-per-session architecture: - All turn state stored on globalThis (persists across VM context hot-reloads) - Hooks registered unconditionally on every api instance; event-level dedup (runId Set for agent_end, WeakSet for before_model_resolve) prevents double-processing - Gateway lifecycle events (gateway_start/stop) guarded once via globalThis flag - Shared initializingChannels lock prevents concurrent channel init across VM contexts in message_received and before_model_resolve - New ChannelStore and IdentityRegistry replace old policy/session-state modules - Added agent_end hook with tail-match polling for Discord delivery confirmation - Added web control page, padded-cell auto-scan, discussion tool support - Removed obsolete v1 modules: channel-resolver, channel-modes, discussion-service, session-state, turn-bootstrap, policy/store, rules, decision-input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
214 lines
8.6 KiB
TypeScript
214 lines
8.6 KiB
TypeScript
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 { startNoReplyApi, stopNoReplyApi } from "./core/no-reply-process.js";
|
|
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.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 { sendModeratorMessage, sendAndDelete } from "./core/moderator-discord.js";
|
|
import { setSpeakerList } from "./turn-manager.js";
|
|
import { fetchVisibleChannelBotAccountIds } from "./core/channel-members.js";
|
|
|
|
type PluginConfig = {
|
|
moderatorBotToken?: string;
|
|
noReplyProvider?: string;
|
|
noReplyModel?: string;
|
|
noReplyPort?: number;
|
|
scheduleIdentifier?: string;
|
|
identityFilePath?: string;
|
|
channelStoreFilePath?: string;
|
|
};
|
|
|
|
function normalizeConfig(api: OpenClawPluginApi): Required<PluginConfig> {
|
|
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
|
return {
|
|
moderatorBotToken: cfg.moderatorBotToken ?? "",
|
|
noReplyProvider: cfg.noReplyProvider ?? "dirigent",
|
|
noReplyModel: cfg.noReplyModel ?? "no-reply",
|
|
noReplyPort: Number(cfg.noReplyPort ?? 8787),
|
|
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"),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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<string, unknown>;
|
|
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);
|
|
|
|
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();
|
|
|
|
api.on("gateway_start", () => {
|
|
const live = normalizeConfig(api);
|
|
|
|
startNoReplyApi(api.logger, pluginDir, live.noReplyPort);
|
|
|
|
if (live.moderatorBotToken) {
|
|
startModeratorPresence(live.moderatorBotToken, api.logger);
|
|
api.logger.info("dirigent: moderator bot presence started");
|
|
} else {
|
|
api.logger.info("dirigent: moderatorBotToken not set — moderator features disabled");
|
|
}
|
|
|
|
tryAutoScanPaddedCell();
|
|
});
|
|
|
|
api.on("gateway_stop", () => {
|
|
stopNoReplyApi(api.logger);
|
|
stopModeratorPresence();
|
|
});
|
|
}
|
|
|
|
// ── Hooks (registered on every api instance — event-level dedup handles duplicates) ──
|
|
registerBeforeModelResolveHook({
|
|
api,
|
|
channelStore,
|
|
identityRegistry,
|
|
moderatorBotToken: config.moderatorBotToken || undefined,
|
|
noReplyModel: config.noReplyModel,
|
|
noReplyProvider: config.noReplyProvider,
|
|
scheduleIdentifier: config.scheduleIdentifier,
|
|
});
|
|
|
|
const interruptTailMatch = registerAgentEndHook({
|
|
api,
|
|
channelStore,
|
|
identityRegistry,
|
|
moderatorBotToken: config.moderatorBotToken || undefined,
|
|
scheduleIdentifier: config.scheduleIdentifier,
|
|
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);
|
|
},
|
|
});
|
|
|
|
registerMessageReceivedHook({
|
|
api,
|
|
channelStore,
|
|
identityRegistry,
|
|
moderatorBotToken: config.moderatorBotToken || undefined,
|
|
scheduleIdentifier: config.scheduleIdentifier,
|
|
interruptTailMatch,
|
|
});
|
|
|
|
// ── Tools ──────────────────────────────────────────────────────────────
|
|
registerDirigentTools({
|
|
api,
|
|
channelStore,
|
|
identityRegistry,
|
|
moderatorBotToken: config.moderatorBotToken || undefined,
|
|
scheduleIdentifier: config.scheduleIdentifier,
|
|
onDiscussionCreate: async ({ channelId, guildId, 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<typeof s> => s !== null);
|
|
|
|
if (speakers.length > 0) {
|
|
setSpeakerList(channelId, speakers);
|
|
const first = speakers[0];
|
|
await sendAndDelete(
|
|
live.moderatorBotToken,
|
|
channelId,
|
|
`<@${first.discordUserId}>${live.scheduleIdentifier}`,
|
|
api.logger,
|
|
).catch(() => undefined);
|
|
}
|
|
},
|
|
});
|
|
|
|
// ── Commands ───────────────────────────────────────────────────────────
|
|
registerSetChannelModeCommand({ api, channelStore });
|
|
registerAddGuildCommand(api);
|
|
|
|
// ── Control page ───────────────────────────────────────────────────────
|
|
registerControlPage({
|
|
api,
|
|
channelStore,
|
|
identityRegistry,
|
|
moderatorBotToken: config.moderatorBotToken || undefined,
|
|
openclawDir,
|
|
hasPaddedCell,
|
|
});
|
|
|
|
api.logger.info("dirigent: plugin registered (v2)");
|
|
},
|
|
};
|