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>
295 lines
13 KiB
TypeScript
295 lines
13 KiB
TypeScript
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
import type { ChannelStore, ChannelMode } from "../core/channel-store.js";
|
|
import type { IdentityRegistry } from "../core/identity-registry.js";
|
|
import { fetchAdminGuilds, fetchGuildChannels } from "../core/moderator-discord.js";
|
|
import { scanPaddedCell } from "../core/padded-cell.js";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
|
|
const SWITCHABLE_MODES: ChannelMode[] = ["none", "chat", "report"];
|
|
const LOCKED_MODES = new Set<ChannelMode>(["work", "discussion"]);
|
|
|
|
function html(strings: TemplateStringsArray, ...values: unknown[]): string {
|
|
return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "");
|
|
}
|
|
|
|
function escapeHtml(s: unknown): string {
|
|
return String(s ?? "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function modeBadge(mode: ChannelMode): string {
|
|
const colors: Record<ChannelMode, string> = {
|
|
none: "#888", chat: "#5865f2", report: "#57f287",
|
|
work: "#fee75c", discussion: "#eb459e",
|
|
};
|
|
return `<span style="background:${colors[mode]};color:#fff;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">${escapeHtml(mode)}</span>`;
|
|
}
|
|
|
|
function buildPage(content: string): string {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Dirigent</title>
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;padding:24px}
|
|
h1{font-size:1.6rem;margin-bottom:4px;color:#fff}
|
|
.subtitle{color:#888;font-size:0.85rem;margin-bottom:24px}
|
|
h2{font-size:1.1rem;margin:24px 0 12px;color:#ccc;border-bottom:1px solid #333;padding-bottom:8px}
|
|
table{width:100%;border-collapse:collapse;margin-bottom:16px;font-size:0.9rem}
|
|
th{text-align:left;padding:8px 12px;background:#252540;color:#aaa;font-weight:600;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.05em}
|
|
td{padding:8px 12px;border-top:1px solid #2a2a4a}
|
|
tr:hover td{background:#1e1e3a}
|
|
input,select{background:#252540;border:1px solid #444;color:#e0e0e0;padding:6px 10px;border-radius:4px;font-size:0.9rem}
|
|
input:focus,select:focus{outline:none;border-color:#5865f2}
|
|
button{background:#5865f2;color:#fff;border:none;padding:7px 16px;border-radius:4px;cursor:pointer;font-size:0.9rem}
|
|
button:hover{background:#4752c4}
|
|
button.danger{background:#ed4245}
|
|
button.danger:hover{background:#c03537}
|
|
button.secondary{background:#36393f}
|
|
button.secondary:hover{background:#2f3136}
|
|
.row{display:flex;gap:8px;align-items:center;margin-bottom:8px}
|
|
.guild-section{background:#16213e;border:1px solid #2a2a4a;border-radius:8px;margin-bottom:16px;overflow:hidden}
|
|
.guild-header{padding:12px 16px;background:#1f2d50;display:flex;align-items:center;gap:10px;font-weight:600}
|
|
.guild-name{font-size:1rem;color:#fff}
|
|
.guild-id{font-size:0.75rem;color:#888;font-family:monospace}
|
|
.msg{padding:8px 12px;border-radius:4px;margin:8px 0;font-size:0.85rem}
|
|
.msg.ok{background:#1a4a2a;border:1px solid #2d7a3a;color:#57f287}
|
|
.msg.err{background:#4a1a1a;border:1px solid #7a2d2d;color:#ed4245}
|
|
.spinner{display:none}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Dirigent</h1>
|
|
<p class="subtitle">OpenClaw multi-agent turn management</p>
|
|
${content}
|
|
<script>
|
|
async function apiCall(endpoint, method, body) {
|
|
const resp = await fetch(endpoint, {
|
|
method: method || 'GET',
|
|
headers: body ? { 'Content-Type': 'application/json' } : {},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
return resp.json();
|
|
}
|
|
function showMsg(el, text, isErr) {
|
|
el.className = 'msg ' + (isErr ? 'err' : 'ok');
|
|
el.textContent = text;
|
|
el.style.display = 'block';
|
|
setTimeout(() => { el.style.display = 'none'; }, 4000);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
export function registerControlPage(deps: {
|
|
api: OpenClawPluginApi;
|
|
channelStore: ChannelStore;
|
|
identityRegistry: IdentityRegistry;
|
|
moderatorBotToken: string | undefined;
|
|
openclawDir: string;
|
|
hasPaddedCell: () => boolean;
|
|
}): void {
|
|
const { api, channelStore, identityRegistry, moderatorBotToken, openclawDir, hasPaddedCell } = deps;
|
|
|
|
// ── Main page ──────────────────────────────────────────────────────────────
|
|
api.registerHttpRoute({
|
|
path: "/dirigent",
|
|
auth: "gateway",
|
|
match: "exact",
|
|
handler: async (_req, res) => {
|
|
const entries = identityRegistry.list();
|
|
const paddedCellBtn = hasPaddedCell()
|
|
? `<button class="secondary" onclick="rescanPaddedCell()">Re-scan padded-cell</button>`
|
|
: "";
|
|
|
|
// Build identity table rows
|
|
const identityRows = entries.map((e) => html`
|
|
<tr data-agent-id="${escapeHtml(e.agentId)}">
|
|
<td><code>${escapeHtml(e.discordUserId)}</code></td>
|
|
<td>${escapeHtml(e.agentId)}</td>
|
|
<td>${escapeHtml(e.agentName)}</td>
|
|
<td><button class="danger" onclick="removeIdentity('${escapeHtml(e.agentId)}')">Remove</button></td>
|
|
</tr>`).join("");
|
|
|
|
// Build guild sections
|
|
let guildHtml = "<p style='color:#888'>Loading guilds…</p>";
|
|
if (moderatorBotToken) {
|
|
try {
|
|
const guilds = await fetchAdminGuilds(moderatorBotToken);
|
|
if (guilds.length === 0) {
|
|
guildHtml = "<p style='color:#888'>No guilds with admin permissions found.</p>";
|
|
} else {
|
|
guildHtml = "";
|
|
for (const guild of guilds) {
|
|
const channels = await fetchGuildChannels(moderatorBotToken, guild.id);
|
|
const channelRows = channels.map((ch) => {
|
|
const mode = channelStore.getMode(ch.id);
|
|
const locked = LOCKED_MODES.has(mode);
|
|
const dropdown = locked
|
|
? modeBadge(mode)
|
|
: `<select onchange="setMode('${escapeHtml(ch.id)}', this.value)">
|
|
${SWITCHABLE_MODES.map((m) => `<option value="${m}"${m === mode ? " selected" : ""}>${m}</option>`).join("")}
|
|
</select>`;
|
|
return html`<tr>
|
|
<td><code style="font-size:0.8rem">${escapeHtml(ch.id)}</code></td>
|
|
<td>#${escapeHtml(ch.name)}</td>
|
|
<td>${dropdown}</td>
|
|
</tr>`;
|
|
}).join("");
|
|
|
|
guildHtml += html`
|
|
<div class="guild-section">
|
|
<div class="guild-header">
|
|
<span class="guild-name">${escapeHtml(guild.name)}</span>
|
|
<span class="guild-id">${escapeHtml(guild.id)}</span>
|
|
</div>
|
|
<table>
|
|
<thead><tr><th>Channel ID</th><th>Name</th><th>Mode</th></tr></thead>
|
|
<tbody>${channelRows}</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
guildHtml = `<p style="color:#ed4245">Failed to load guilds: ${escapeHtml(String(err))}</p>`;
|
|
}
|
|
} else {
|
|
guildHtml = "<p style='color:#888'>moderatorBotToken not configured — cannot list guilds.</p>";
|
|
}
|
|
|
|
const content = html`
|
|
<h2>Identity Registry</h2>
|
|
<div id="identity-msg" class="msg" style="display:none"></div>
|
|
<table>
|
|
<thead><tr><th>Discord User ID</th><th>Agent ID</th><th>Agent Name</th><th></th></tr></thead>
|
|
<tbody id="identity-tbody">${identityRows}</tbody>
|
|
</table>
|
|
<div class="row">
|
|
<input id="new-discord-id" placeholder="Discord user ID" style="width:200px">
|
|
<input id="new-agent-id" placeholder="Agent ID" style="width:160px">
|
|
<input id="new-agent-name" placeholder="Agent name (optional)" style="width:180px">
|
|
<button onclick="addIdentity()">Add</button>
|
|
${paddedCellBtn}
|
|
</div>
|
|
|
|
<h2>Guild & Channel Configuration</h2>
|
|
<div id="channel-msg" class="msg" style="display:none"></div>
|
|
${guildHtml}
|
|
|
|
<script>
|
|
async function addIdentity() {
|
|
const discordUserId = document.getElementById('new-discord-id').value.trim();
|
|
const agentId = document.getElementById('new-agent-id').value.trim();
|
|
const agentName = document.getElementById('new-agent-name').value.trim();
|
|
if (!discordUserId || !agentId) return alert('Discord user ID and Agent ID are required');
|
|
const r = await apiCall('/dirigent/api/identity', 'POST', { discordUserId, agentId, agentName: agentName || agentId });
|
|
showMsg(document.getElementById('identity-msg'), r.ok ? 'Added.' : r.error, !r.ok);
|
|
if (r.ok) location.reload();
|
|
}
|
|
async function removeIdentity(agentId) {
|
|
if (!confirm('Remove identity for ' + agentId + '?')) return;
|
|
const r = await apiCall('/dirigent/api/identity/' + encodeURIComponent(agentId), 'DELETE');
|
|
showMsg(document.getElementById('identity-msg'), r.ok ? 'Removed.' : r.error, !r.ok);
|
|
if (r.ok) location.reload();
|
|
}
|
|
async function setMode(channelId, mode) {
|
|
const r = await apiCall('/dirigent/api/channel-mode', 'POST', { channelId, mode });
|
|
showMsg(document.getElementById('channel-msg'), r.ok ? 'Mode updated.' : r.error, !r.ok);
|
|
}
|
|
async function rescanPaddedCell() {
|
|
const r = await apiCall('/dirigent/api/rescan-padded-cell', 'POST');
|
|
showMsg(document.getElementById('identity-msg'), r.ok ? ('Scanned: ' + r.count + ' entries.') : r.error, !r.ok);
|
|
if (r.ok) location.reload();
|
|
}
|
|
</script>`;
|
|
|
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
res.end(buildPage(content));
|
|
},
|
|
});
|
|
|
|
// ── API: add identity ──────────────────────────────────────────────────────
|
|
api.registerHttpRoute({
|
|
path: "/dirigent/api/identity",
|
|
auth: "gateway",
|
|
match: "exact",
|
|
handler: (req, res) => {
|
|
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
|
|
let body = "";
|
|
req.on("data", (c: Buffer) => { body += c.toString(); });
|
|
req.on("end", () => {
|
|
try {
|
|
const { discordUserId, agentId, agentName } = JSON.parse(body);
|
|
if (!discordUserId || !agentId) throw new Error("discordUserId and agentId required");
|
|
identityRegistry.upsert({ discordUserId, agentId, agentName: agentName ?? agentId });
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
} catch (err) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
|
}
|
|
});
|
|
},
|
|
});
|
|
|
|
// ── API: remove identity ───────────────────────────────────────────────────
|
|
api.registerHttpRoute({
|
|
path: "/dirigent/api/identity/",
|
|
auth: "gateway",
|
|
match: "prefix",
|
|
handler: (req, res) => {
|
|
if (req.method !== "DELETE") { res.writeHead(405); res.end(); return; }
|
|
const agentId = decodeURIComponent((req.url ?? "").replace("/dirigent/api/identity/", ""));
|
|
const removed = identityRegistry.remove(agentId);
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: removed, error: removed ? undefined : "Not found" }));
|
|
},
|
|
});
|
|
|
|
// ── API: set channel mode ──────────────────────────────────────────────────
|
|
api.registerHttpRoute({
|
|
path: "/dirigent/api/channel-mode",
|
|
auth: "gateway",
|
|
match: "exact",
|
|
handler: (req, res) => {
|
|
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
|
|
let body = "";
|
|
req.on("data", (c: Buffer) => { body += c.toString(); });
|
|
req.on("end", () => {
|
|
try {
|
|
const { channelId, mode } = JSON.parse(body) as { channelId: string; mode: ChannelMode };
|
|
if (!channelId || !mode) throw new Error("channelId and mode required");
|
|
if (LOCKED_MODES.has(mode)) throw new Error(`Mode "${mode}" is locked to creation tools`);
|
|
channelStore.setMode(channelId, mode);
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
} catch (err) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
|
}
|
|
});
|
|
},
|
|
});
|
|
|
|
// ── API: rescan padded-cell ────────────────────────────────────────────────
|
|
api.registerHttpRoute({
|
|
path: "/dirigent/api/rescan-padded-cell",
|
|
auth: "gateway",
|
|
match: "exact",
|
|
handler: (req, res) => {
|
|
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
|
|
const count = scanPaddedCell(identityRegistry, openclawDir, api.logger);
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: count >= 0, count, error: count < 0 ? "padded-cell not detected" : undefined }));
|
|
},
|
|
});
|
|
}
|