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:
h z
2026-04-08 22:41:25 +01:00
parent dea345698b
commit b5196e972c
40 changed files with 2427 additions and 2753 deletions

View File

@@ -1,455 +1,268 @@
/**
* Turn-based speaking manager for group channels.
* Turn Manager (v2)
*
* Rules:
* - Humans (humanList) are never in the turn order
* - Turn order is auto-populated from channel/server members minus humans
* - currentSpeaker can be null (dormant state)
* - When ALL agents in a cycle have NO_REPLY'd, state goes dormant (null)
* - Dormant → any new message reactivates:
* - If sender is NOT in turn order → current = first in list
* - If sender IS in turn order → current = next after sender
* Per-channel state machine governing who speaks when.
* Called from before_model_resolve (check turn) and agent_end (advance turn).
*/
import { getChannelShuffling, isMultiMessageMode, markLastShuffled } from "./core/channel-modes.js";
export type ChannelTurnState = {
/** Ordered accountIds for this channel (auto-populated, shuffled) */
turnOrder: string[];
/** Current speaker accountId, or null if dormant */
currentSpeaker: string | null;
/** Set of accountIds that have NO_REPLY'd this cycle */
noRepliedThisCycle: Set<string>;
/** Timestamp of last state change */
lastChangedAt: number;
// ── Mention override state ──
/** Original turn order saved when override is active */
savedTurnOrder?: string[];
/** First agent in override cycle; used to detect cycle completion */
overrideFirstAgent?: string;
// ── Wait-for-human state ──
/** When true, an agent used the wait identifier — all agents should stay silent until a human speaks */
waitingForHuman: boolean;
export type SpeakerEntry = {
agentId: string;
discordUserId: string;
};
const channelTurns = new Map<string, ChannelTurnState>();
/** Turn timeout: if the current speaker hasn't responded, auto-advance */
const TURN_TIMEOUT_MS = 60_000;
// --- helpers ---
function shuffleArray<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function reshuffleTurnOrder(channelId: string, currentOrder: string[], lastSpeaker?: string): string[] {
const shufflingEnabled = getChannelShuffling(channelId);
if (!shufflingEnabled) return currentOrder;
const shuffled = shuffleArray(currentOrder);
// If there's a last speaker and they're in the order, ensure they're not first
if (lastSpeaker && shuffled.length > 1 && shuffled[0] === lastSpeaker) {
// Find another speaker to swap with
for (let i = 1; i < shuffled.length; i++) {
if (shuffled[i] !== lastSpeaker) {
[shuffled[0], shuffled[i]] = [shuffled[i], shuffled[0]];
break;
}
}
}
return shuffled;
}
// --- public API ---
type ChannelTurnState = {
speakerList: SpeakerEntry[];
currentIndex: number;
/** Tracks which agents sent empty turns in the current cycle. */
emptyThisCycle: Set<string>;
/** Tracks which agents completed a turn at all this cycle. */
completedThisCycle: Set<string>;
dormant: boolean;
/** Discord message ID recorded at before_model_resolve, used as poll anchor. */
anchorMessageId: Map<string, string>; // agentId → messageId
};
/**
* Initialize or update the turn order for a channel.
* Called with the list of bot accountIds (already filtered, humans excluded).
* All mutable state is stored on globalThis so it persists across VM-context
* hot-reloads within the same gateway process. OpenClaw re-imports this module
* in a fresh isolated VM context on each reload, but all contexts share the real
* globalThis object because they run in the same Node.js process.
*/
export function initTurnOrder(channelId: string, botAccountIds: string[]): void {
const existing = channelTurns.get(channelId);
if (existing) {
// Compare membership against base order.
// If mention override is active, turnOrder is temporary; use savedTurnOrder for stable comparison.
const baseOrder = existing.savedTurnOrder || existing.turnOrder;
const oldSet = new Set(baseOrder);
const newSet = new Set(botAccountIds);
const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id));
if (same) return; // no change
const _G = globalThis as Record<string, unknown>;
console.log(
`[dirigent][turn-debug] initTurnOrder membership-changed channel=${channelId} ` +
`oldOrder=${JSON.stringify(existing.turnOrder)} oldCurrent=${existing.currentSpeaker} ` +
`oldOverride=${JSON.stringify(existing.savedTurnOrder || null)} newMembers=${JSON.stringify(botAccountIds)}`,
);
function channelStates(): Map<string, ChannelTurnState> {
if (!(_G._tmChannelStates instanceof Map)) _G._tmChannelStates = new Map<string, ChannelTurnState>();
return _G._tmChannelStates as Map<string, ChannelTurnState>;
}
const nextOrder = shuffleArray(botAccountIds);
function pendingTurns(): Set<string> {
if (!(_G._tmPendingTurns instanceof Set)) _G._tmPendingTurns = new Set<string>();
return _G._tmPendingTurns as Set<string>;
}
// Mention override active: update only the saved base order.
// Keep temporary turnOrder/currentSpeaker intact so @mention routing is not clobbered.
if (existing.savedTurnOrder) {
existing.savedTurnOrder = nextOrder;
existing.lastChangedAt = Date.now();
console.log(
`[dirigent][turn-debug] initTurnOrder applied-base-only channel=${channelId} ` +
`savedOrder=${JSON.stringify(nextOrder)} keptOverrideOrder=${JSON.stringify(existing.turnOrder)} ` +
`keptCurrent=${existing.currentSpeaker}`,
);
return;
}
// Non-mention flow: preserve previous behavior (re-init to dormant).
channelTurns.set(channelId, {
turnOrder: nextOrder,
currentSpeaker: null, // start dormant
noRepliedThisCycle: new Set(),
lastChangedAt: Date.now(),
waitingForHuman: false,
});
console.log(
`[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`,
);
return;
}
console.log(
`[dirigent][turn-debug] initTurnOrder first-init channel=${channelId} members=${JSON.stringify(botAccountIds)}`,
);
const nextOrder = shuffleArray(botAccountIds);
channelTurns.set(channelId, {
turnOrder: nextOrder,
currentSpeaker: null, // start dormant
noRepliedThisCycle: new Set(),
lastChangedAt: Date.now(),
waitingForHuman: false,
});
console.log(
`[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`,
);
function blockedPendingCounts(): Map<string, number> {
if (!(_G._tmBlockedPendingCounts instanceof Map)) _G._tmBlockedPendingCounts = new Map<string, number>();
return _G._tmBlockedPendingCounts as Map<string, number>;
}
/**
* Check if the given accountId is allowed to speak.
* Shared initialization lock: prevents multiple concurrent VM contexts from
* simultaneously initializing the same channel's speaker list.
* Used by both before_model_resolve and message_received hooks.
*/
export function checkTurn(channelId: string, accountId: string): {
allowed: boolean;
currentSpeaker: string | null;
reason: string;
} {
const state = channelTurns.get(channelId);
if (!state || state.turnOrder.length === 0) {
return { allowed: true, currentSpeaker: null, reason: "no_turn_state" };
}
export function getInitializingChannels(): Set<string> {
if (!(_G._tmInitializingChannels instanceof Set)) _G._tmInitializingChannels = new Set<string>();
return _G._tmInitializingChannels as Set<string>;
}
// Waiting for human → block all agents
if (state.waitingForHuman) {
return { allowed: false, currentSpeaker: null, reason: "waiting_for_human" };
}
export function markTurnStarted(channelId: string, agentId: string): void {
pendingTurns().add(`${channelId}:${agentId}`);
}
// Not in turn order (human or unknown) → always allowed
if (!state.turnOrder.includes(accountId)) {
return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" };
}
export function isTurnPending(channelId: string, agentId: string): boolean {
return pendingTurns().has(`${channelId}:${agentId}`);
}
// Dormant → not allowed (will be activated by onNewMessage)
if (state.currentSpeaker === null) {
return { allowed: false, currentSpeaker: null, reason: "dormant" };
}
// Check timeout → auto-advance
if (Date.now() - state.lastChangedAt > TURN_TIMEOUT_MS) {
advanceTurn(channelId);
// Re-check after advance
const updated = channelTurns.get(channelId)!;
if (updated.currentSpeaker === accountId) {
return { allowed: true, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_self" };
}
return { allowed: false, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_other" };
}
if (accountId === state.currentSpeaker) {
return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "is_current_speaker" };
}
return { allowed: false, currentSpeaker: state.currentSpeaker, reason: "not_current_speaker" };
export function clearTurnPending(channelId: string, agentId: string): void {
pendingTurns().delete(`${channelId}:${agentId}`);
}
/**
* Called when a new message arrives in the channel.
* Handles reactivation from dormant state and human-triggered resets.
*
* NOTE: For human messages with @mentions, call setMentionOverride() instead.
*
* @param senderAccountId - the accountId of the message sender (could be human/bot/unknown)
* @param isHuman - whether the sender is in the humanList
* Counts NO_REPLY completions currently in-flight for an agent that was
* blocked (non-speaker or init-suppressed). These completions take ~10s to
* arrive (history-building overhead) and may arrive after markTurnStarted,
* causing false empty-turn detection. We count them and skip one per agent_end
* until the count reaches zero, at which point the next agent_end is real.
*/
export function onNewMessage(channelId: string, senderAccountId: string | undefined, isHuman: boolean): void {
const state = channelTurns.get(channelId);
if (!state || state.turnOrder.length === 0) return;
// Check for multi-message mode exit condition
if (isMultiMessageMode(channelId) && isHuman) {
// In multi-message mode, human messages don't trigger turn activation
// We only exit multi-message mode if the end marker is detected in a higher-level hook
return;
}
if (isHuman) {
// Human message: clear wait-for-human, restore original order if overridden, activate from first
state.waitingForHuman = false;
restoreOriginalOrder(state);
state.currentSpeaker = state.turnOrder[0];
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return;
}
if (state.waitingForHuman) {
// Waiting for human — ignore non-human messages
return;
}
if (state.currentSpeaker !== null) {
// Already active, no change needed from incoming message
return;
}
// Dormant state + non-human message → reactivate
if (senderAccountId && state.turnOrder.includes(senderAccountId)) {
// Sender is in turn order → next after sender
const idx = state.turnOrder.indexOf(senderAccountId);
const nextIdx = (idx + 1) % state.turnOrder.length;
state.currentSpeaker = state.turnOrder[nextIdx];
} else {
// Sender not in turn order → start from first
state.currentSpeaker = state.turnOrder[0];
}
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
export function incrementBlockedPending(channelId: string, agentId: string): void {
const bpc = blockedPendingCounts();
const key = `${channelId}:${agentId}`;
bpc.set(key, (bpc.get(key) ?? 0) + 1);
}
/**
* Restore original turn order if an override is active.
*/
function restoreOriginalOrder(state: ChannelTurnState): void {
if (state.savedTurnOrder) {
state.turnOrder = state.savedTurnOrder;
state.savedTurnOrder = undefined;
state.overrideFirstAgent = undefined;
}
}
/**
* Set a temporary mention override for the turn order.
* When a human @mentions specific agents, only those agents speak (in their
* relative order from the current turn order). After the cycle returns to the
* first agent, the original order is restored.
*
* @param channelId - Discord channel ID
* @param mentionedAccountIds - accountIds of @mentioned agents, ordered by
* their position in the current turn order
* @returns true if override was set, false if no valid agents
*/
export function setMentionOverride(channelId: string, mentionedAccountIds: string[]): boolean {
const state = channelTurns.get(channelId);
if (!state || mentionedAccountIds.length === 0) return false;
console.log(
`[dirigent][turn-debug] setMentionOverride start channel=${channelId} ` +
`mentioned=${JSON.stringify(mentionedAccountIds)} current=${state.currentSpeaker} ` +
`order=${JSON.stringify(state.turnOrder)} saved=${JSON.stringify(state.savedTurnOrder || null)}`,
);
// Restore any existing override first
restoreOriginalOrder(state);
// Filter to agents actually in the turn order
const validIds = mentionedAccountIds.filter(id => state.turnOrder.includes(id));
if (validIds.length === 0) {
console.log(`[dirigent][turn-debug] setMentionOverride ignored channel=${channelId} reason=no-valid-mentioned`);
return false;
}
// Order by their position in the current turn order
validIds.sort((a, b) => state.turnOrder.indexOf(a) - state.turnOrder.indexOf(b));
// Save original and apply override
state.savedTurnOrder = [...state.turnOrder];
state.turnOrder = validIds;
state.overrideFirstAgent = validIds[0];
state.currentSpeaker = validIds[0];
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
console.log(
`[dirigent][turn-debug] setMentionOverride applied channel=${channelId} ` +
`overrideOrder=${JSON.stringify(state.turnOrder)} current=${state.currentSpeaker} ` +
`savedOriginal=${JSON.stringify(state.savedTurnOrder || null)}`,
);
/** Returns true (and decrements) if this agent_end should be treated as a stale blocked completion. */
export function consumeBlockedPending(channelId: string, agentId: string): boolean {
const bpc = blockedPendingCounts();
const key = `${channelId}:${agentId}`;
const count = bpc.get(key) ?? 0;
if (count <= 0) return false;
bpc.set(key, count - 1);
return true;
}
/**
* Check if a mention override is currently active.
*/
export function hasMentionOverride(channelId: string): boolean {
const state = channelTurns.get(channelId);
return !!state?.savedTurnOrder;
export function resetBlockedPending(channelId: string, agentId: string): void {
blockedPendingCounts().delete(`${channelId}:${agentId}`);
}
function getState(channelId: string): ChannelTurnState | undefined {
return channelStates().get(channelId);
}
function ensureState(channelId: string): ChannelTurnState {
const cs = channelStates();
let s = cs.get(channelId);
if (!s) {
s = {
speakerList: [],
currentIndex: 0,
emptyThisCycle: new Set(),
completedThisCycle: new Set(),
dormant: false,
anchorMessageId: new Map(),
};
cs.set(channelId, s);
}
return s;
}
/** Replace the speaker list (called at cycle boundaries and on init). */
export function setSpeakerList(channelId: string, speakers: SpeakerEntry[]): void {
const s = ensureState(channelId);
s.speakerList = speakers;
s.currentIndex = 0;
}
/** Get the currently active speaker, or null if dormant / list empty. */
export function getCurrentSpeaker(channelId: string): SpeakerEntry | null {
const s = getState(channelId);
if (!s || s.dormant || s.speakerList.length === 0) return null;
return s.speakerList[s.currentIndex] ?? null;
}
/** Check if a given agentId is the current speaker. */
export function isCurrentSpeaker(channelId: string, agentId: string): boolean {
const speaker = getCurrentSpeaker(channelId);
return speaker?.agentId === agentId;
}
/** Record the Discord anchor message ID for an agent's upcoming turn. */
export function setAnchor(channelId: string, agentId: string, messageId: string): void {
const s = ensureState(channelId);
s.anchorMessageId.set(agentId, messageId);
}
export function getAnchor(channelId: string, agentId: string): string | undefined {
return getState(channelId)?.anchorMessageId.get(agentId);
}
/**
* Set the channel to "waiting for human" state.
* All agents will be routed to no-reply until a human sends a message.
* Advance the speaker after a turn completes.
* Returns the new current speaker (or null if dormant).
*
* @param isEmpty - whether the completed turn was an empty turn
* @param rebuildFn - async function that fetches current Discord members and
* returns a new SpeakerEntry[]. Called at cycle boundaries.
* @param previousLastAgentId - for shuffle mode: the last speaker of the
* previous cycle (cannot become the new first speaker).
*/
export function setWaitingForHuman(channelId: string): void {
const state = channelTurns.get(channelId);
if (!state) return;
state.waitingForHuman = true;
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
export async function advanceSpeaker(
channelId: string,
agentId: string,
isEmpty: boolean,
rebuildFn: () => Promise<SpeakerEntry[]>,
previousLastAgentId?: string,
): Promise<{ next: SpeakerEntry | null; enteredDormant: boolean }> {
const s = ensureState(channelId);
// Record this turn
s.completedThisCycle.add(agentId);
if (isEmpty) s.emptyThisCycle.add(agentId);
const wasLastInCycle = s.currentIndex >= s.speakerList.length - 1;
if (!wasLastInCycle) {
// Middle of cycle — just advance pointer
s.currentIndex++;
s.dormant = false;
return { next: s.speakerList[s.currentIndex] ?? null, enteredDormant: false };
}
// === Cycle boundary ===
const newSpeakers = await rebuildFn();
const previousAgentIds = new Set(s.speakerList.map((sp) => sp.agentId));
const hasNewAgents = newSpeakers.some((sp) => !previousAgentIds.has(sp.agentId));
const allEmpty =
s.completedThisCycle.size > 0 &&
[...s.completedThisCycle].every((id) => s.emptyThisCycle.has(id));
// Reset cycle tracking
s.emptyThisCycle = new Set();
s.completedThisCycle = new Set();
if (allEmpty && !hasNewAgents) {
// Enter dormant
s.speakerList = newSpeakers;
s.currentIndex = 0;
s.dormant = true;
return { next: null, enteredDormant: true };
}
// Continue with updated list (apply shuffle if caller provides previousLastAgentId)
s.speakerList = previousLastAgentId != null
? shuffleList(newSpeakers, previousLastAgentId)
: newSpeakers;
s.currentIndex = 0;
s.dormant = false;
return { next: s.speakerList[0] ?? null, enteredDormant: false };
}
/**
* Check if the channel is waiting for a human reply.
* Wake the channel from dormant.
* Returns the new first speaker.
*/
export function isWaitingForHuman(channelId: string): boolean {
const state = channelTurns.get(channelId);
return !!state?.waitingForHuman;
export function wakeFromDormant(channelId: string): SpeakerEntry | null {
const s = getState(channelId);
if (!s) return null;
s.dormant = false;
s.currentIndex = 0;
s.emptyThisCycle = new Set();
s.completedThisCycle = new Set();
return s.speakerList[0] ?? null;
}
export function isDormant(channelId: string): boolean {
return getState(channelId)?.dormant ?? false;
}
export function hasSpeakers(channelId: string): boolean {
const s = getState(channelId);
return (s?.speakerList.length ?? 0) > 0;
}
/**
* Called when the current speaker finishes (end symbol detected) or says NO_REPLY.
* @param wasNoReply - true if the speaker said NO_REPLY (empty/silent)
* @returns the new currentSpeaker (or null if dormant)
* Shuffle a speaker list. Constraint: previousLastAgentId cannot be new first speaker.
*/
export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: boolean): string | null {
const state = channelTurns.get(channelId);
if (!state) return null;
if (state.currentSpeaker !== accountId) return state.currentSpeaker; // not current speaker, ignore
if (wasNoReply) {
state.noRepliedThisCycle.add(accountId);
// Check if ALL agents have NO_REPLY'd this cycle
const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id));
if (allNoReplied) {
// If override active, restore original order before going dormant
restoreOriginalOrder(state);
// Go dormant
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return null;
}
} else {
// Successful speech resets the cycle counter
state.noRepliedThisCycle = new Set();
export function shuffleList(list: SpeakerEntry[], previousLastAgentId?: string): SpeakerEntry[] {
if (list.length <= 1) return list;
const arr = [...list];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
const prevSpeaker = state.currentSpeaker;
const next = advanceTurn(channelId);
// Check if override cycle completed (returned to first agent)
if (state.overrideFirstAgent && next === state.overrideFirstAgent) {
restoreOriginalOrder(state);
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return null; // go dormant after override cycle completes
if (previousLastAgentId && arr[0].agentId === previousLastAgentId && arr.length > 1) {
const swapIdx = 1 + Math.floor(Math.random() * (arr.length - 1));
[arr[0], arr[swapIdx]] = [arr[swapIdx], arr[0]];
}
// Check if we've completed a full cycle (all agents spoke once)
// This happens when we're back to the first agent in the turn order
const isFirstSpeakerAgain = next === state.turnOrder[0];
if (!wasNoReply && !state.overrideFirstAgent && next && isFirstSpeakerAgain && state.noRepliedThisCycle.size === 0) {
// Completed a full cycle without anyone NO_REPLYing - reshuffle if enabled
const newOrder = reshuffleTurnOrder(channelId, state.turnOrder, prevSpeaker);
if (newOrder !== state.turnOrder) {
state.turnOrder = newOrder;
markLastShuffled(channelId);
console.log(`[dirigent][turn-debug] reshuffled turn order for channel=${channelId} newOrder=${JSON.stringify(newOrder)}`);
}
}
return next;
return arr;
}
/**
* Advance to next speaker in order.
*/
export function advanceTurn(channelId: string): string | null {
const state = channelTurns.get(channelId);
if (!state || state.turnOrder.length === 0) return null;
if (state.currentSpeaker === null) return null;
const idx = state.turnOrder.indexOf(state.currentSpeaker);
const nextIdx = (idx + 1) % state.turnOrder.length;
// Skip agents that already NO_REPLY'd this cycle
let attempts = 0;
let candidateIdx = nextIdx;
while (state.noRepliedThisCycle.has(state.turnOrder[candidateIdx]) && attempts < state.turnOrder.length) {
candidateIdx = (candidateIdx + 1) % state.turnOrder.length;
attempts++;
}
if (attempts >= state.turnOrder.length) {
// All have NO_REPLY'd
state.currentSpeaker = null;
state.lastChangedAt = Date.now();
return null;
}
state.currentSpeaker = state.turnOrder[candidateIdx];
state.lastChangedAt = Date.now();
return state.currentSpeaker;
}
/**
* Force reset: go dormant.
*/
export function resetTurn(channelId: string): void {
const state = channelTurns.get(channelId);
if (state) {
restoreOriginalOrder(state);
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
state.waitingForHuman = false;
state.lastChangedAt = Date.now();
}
}
/**
* Get debug info.
*/
export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
const state = channelTurns.get(channelId);
if (!state) return { channelId, hasTurnState: false };
export function getDebugInfo(channelId: string) {
const s = getState(channelId);
if (!s) return { exists: false };
return {
channelId,
hasTurnState: true,
turnOrder: state.turnOrder,
currentSpeaker: state.currentSpeaker,
noRepliedThisCycle: [...state.noRepliedThisCycle],
lastChangedAt: state.lastChangedAt,
dormant: state.currentSpeaker === null,
waitingForHuman: state.waitingForHuman,
hasOverride: !!state.savedTurnOrder,
overrideFirstAgent: state.overrideFirstAgent || null,
savedTurnOrder: state.savedTurnOrder || null,
exists: true,
speakerList: s.speakerList.map((sp) => sp.agentId),
currentIndex: s.currentIndex,
currentSpeaker: s.speakerList[s.currentIndex]?.agentId ?? null,
dormant: s.dormant,
emptyThisCycle: [...s.emptyThisCycle],
completedThisCycle: [...s.completedThisCycle],
};
}
/** Remove a channel's turn state entirely (e.g. when archived). */
export function clearChannel(channelId: string): void {
channelStates().delete(channelId);
}