Files
Dirigent/plugin/core/channel-store.ts
hzhang b5196e972c feat: rewrite plugin as v2 with globalThis-based turn management
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>
2026-04-08 22:41:25 +01:00

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 }));
}
}