Files
Dirigent/plugin/index.ts
hzhang 32dc9a4233 refactor: new design — sidecar services, moderator Gateway client, tool execute API
- Replace standalone no-reply-api Docker service with unified sidecar (services/main.mjs)
  that routes /no-reply/* and /moderator/* and starts/stops with openclaw-gateway
- Add moderator Discord Gateway client (services/moderator/index.mjs) for real-time
  MESSAGE_CREATE push instead of polling; notifies plugin via HTTP callback
- Add plugin HTTP routes (plugin/web/dirigent-api.ts) for moderator → plugin callbacks
  (wake-from-dormant, interrupt tail-match)
- Fix tool registration format: AgentTool requires execute: not handler:; factory form
  for tools needing ctx
- Rename no-reply-process.ts → sidecar-process.ts, startNoReplyApi → startSideCar
- Remove dead config fields from openclaw.plugin.json (humanList, agentList, listMode,
  channelPoliciesFile, endSymbols, waitIdentifier, multiMessage*, bypassUserIds, etc.)
- Rename noReplyPort → sideCarPort
- Remove docker-compose.yml, dev-up/down scripts, package-plugin.mjs, test-no-reply-api.mjs
- Update install.mjs: clean dist before build, copy services/, drop dead config writes
- Update README, Makefile, smoke script for new architecture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:07:59 +01:00

295 lines
12 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 { 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<PluginConfig> {
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<string, unknown>)?.gateway as Record<string, unknown>)?.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<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);
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<typeof s> => 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)");
},
};