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>
137 lines
4.2 KiB
TypeScript
137 lines
4.2 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
export type ChannelMode = "none" | "work" | "report" | "discussion" | "chat";
|
|
export type TurnManagerState = "disabled" | "dead" | "normal" | "shuffle" | "archived";
|
|
|
|
/** Modes that cannot be changed once set. */
|
|
const LOCKED_MODES = new Set<ChannelMode>(["work", "discussion"]);
|
|
|
|
/** Derive turn-manager state from mode + agent count. */
|
|
export function deriveTurnManagerState(mode: ChannelMode, agentCount: number, concluded = false): TurnManagerState {
|
|
if (mode === "none" || mode === "work") return "disabled";
|
|
if (mode === "report") return "dead";
|
|
if (mode === "discussion") {
|
|
if (concluded) return "archived";
|
|
if (agentCount <= 1) return "disabled";
|
|
if (agentCount === 2) return "normal";
|
|
return "shuffle";
|
|
}
|
|
if (mode === "chat") {
|
|
if (agentCount <= 1) return "disabled";
|
|
if (agentCount === 2) return "normal";
|
|
return "shuffle";
|
|
}
|
|
return "disabled";
|
|
}
|
|
|
|
export type DiscussionMeta = {
|
|
initiatorAgentId: string;
|
|
callbackGuildId: string;
|
|
callbackChannelId: string;
|
|
concluded: boolean;
|
|
};
|
|
|
|
export type ChannelRecord = {
|
|
mode: ChannelMode;
|
|
/** For discussion channels: metadata about the discussion. */
|
|
discussion?: DiscussionMeta;
|
|
};
|
|
|
|
export class ChannelStore {
|
|
private filePath: string;
|
|
private records: Record<string, ChannelRecord> = {};
|
|
private loaded = false;
|
|
|
|
constructor(filePath: string) {
|
|
this.filePath = filePath;
|
|
}
|
|
|
|
private load(): void {
|
|
if (this.loaded) return;
|
|
this.loaded = true;
|
|
if (!fs.existsSync(this.filePath)) {
|
|
this.records = {};
|
|
return;
|
|
}
|
|
try {
|
|
const raw = fs.readFileSync(this.filePath, "utf8");
|
|
this.records = JSON.parse(raw) ?? {};
|
|
} catch {
|
|
this.records = {};
|
|
}
|
|
}
|
|
|
|
private save(): void {
|
|
const dir = path.dirname(this.filePath);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(this.filePath, JSON.stringify(this.records, null, 2), "utf8");
|
|
}
|
|
|
|
getMode(channelId: string): ChannelMode {
|
|
this.load();
|
|
return this.records[channelId]?.mode ?? "none";
|
|
}
|
|
|
|
getRecord(channelId: string): ChannelRecord {
|
|
this.load();
|
|
return this.records[channelId] ?? { mode: "none" };
|
|
}
|
|
|
|
/**
|
|
* Set channel mode. Throws if the channel is currently in a locked mode,
|
|
* or if the requested mode is locked (must use setLockedMode instead).
|
|
*/
|
|
setMode(channelId: string, mode: ChannelMode): void {
|
|
this.load();
|
|
const current = this.records[channelId]?.mode ?? "none";
|
|
if (LOCKED_MODES.has(current)) {
|
|
throw new Error(`Channel ${channelId} is in locked mode "${current}" and cannot be changed.`);
|
|
}
|
|
if (LOCKED_MODES.has(mode)) {
|
|
throw new Error(`Mode "${mode}" can only be set at channel creation via the dedicated tool.`);
|
|
}
|
|
this.records[channelId] = { ...this.records[channelId], mode };
|
|
this.save();
|
|
}
|
|
|
|
/**
|
|
* Set a locked mode (work or discussion). Only callable from creation tools.
|
|
* Throws if the channel already has any mode set.
|
|
*/
|
|
setLockedMode(channelId: string, mode: ChannelMode, discussion?: DiscussionMeta): void {
|
|
this.load();
|
|
if (this.records[channelId]) {
|
|
throw new Error(`Channel ${channelId} already has mode "${this.records[channelId].mode}".`);
|
|
}
|
|
const record: ChannelRecord = { mode };
|
|
if (discussion) record.discussion = discussion;
|
|
this.records[channelId] = record;
|
|
this.save();
|
|
}
|
|
|
|
/** Mark a discussion as concluded (sets archived state). */
|
|
concludeDiscussion(channelId: string): void {
|
|
this.load();
|
|
const rec = this.records[channelId];
|
|
if (!rec || rec.mode !== "discussion") {
|
|
throw new Error(`Channel ${channelId} is not a discussion channel.`);
|
|
}
|
|
if (!rec.discussion) {
|
|
throw new Error(`Channel ${channelId} has no discussion metadata.`);
|
|
}
|
|
rec.discussion = { ...rec.discussion, concluded: true };
|
|
this.save();
|
|
}
|
|
|
|
isLocked(channelId: string): boolean {
|
|
this.load();
|
|
return LOCKED_MODES.has(this.records[channelId]?.mode ?? "none");
|
|
}
|
|
|
|
listAll(): Array<{ channelId: string } & ChannelRecord> {
|
|
this.load();
|
|
return Object.entries(this.records).map(([channelId, rec]) => ({ channelId, ...rec }));
|
|
}
|
|
}
|