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:
294
plugin/web/control-page.ts
Normal file
294
plugin/web/control-page.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
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 }));
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user