Files
Dirigent/plugin/hooks/before-model-resolve.ts
zhi 5b05e91d4e 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) <noreply@anthropic.com>
2026-05-08 07:36:00 +00:00

166 lines
7.5 KiB
TypeScript

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";
import { getLatestMessageId, sendScheduleTrigger } from "../core/moderator-discord.js";
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
/** Extract Discord channel ID from sessionKey like "agent:home-developer:discord:channel:1234567890". */
export function parseDiscordChannelId(sessionKey: string): string | undefined {
const m = sessionKey.match(/:discord:channel:(\d+)$/);
return m?.[1];
}
type Deps = {
api: OpenClawPluginApi;
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
scheduleIdentifier: string;
debugMode: boolean;
noReplyProvider: string;
noReplyModel: string;
};
/**
* Process-level deduplication for before_model_resolve events.
* Uses a WeakSet keyed on the event object — works when OpenClaw passes
* the same event reference to all stacked handlers (hot-reload scenario).
* Stored on globalThis so it persists across module reloads.
*/
const _BMR_DEDUP_KEY = "_dirigentProcessedBMREvents";
if (!(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY]) {
(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] = new WeakSet<object>();
}
const processedBeforeModelResolveEvents: WeakSet<object> = (globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] as WeakSet<object>;
export function registerBeforeModelResolveHook(deps: Deps): void {
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, debugMode, noReplyProvider, noReplyModel } = deps;
/** Shared init lock — see turn-manager.ts getInitializingChannels(). */
const initializingChannels = getInitializingChannels();
api.on("before_model_resolve", async (event, ctx) => {
// Deduplicate: if another handler instance already processed this event
// object, skip. Prevents double-counting from hot-reload stacked handlers.
const eventObj = event as object;
if (processedBeforeModelResolveEvents.has(eventObj)) return;
processedBeforeModelResolveEvents.add(eventObj);
const sessionKey = ctx.sessionKey;
if (!sessionKey) return;
// Only handle Discord group channel sessions
const channelId = parseDiscordChannelId(sessionKey);
if (!channelId) return;
const mode = channelStore.getMode(channelId);
// dead/report mode: suppress all via no-reply model
if (mode === "report" || mode === "dead" as string) {
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// concluded discussion: suppress via no-reply model
if (mode === "discussion") {
const rec = channelStore.getRecord(channelId);
if (rec.discussion?.concluded) {
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
}
// disabled modes: no turn management
if (mode === "none" || mode === "work") return;
// chat / discussion (active): check turn
const agentId = ctx.agentId;
if (!agentId) return;
// If speaker list not yet loaded, initialize it now
if (!hasSpeakers(channelId)) {
// Only one concurrent initializer per channel (Node.js single-threaded: this is safe)
if (initializingChannels.has(channelId)) {
api.logger.info(`dirigent: before_model_resolve init in progress, suppressing agentId=${agentId} channel=${channelId}`);
// incrementBlockedPending so agent_end knows to expect a stale NO_REPLY completion later
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
initializingChannels.add(channelId);
try {
const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
const speakers: SpeakerEntry[] = agentIds
.map((aid) => {
const entry = identityRegistry.findByAgentId(aid);
return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null;
})
.filter((s): s is SpeakerEntry => s !== null);
if (speakers.length > 0) {
setSpeakerList(channelId, speakers);
const first = speakers[0];
api.logger.info(`dirigent: initialized speaker list channel=${channelId} first=${first.agentId} all=${speakers.map(s => s.agentId).join(",")}`);
// If this agent is NOT the first speaker, trigger first speaker and suppress self
if (first.agentId !== agentId && moderatorBotToken) {
await sendScheduleTrigger(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger, debugMode);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// Fall through — this agent IS the first speaker
} else {
// No registered agents visible — let everyone respond freely
return;
}
} catch (err) {
api.logger.warn(`dirigent: before_model_resolve init failed: ${String(err)}`);
return;
} finally {
initializingChannels.delete(channelId);
}
}
// Channel is dormant: suppress via no-reply model
if (isDormant(channelId)) {
api.logger.info(`dirigent: before_model_resolve suppressing dormant agentId=${agentId} channel=${channelId}`);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
if (!isCurrentSpeaker(channelId, agentId)) {
api.logger.info(`dirigent: before_model_resolve suppressing non-speaker agentId=${agentId} channel=${channelId}`);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// If a turn is already in progress for this agent, this is a duplicate wakeup
// (e.g. agent woke itself via a message-tool send). Suppress it.
if (isTurnPending(channelId, agentId)) {
api.logger.info(`dirigent: before_model_resolve turn already in progress, suppressing self-wakeup agentId=${agentId} channel=${channelId}`);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// Mark that this is a legitimate turn (guards agent_end against stale NO_REPLY completions)
markTurnStarted(channelId, agentId);
// Current speaker: record anchor message ID for tail-match polling
if (moderatorBotToken) {
try {
const anchorId = await getLatestMessageId(moderatorBotToken, channelId);
if (anchorId) {
setAnchor(channelId, agentId, anchorId);
api.logger.info(`dirigent: before_model_resolve anchor set channel=${channelId} agentId=${agentId} anchorId=${anchorId}`);
}
} catch (err) {
api.logger.warn(`dirigent: before_model_resolve failed to get anchor: ${String(err)}`);
}
}
// Verify agent has a known Discord user ID (needed for tail-match later)
const identity = identityRegistry.findByAgentId(agentId);
if (!identity) {
api.logger.warn(`dirigent: before_model_resolve no identity for agentId=${agentId} — proceeding without tail-match capability`);
}
});
}