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

@@ -133,21 +133,6 @@ function getJson(pathKey) {
try { return JSON.parse(out); } catch { return undefined; }
}
function setJson(pathKey, value) { runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); }
function isPlainObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); }
function mergePreservingExisting(base, updates) {
if (!isPlainObject(updates)) return updates;
const out = isPlainObject(base) ? { ...base } : {};
for (const [key, nextValue] of Object.entries(updates)) {
const currentValue = out[key];
if (nextValue === undefined) continue;
if (isPlainObject(nextValue)) { out[key] = mergePreservingExisting(currentValue, nextValue); continue; }
if (nextValue === null) { if (currentValue === undefined) out[key] = null; continue; }
if (typeof nextValue === "string") { if (nextValue === "" && currentValue !== undefined) continue; out[key] = nextValue; continue; }
if (Array.isArray(nextValue)) { if (nextValue.length === 0 && Array.isArray(currentValue) && currentValue.length > 0) continue; out[key] = nextValue; continue; }
out[key] = nextValue;
}
return out;
}
function syncDirRecursive(src, dest) {
fs.mkdirSync(dest, { recursive: true });
fs.cpSync(src, dest, { recursive: true, force: true });
@@ -187,24 +172,34 @@ if (mode === "install") {
}
step(4, 7, "configure plugin entry");
const plugins = getJson("plugins") || {};
const loadPaths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : [];
if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) loadPaths.push(PLUGIN_INSTALL_DIR);
plugins.load = plugins.load || {}; plugins.load.paths = loadPaths;
plugins.entries = plugins.entries || {};
const existingDirigent = isPlainObject(plugins.entries.dirigent) ? plugins.entries.dirigent : {};
const desired = {
enabled: true,
config: {
enabled: true, discordOnly: true, listMode: "human-list",
humanList: [], agentList: [],
channelPoliciesFile: path.join(OPENCLAW_DIR, "dirigent-channel-policies.json"),
endSymbols: ["🔚"], schedulingIdentifier: "➡️",
noReplyProvider: NO_REPLY_PROVIDER_ID, noReplyModel: NO_REPLY_MODEL_ID, noReplyPort: NO_REPLY_PORT,
},
};
plugins.entries.dirigent = mergePreservingExisting(existingDirigent, desired);
setJson("plugins", plugins);
// Plugin load path — safe to read/write (not sensitive)
const loadPaths = getJson("plugins.load.paths") || [];
if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) {
loadPaths.push(PLUGIN_INSTALL_DIR);
setJson("plugins.load.paths", loadPaths);
}
// For each config field: only write the default if the field has no value.
// Sensitive fields (e.g. moderatorBotToken) are never touched — user sets them manually.
// `getJson` returns undefined if the field is unset; __OPENCLAW_REDACTED__ counts as "set".
function setIfMissing(pathKey, defaultVal) {
const existing = getJson(pathKey);
if (existing === undefined || existing === null) setJson(pathKey, defaultVal);
}
setIfMissing("plugins.entries.dirigent.enabled", true);
const cp = "plugins.entries.dirigent.config";
setIfMissing(`${cp}.enabled`, true);
setIfMissing(`${cp}.discordOnly`, true);
setIfMissing(`${cp}.listMode`, "human-list");
setIfMissing(`${cp}.humanList`, []);
setIfMissing(`${cp}.agentList`, []);
setIfMissing(`${cp}.channelPoliciesFile`, path.join(OPENCLAW_DIR, "dirigent-channel-policies.json"));
setIfMissing(`${cp}.endSymbols`, ["🔚"]);
setIfMissing(`${cp}.schedulingIdentifier`, "➡️");
setIfMissing(`${cp}.noReplyProvider`, NO_REPLY_PROVIDER_ID);
setIfMissing(`${cp}.noReplyModel`, NO_REPLY_MODEL_ID);
setIfMissing(`${cp}.noReplyPort`, NO_REPLY_PORT);
// moderatorBotToken: intentionally not touched — set manually via:
// openclaw config set plugins.entries.dirigent.config.moderatorBotToken "<token>"
ok("plugin configured");
step(5, 7, "configure no-reply provider");