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>
This commit is contained in:
136
plugin/core/channel-store.ts
Normal file
136
plugin/core/channel-store.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user