",
@@ -634,8 +894,24 @@ function renderHtml(snapshot) {
row("Pause flag", snapshot.sources?.pausePath, snapshot.sources?.pauseReadable ? (summary.paused ? "paused" : "ok") : "missing"),
row("CultMesh store", snapshot.cultMesh?.storePath, snapshot.cultMesh?.writeStatus),
"",
- "",
+ "",
].join("");
+ attachControls();
+ }
+
+ function renderTimeline(turns) {
+ const cards = turns.map((turn) => {
+ const klass = turn.activeJobId ? "turn-card active-turn" : "turn-card";
+ return "
" + avatarHtml(turn) + "" + esc(turn.displayName) + "" + esc(minutes(turn.nextTurnInMinutes)) + "" + (turn.shuffleReason ? "" + esc(turn.shuffleReason) + "" : "") + "";
+ }).join("");
+ return "
";
+ }
+
+ function avatarHtml(turn) {
+ const initials = text(turn.displayName).split(/\\s+/).map((part) => part[0]).join("").slice(0, 2).toUpperCase();
+ return turn.avatarUrl
+ ? "
"
+ : "
" + esc(initials || "?") + "
";
}
function agentCard(agent) {
@@ -665,6 +941,41 @@ function renderHtml(snapshot) {
return "
| " + esc(label) + " | " + esc(value) + " | " + badge(state || "unknown") + " |
|---|
";
}
+ function attachControls() {
+ const cadenceInput = document.getElementById("cadence-input");
+ const cadenceValue = document.getElementById("cadence-value");
+ const cadenceApply = document.getElementById("cadence-apply");
+ const forceSelect = document.getElementById("force-identity");
+ const forceButton = document.getElementById("force-turn");
+ if (cadenceInput && cadenceValue) {
+ cadenceInput.addEventListener("input", () => cadenceValue.textContent = "x" + Number(cadenceInput.value).toLocaleString(undefined, { maximumFractionDigits: 1 }));
+ }
+ if (cadenceApply && cadenceInput) {
+ cadenceApply.addEventListener("click", async () => {
+ await postJson("/api/controls", { cadenceMultiplier: Number(cadenceInput.value) });
+ await refresh();
+ });
+ }
+ if (forceButton && forceSelect) {
+ forceButton.addEventListener("click", async () => {
+ await postJson("/api/turns/force", { identityId: forceSelect.value });
+ await refresh();
+ });
+ }
+ }
+
+ async function postJson(url, body) {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) {
+ const payload = await response.json().catch(() => ({}));
+ throw new Error(payload.error || "Control request failed.");
+ }
+ }
+
async function refresh() {
try {
const response = await fetch("swarm-state.json?ts=" + Date.now(), { cache: "no-store" });
@@ -743,6 +1054,14 @@ async function serveDashboard({ host, port, rootDir, refreshSeconds }) {
const server = createServer(async (request, response) => {
try {
const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
+ if (request.method === "POST" && requestUrl.pathname === "/api/controls") {
+ await handleControlUpdate(request, response);
+ return;
+ }
+ if (request.method === "POST" && requestUrl.pathname === "/api/turns/force") {
+ await handleForceTurn(request, response);
+ return;
+ }
const pathname = requestUrl.pathname === "/" ? "/swarm-dashboard.html" : decodeURIComponent(requestUrl.pathname);
const target = resolve(rootDir, `.${pathname}`);
if (!target.startsWith(rootDir)) {
@@ -774,6 +1093,95 @@ async function serveDashboard({ host, port, rootDir, refreshSeconds }) {
}
}
+async function handleControlUpdate(request, response) {
+ if (args.public) {
+ writeJsonResponse(response, 403, { ok: false, error: "Controls are disabled in public mode." });
+ return;
+ }
+ const body = await readRequestJson(request);
+ const multiplier = Number(body.cadenceMultiplier);
+ if (!Number.isFinite(multiplier) || multiplier < 0.1 || multiplier > 12) {
+ writeJsonResponse(response, 400, { ok: false, error: "cadenceMultiplier must be between 0.1 and 12." });
+ return;
+ }
+ const state = await readMutableHeartbeatState();
+ state.controls = normalizeDashboardControls(state.controls);
+ state.controls.cadenceMultiplier = Math.round(multiplier * 100) / 100;
+ state.controls.updatedAt = new Date().toISOString();
+ await writeMutableHeartbeatState(state);
+ await render();
+ writeJsonResponse(response, 200, { ok: true, controls: state.controls });
+}
+
+async function handleForceTurn(request, response) {
+ if (args.public) {
+ writeJsonResponse(response, 403, { ok: false, error: "Controls are disabled in public mode." });
+ return;
+ }
+ const body = await readRequestJson(request);
+ const identityId = typeof body.identityId === "string" ? body.identityId.trim() : "";
+ if (!identityId) {
+ writeJsonResponse(response, 400, { ok: false, error: "identityId is required." });
+ return;
+ }
+ const state = await readMutableHeartbeatState();
+ state.controls = normalizeDashboardControls(state.controls);
+ state.controls.manualTurnRequests.push({
+ id: randomUUID(),
+ identityId,
+ requestedAt: new Date().toISOString(),
+ status: "pending",
+ note: "Requested from swarm dashboard.",
+ });
+ state.controls.manualTurnRequests = state.controls.manualTurnRequests.slice(-20);
+ state.controls.updatedAt = new Date().toISOString();
+ await writeMutableHeartbeatState(state);
+ await render();
+ writeJsonResponse(response, 200, { ok: true, controls: state.controls });
+}
+
+async function readMutableHeartbeatState() {
+ const parsed = await readJsonFile(heartbeatStatePath);
+ if (!parsed.ok || !parsed.value || typeof parsed.value !== "object") {
+ throw new Error(`Cannot read heartbeat state at ${heartbeatStatePath}`);
+ }
+ return parsed.value;
+}
+
+async function writeMutableHeartbeatState(state) {
+ await mkdir(dirname(heartbeatStatePath), { recursive: true });
+ await writeFile(heartbeatStatePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
+}
+
+function normalizeDashboardControls(value) {
+ const controls = value && typeof value === "object" ? value : {};
+ return {
+ cadenceMultiplier: Math.min(12, Math.max(0.1, Number(controls.cadenceMultiplier) || 1)),
+ manualTurnRequests: Array.isArray(controls.manualTurnRequests) ? controls.manualTurnRequests : [],
+ updatedAt: typeof controls.updatedAt === "string" ? controls.updatedAt : undefined,
+ };
+}
+
+async function readRequestJson(request) {
+ const chunks = [];
+ for await (const chunk of request) {
+ chunks.push(chunk);
+ if (Buffer.concat(chunks).length > 64 * 1024) {
+ throw new Error("Request body is too large.");
+ }
+ }
+ const raw = Buffer.concat(chunks).toString("utf8");
+ return raw ? JSON.parse(raw) : {};
+}
+
+function writeJsonResponse(response, statusCode, payload) {
+ response.writeHead(statusCode, {
+ "content-type": "application/json; charset=utf-8",
+ "cache-control": "no-store",
+ });
+ response.end(`${JSON.stringify(payload)}\n`);
+}
+
function contentType(path) {
switch (extname(path).toLowerCase()) {
case ".html":
diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts
index abc30ba..cd4b342 100644
--- a/scripts/run-repo-face-heartbeats.ts
+++ b/scripts/run-repo-face-heartbeats.ts
@@ -77,6 +77,21 @@ interface FaceHeartbeatState {
participants: FaceHeartbeatParticipant[];
history: Array
>;
pendingMentions: RepoFacePendingMention[];
+ controls: FaceHeartbeatControls;
+}
+
+interface FaceHeartbeatControls {
+ cadenceMultiplier: number;
+ manualTurnRequests: ManualTurnRequest[];
+ updatedAt?: string;
+}
+
+interface ManualTurnRequest {
+ id: string;
+ identityId: string;
+ requestedAt: string;
+ status: "pending" | "applied" | "consumed" | "rejected";
+ note?: string;
}
interface ActiveTurnScan {
@@ -224,6 +239,7 @@ async function main(): Promise {
const faceRegistry = await loadFaceIdentityRegistry(config.repoDiscordIdentitiesPath);
const registry = faceRegistryAsRepoDiscordRegistry(faceRegistry);
const state = await readHeartbeatState(config.repoFaceHeartbeats.statePath);
+ state.controls = normalizeHeartbeatControls(state.controls);
const runtimeSnapshots = await loadRepoFaceRuntimeSnapshots(registry.identities, config.storageRoot, state, { dryRun });
const now = new Date();
advanceInitiativeClockFromWallClock(state, now);
@@ -244,7 +260,9 @@ async function main(): Promise {
}
state.baseRecoveryMinutes = config.repoFaceHeartbeats.baseRecoveryMinutes;
- state.globalHeat = config.repoFaceHeartbeats.globalHeat;
+ const cadenceMultiplier = clamp(state.controls.cadenceMultiplier, 0.1, 12);
+ const effectiveGlobalHeat = config.repoFaceHeartbeats.globalHeat * cadenceMultiplier;
+ state.globalHeat = effectiveGlobalHeat;
const completedThisTick = new Set();
state.participants = reconcileParticipants(
state.participants,
@@ -254,7 +272,7 @@ async function main(): Promise {
config.repoFaceHeartbeats.heatOverrides,
state.initiativeClock,
config.repoFaceHeartbeats.baseRecoveryMinutes,
- config.repoFaceHeartbeats.globalHeat,
+ effectiveGlobalHeat,
runtimeSnapshots.pressureByIdentity,
).map((participant) =>
applyActiveTurnFreeze(
@@ -266,6 +284,7 @@ async function main(): Promise {
);
rescheduleStaleOverdueParticipants(state);
applyPendingMentionPriority(state);
+ const manualOverrideIdentities = applyManualTurnRequests(state, now);
const idleCooling = await readIdleCoolingSnapshot({
config,
identities: registry.identities,
@@ -279,6 +298,7 @@ async function main(): Promise {
completedThisTick,
runtimeSnapshots.restStates,
idleCooling,
+ manualOverrideIdentities,
);
const queuedIdentityIds: string[] = [];
@@ -291,6 +311,7 @@ async function main(): Promise {
participant.lastTurnAt = state.initiativeClock;
participant.queuedCount += 1;
participant.nextTurnAt = Math.max(state.initiativeClock, participant.nextTurnAt) + recoveryMinutes;
+ consumeManualTurnRequests(state, participant.identityId, "consumed", "dry run selected");
queuedIdentityIds.push(participant.identityId);
state.history.push({
type: "dry_run_selected",
@@ -335,6 +356,7 @@ async function main(): Promise {
participant.lastTurnAt = state.initiativeClock;
participant.queuedCount += 1;
participant.currentLoad = 1;
+ consumeManualTurnRequests(state, participant.identityId, "consumed", `queued ${turn.activeJobId}`);
if (pendingMentions.length > 0) {
const consumedIds = new Set(pendingMentions.map((entry) => entry.id));
state.pendingMentions = state.pendingMentions.filter((entry) => !consumedIds.has(entry.id));
@@ -1560,11 +1582,13 @@ function selectReadyParticipants(
completedThisTick: Set,
restStates: Map,
idleCooling: IdleCoolingSnapshot,
+ manualOverrideIdentities: Set,
): FaceHeartbeatParticipant[] {
const pendingMentionCounts = countPendingMentionsByIdentity(state.pendingMentions);
const eligible = state.participants
.filter((participant) => {
const pendingMentionCount = pendingMentionCounts.get(participant.identityId) ?? 0;
+ const manualOverrideCount = manualOverrideIdentities.has(participant.identityId) ? 1 : 0;
const restState = restStates.get(participant.identityId);
return (
participant.status === "active" &&
@@ -1573,6 +1597,7 @@ function selectReadyParticipants(
!(
participant.participantKind === "repo_face" &&
pendingMentionCount === 0 &&
+ manualOverrideCount === 0 &&
restState?.isNapping === true &&
idleCooling.napModeActive !== true
)
@@ -1586,6 +1611,12 @@ function selectReadyParticipants(
const ready = eligible
.filter((participant) => participant.nextTurnAt <= state.initiativeClock)
.sort((left, right) => {
+ const manualDelta =
+ Number(manualOverrideIdentities.has(right.identityId)) -
+ Number(manualOverrideIdentities.has(left.identityId));
+ if (manualDelta !== 0) {
+ return manualDelta;
+ }
const pendingDelta =
(pendingMentionCounts.get(right.identityId) ?? 0) -
(pendingMentionCounts.get(left.identityId) ?? 0);
@@ -1610,7 +1641,12 @@ function selectReadyParticipants(
}
const mentioned = ready.filter((participant) => (pendingMentionCounts.get(participant.identityId) ?? 0) > 0);
- const unprompted = ready.filter((participant) => (pendingMentionCounts.get(participant.identityId) ?? 0) === 0);
+ const manual = ready.filter((participant) => manualOverrideIdentities.has(participant.identityId));
+ const prompted = uniqueParticipants([...manual, ...mentioned]);
+ const unprompted = ready.filter((participant) =>
+ (pendingMentionCounts.get(participant.identityId) ?? 0) === 0 &&
+ !manualOverrideIdentities.has(participant.identityId)
+ );
const napRuns = idleCooling.napModeActive === true
? unprompted.filter((participant) => restStates.get(participant.identityId)?.isNapping === true)
: [];
@@ -1618,11 +1654,24 @@ function selectReadyParticipants(
const coolingAllowsUnpromptedTurn =
!idleCooling.nextUnpromptedTurnAllowedAt ||
Date.parse(idleCooling.nextUnpromptedTurnAllowedAt) <= Date.now();
- const cooled = coolingAllowsUnpromptedTurn && mentioned.length < maxJobs
+ const cooled = coolingAllowsUnpromptedTurn && prompted.length < maxJobs
? (napRuns[0] ? napRuns : awakeRuns).slice(0, 1)
: [];
- return [...mentioned, ...cooled].slice(0, maxJobs);
+ return [...prompted, ...cooled].slice(0, maxJobs);
+}
+
+function uniqueParticipants(participants: FaceHeartbeatParticipant[]): FaceHeartbeatParticipant[] {
+ const seen = new Set();
+ const unique: FaceHeartbeatParticipant[] = [];
+ for (const participant of participants) {
+ if (seen.has(participant.identityId)) {
+ continue;
+ }
+ seen.add(participant.identityId);
+ unique.push(participant);
+ }
+ return unique;
}
function advanceInitiativeClockFromWallClock(state: FaceHeartbeatState, now: Date): void {
@@ -1677,6 +1726,75 @@ function applyPendingMentionPriority(state: FaceHeartbeatState): void {
}
}
+function applyManualTurnRequests(state: FaceHeartbeatState, now: Date): Set {
+ const manualOverrideIdentities = new Set();
+ const participantsByIdentity = new Map(state.participants.map((participant) => [participant.identityId, participant]));
+ let changed = false;
+
+ for (const request of state.controls.manualTurnRequests) {
+ if (request.status !== "pending" && request.status !== "applied") {
+ continue;
+ }
+
+ const participant = participantsByIdentity.get(request.identityId);
+ if (!participant || participant.status !== "active") {
+ request.status = "rejected";
+ request.note = "No active participant with that identity.";
+ changed = true;
+ continue;
+ }
+
+ if (participant.currentLoad >= 1 || participant.activeJobId) {
+ request.status = "applied";
+ request.note = "Participant is already running; request will clear after the turn closes.";
+ manualOverrideIdentities.add(request.identityId);
+ changed = true;
+ continue;
+ }
+
+ participant.nextTurnAt = Math.min(participant.nextTurnAt, state.initiativeClock);
+ manualOverrideIdentities.add(request.identityId);
+ if (request.status !== "applied") {
+ request.status = "applied";
+ request.note = "Pulled to the front of the CTB queue.";
+ state.history.push({
+ type: "manual_turn_override_applied",
+ identityId: request.identityId,
+ requestId: request.id,
+ appliedAt: now.toISOString(),
+ initiativeClock: state.initiativeClock,
+ });
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ state.controls.updatedAt = now.toISOString();
+ }
+ state.controls.manualTurnRequests = state.controls.manualTurnRequests.slice(-20);
+ return manualOverrideIdentities;
+}
+
+function consumeManualTurnRequests(
+ state: FaceHeartbeatState,
+ identityId: string,
+ status: ManualTurnRequest["status"],
+ note: string,
+): void {
+ const now = new Date().toISOString();
+ let changed = false;
+ for (const request of state.controls.manualTurnRequests) {
+ if ((request.status === "pending" || request.status === "applied") && request.identityId === identityId) {
+ request.status = status;
+ request.note = note;
+ changed = true;
+ }
+ }
+ if (changed) {
+ state.controls.updatedAt = now;
+ }
+}
+
function pendingMentionsForParticipant(
state: FaceHeartbeatState,
identityId: string,
@@ -4750,6 +4868,7 @@ async function readHeartbeatState(path: string): Promise {
pendingMentions: Array.isArray(parsed.pendingMentions)
? parsed.pendingMentions.filter(isRepoFacePendingMention)
: [],
+ controls: normalizeHeartbeatControls((parsed as { controls?: unknown }).controls),
};
}
if (Array.isArray(parsed.participants)) {
@@ -4767,6 +4886,7 @@ async function readHeartbeatState(path: string): Promise {
participants: [],
history: [],
pendingMentions: [],
+ controls: defaultHeartbeatControls(),
};
}
@@ -4826,6 +4946,7 @@ function migrateLegacyHeartbeatState(
pendingMentions: Array.isArray(parsed.pendingMentions)
? parsed.pendingMentions.filter(isRepoFacePendingMention)
: [],
+ controls: defaultHeartbeatControls(),
history: [
...(Array.isArray(parsed.history) ? parsed.history : []),
{
@@ -4838,6 +4959,50 @@ function migrateLegacyHeartbeatState(
};
}
+function defaultHeartbeatControls(): FaceHeartbeatControls {
+ return {
+ cadenceMultiplier: 1,
+ manualTurnRequests: [],
+ };
+}
+
+function normalizeHeartbeatControls(value: unknown): FaceHeartbeatControls {
+ if (!value || typeof value !== "object") {
+ return defaultHeartbeatControls();
+ }
+
+ const record = value as Record;
+ const cadenceMultiplier = Number(record.cadenceMultiplier);
+ const manualTurnRequests = Array.isArray(record.manualTurnRequests)
+ ? record.manualTurnRequests.flatMap((entry): ManualTurnRequest[] => {
+ if (!entry || typeof entry !== "object") {
+ return [];
+ }
+ const request = entry as Record;
+ const identityId = typeof request.identityId === "string" ? request.identityId.trim() : "";
+ if (!identityId) {
+ return [];
+ }
+ const status = request.status === "applied" || request.status === "consumed" || request.status === "rejected"
+ ? request.status
+ : "pending";
+ return [{
+ id: typeof request.id === "string" && request.id.trim() ? request.id : `manual-${Date.now()}-${identityId}`,
+ identityId,
+ requestedAt: typeof request.requestedAt === "string" ? request.requestedAt : new Date().toISOString(),
+ status,
+ note: typeof request.note === "string" ? request.note : undefined,
+ }];
+ })
+ : [];
+
+ return {
+ cadenceMultiplier: Number.isFinite(cadenceMultiplier) ? clamp(cadenceMultiplier, 0.1, 12) : 1,
+ manualTurnRequests: manualTurnRequests.slice(-20),
+ updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
+ };
+}
+
function isRepoFacePendingMention(value: unknown): value is RepoFacePendingMention {
if (!value || typeof value !== "object") {
return false;
diff --git a/state/evidence.jsonl b/state/evidence.jsonl
index dcb6472..7f17b64 100644
--- a/state/evidence.jsonl
+++ b/state/evidence.jsonl
@@ -324,3 +324,4 @@
{"ts":"2026-05-24T19:38:23Z","type":"implementation","status":"ok","note":"Repo Face affect now has socialBiases for neuroticism, threat sensitivity, hostile attribution, reassurance need, grievance retention, status vigilance, and trust baseline. Active Faces plus Void were seeded with archetypal bias baselines; Nibu's projection smoke shows the biases reaching child prompt context."}
{"ts":"2026-05-24T21:25:43Z","type":"implementation","status":"ok","note":"Repo Face idle cooling now decays over hours by default: cooling after 180 minutes, nap mode after 360 minutes, cooled unprompted turns every 120 minutes; nap mode can select napping Faces for sparse dream turns and nap memory maintenance has a 60-minute completion cooldown."}
{"ts":"2026-05-24T21:27:15Z","type":"implementation","status":"ok","note":"Added read-only VoidBot swarm web exporter: renders .voidbot/status/swarm-dashboard.html and swarm-state.json from heartbeat/orchestrator status, writes a CultCache snapshot for CultMesh at .voidbot/status/cultmesh/voidbot-swarm-state.cc, and supports --public redaction for future voidbot.gamecult.org hosting."}
+{"ts":"2026-05-24T21:42:11Z","type":"implementation","status":"ok","note":"Swarm dashboard now acts as local CTB control surface: heartbeat state carries scheduler-owned controls for cadenceMultiplier and manualTurnRequests, the heartbeat runner consumes those controls on pulse, and the CultMesh snapshot mirrors compact avatar-based upcoming turn order plus direct-mention shuffle evidence."}