// The plug-in kind registry (REQ-PLUG-1; the first two kinds are // REQ-LIMIT-4's). A kind names the exports its modules must provide, the // interface version they implement, and whether the organization file // switches on one module or a list. Adding a kind, or changing one, is a // core change: a reviewed edit to this file. // Optional proxy telemetry is separate from session usage-source and limit-check. export function checkUsageSettings(value) { const object = v => v !== null && typeof v === "object" && !Array.isArray(v); const bad = () => { throw new PluginError("invalid-usage-settings", "usage.cliproxyQueue.pollSeconds must be a whole number from 1 through 59"); }; if (!object(value) || Object.keys(value).some(k => k !== "cliproxyQueue")) bad(); if (Object.hasOwn(value, "cliproxyQueue")) { const queue = value.cliproxyQueue; if (!object(queue) || Object.keys(queue).some(k => k !== "pollSeconds")) bad(); if (Object.hasOwn(queue, "pollSeconds") && (!Number.isSafeInteger(queue.pollSeconds) || queue.pollSeconds < 1 || queue.pollSeconds >= 60)) bad(); } return structuredClone(value); } export const NAME = /^[a-z][a-z0-9-]*$/; export const KINDS = Object.freeze({ // Reads one turn's usage for the limit checks (row 124 converts the first). "usage-source": Object.freeze({ kind: "usage-source", interfaceVersion: 1, exports: Object.freeze(["readTurn"]), switch: "list", }), // Decides whether a launch may start, whether a running session must stop, // and what each limit has used (REQ-LIMIT-3; version 2 since row 125). Core // calls each with the business's limits and usage rows, never a store: // mayLaunch({ business, limits, subject, records, now }) -> { allow: true } | { allow: false, hit } // mustStop({ business, limits, subject, records, now }) -> { stop: false } | { stop: true, hit } // report({ business, limits, records, now }) -> [{ path, limit, since, count, kinds, unknownTurns }] // with hit = { path, limit, count, unknownTurns } and subject = { instance, provider, model, account }. // While a daily key quota is set (row 333), subject also carries modelKeyId and every export gets // lineages: one array of key ids per hired agent, current, retiring and earlier (R3). "limit-check": Object.freeze({ kind: "limit-check", interfaceVersion: 2, exports: Object.freeze(["mayLaunch", "mustStop", "report"]), switch: "list", }), // Answers web_search for a role's web tool (REQ-HARN-2, REQ-PLUG-2). The organization file names // one source, and the module's name is the choice: harness-builtin, searxng-bundled or // searxng-url (technical decision 0b81b0c5). The broker calls `search` with the business's // settings and an AbortSignal (`search(settings, query, deps, { signal })`); harness-builtin's `search` only refuses, since the harness serves web itself. "search-source": Object.freeze({ kind: "search-source", interfaceVersion: 1, exports: Object.freeze(["search"]), switch: "one", }), // Human project/thread surfaces; proof and the recovery writer remain in core. channel: Object.freeze({ kind: "channel", interfaceVersion: 1, exports: Object.freeze(["dispatch"]), switch: "list", }), // A managed session's harness (row 65, REQ-PLUG-2, Q156). Core reaches a harness only through // these, each a function the launcher, the bundle and the usage reader call on the host; the // sandboxed runner gets their answers as data in session.json. The organization file switches // on a list, and an instance's `harness` var names one of them. packages/plugins/README.md has // each one's contract: // adapter() -> the adapter script's absolute path levels() -> the thinking levels it applies // preflight({ env }) -> null | { code, message } version({ env }) -> string | null // countsAccount() -> true when its launches spend launch.account // mounts({ env }) -> read-only binds [{ src, dest, why }] // builtinWeb() -> true when it serves harness-builtin web // builtins({ tools, webBuiltin }) -> its names for the built-in tools // typedName(name) -> its name for a typed bus tool reliesOn() -> the row S0 lines // gateTimeout() -> the gate hook's timeouts | null // bundle({ dir, files, session, write, quote }) -> { files, digests, sources }, its own bundle files // env({ files, policy, tools, session }) -> its adapter variables, written into session.json // turnEnd() -> "marker" (a turn ends only with the turn marker) | "exit" // outputPolicy({ proxy }) -> "adapter" | "non-newline-stdout" | "stdout" // transcript({ runDir, store }) -> the directory its transcripts land in, for usage // compactions({ store, cursors, budget, streams, first }) -> { found, cursors, known, reason, version }, // the session store's compactions past the ledger's cursors (row 307) harness: Object.freeze({ kind: "harness", interfaceVersion: 1, switch: "list", exports: Object.freeze(["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins", "typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "outputPolicy", "transcript", "compactions"]), }), // Optional proxy telemetry (row 333): read({ readUsageQueue }) -> the drained events. The bus // hands the module that one function and nothing else from its management client (R3). usage: Object.freeze({ kind: "usage", interfaceVersion: 1, exports: Object.freeze(["read"]), switch: "one", }), "model-provider": Object.freeze({ kind: "model-provider", interfaceVersion: 1, exports: Object.freeze(["catalogue", "preflight", "route"]), switch: "list", }), // Project system types are installed modules, enabled per organization. "system-type": Object.freeze({ kind: "system-type", interfaceVersion: 1, exports: Object.freeze(["systemType"]), switch: "list", }), // A Console colour theme (stream S4, PLAN C5). A module is data only: theme() takes nothing and returns // { displayName, tokens: { light, dim, dark } } // where each mode is a flat map of the Console's colour custom properties to #rrggbb strings. theme.mjs // checks it strictly and refuses a module that isn't exactly that. Themes are found by listing // packages/plugins/theme/ (loader.loadInstalled), not by a name in a list; an organization picks one // with `appearance.theme`, never with `plugins`, so the switch below is only the kind's shape. theme: Object.freeze({ kind: "theme", interfaceVersion: 1, exports: Object.freeze(["theme"]), switch: "list", }), // Where an organization keeps its tasks (U4a, REQ-TASK-1). The organization file names one module; // with none, the bus uses `store`, the task store built into each organization's own bus. `vikunja` // is the optional Vikunja tracker, set up from the boot config's `trackers` as before. Core calls // adapter({ trackers }) -> async ({ broker, credentials, businesses, processing }) -> { handle, timeout, close } // once per bus, where handle(cap, verb, args) answers the eight task verbs for a role's cap and // timeout is in milliseconds. Every module answers the same `tasks` read through the broker. "task-tracker": Object.freeze({ kind: "task-tracker", interfaceVersion: 1, exports: Object.freeze(["adapter"]), switch: "one", }), }); export class PluginError extends Error { constructor(code, message, name = null) { super(message); this.code = code; this.name = "PluginError"; this.plugin = name; } } const raise = (code, message, name) => { throw new PluginError(code, message, name); }; // The module names one kind's setting switches on, checked against the // kind's cardinality and the name pattern. A list may be empty. export function namesFor(spec, setting, where, fail = raise) { if (spec.switch === "one") { if (typeof setting !== "string") fail("bad-setting", `${where} must be one module name`); } else if (!Array.isArray(setting)) { fail("bad-setting", `${where} must be a list of module names`); } const names = spec.switch === "one" ? [setting] : setting; const seen = new Set(); for (const name of names) { if (typeof name !== "string" || !NAME.test(name)) { fail("bad-name", `${where} names ${JSON.stringify(name)}, which doesn't match ${NAME}`, typeof name === "string" ? name : null); } if (seen.has(name)) fail("bad-setting", `${where} names ${name} twice`, name); seen.add(name); } return names; } // The organization file's `plugins` object: one key per kind, each a known kind. export function checkPlugins(value, where, { kinds = KINDS, fail = raise } = {}) { if (value === null || typeof value !== "object" || Array.isArray(value)) { fail("bad-setting", `${where} must be an object with one key per kind`); } for (const [kind, setting] of Object.entries(value)) { if (!Object.hasOwn(kinds, kind)) { fail("unknown-kind", `${where} names unknown kind ${JSON.stringify(kind)}; adding a kind is a core change`); } namesFor(kinds[kind], setting, `${where}.${kind}`, fail); } return value; }