quoxcli 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +1 -0
- package/lib/commands/mail.js +161 -20
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +55 -144
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +259 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +2 -3
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +824 -0
- package/lib/commands/wazuhIntake.js +317 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +8 -0
- package/lib/core/config.js +28 -2
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +37 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +1 -1
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +185 -34
- package/lib/quoxon/app.js +1032 -152
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +69 -11
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +1 -1
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +7 -0
- package/lib/quoxon/commands.js +40 -10
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +43 -7
- package/lib/quoxon/extensions.js +398 -38
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +5 -1
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +20 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +63 -14
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +18 -5
- package/lib/quoxon/slash/broadcastRun.js +13 -2
- package/lib/quoxon/slash/cliLeaves.json +63 -1
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +22 -1
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/restart.js +7 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +17 -0
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +36 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
package/lib/quoxon/extensions.js
CHANGED
|
@@ -9,22 +9,30 @@ import fs from 'node:fs';
|
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { execFileSync } from 'node:child_process';
|
|
12
|
-
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
13
13
|
|
|
14
14
|
export const THROTTLE_MS = 1000;
|
|
15
|
+
export const RELOAD_DEBOUNCE_MS = 2000; // fs.watch burst -> one check
|
|
16
|
+
export const HEARTBEAT_MS = 15000; // contract v1 F3: a push this often while any extension is loaded
|
|
17
|
+
export const STALE_MS = 60000; // state.stale once the last good server read is older than this
|
|
18
|
+
export const CLOSE_TIMEOUT_MS = 500; // F7: close() is awaited this long, then the instance is dropped
|
|
19
|
+
export const PANEL_SLOW_MS = 50; // F1: a render slower than this is an error
|
|
20
|
+
export const PANEL_RETRY_MS = 5000; // F1: a failed panel is not rendered again for this long
|
|
21
|
+
export const INVALIDATE_MS = 250; // F1: invalidate() is throttled to 4 per second
|
|
22
|
+
export const RELOAD_POLL_MS = 30000; // stat poll fallback: global installs replace directories, which kills file watches
|
|
15
23
|
export const notInstalled = (name) => `not installed: npm i -g ${name}`;
|
|
16
24
|
const HEALTH = new Set(['ok', 'warn', 'down']);
|
|
17
25
|
const CHAIN = new Set(['ok', 'broken', 'unknown']);
|
|
18
26
|
const FLEET_MAX = 16;
|
|
19
27
|
|
|
20
28
|
// ---- the listing: ~/.quox/extensions.json ---------------------------------------------------------------------------
|
|
21
|
-
export const extListPath = (home
|
|
29
|
+
export const extListPath = (home) => (home === undefined && process.env.QUOX_EXT_LIST) || path.join(home ?? os.homedir(), '.quox', 'extensions.json'); // QUOX_EXT_LIST overrides the default location
|
|
22
30
|
const validName = (n) => typeof n === 'string' && /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i.test(n);
|
|
23
31
|
|
|
24
32
|
export function readExtList(file = extListPath()) {
|
|
25
33
|
try {
|
|
26
34
|
const j = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
27
|
-
return (Array.isArray(j?.extensions) ? j.extensions : []).filter((x) => validName(x?.name)).map((x) => ({ name: x.name, enabled: x.enabled !== false }));
|
|
35
|
+
return (Array.isArray(j?.extensions) ? j.extensions : []).filter((x) => validName(x?.name)).map((x) => ({ name: x.name, enabled: x.enabled !== false, ...(x.settings && typeof x.settings === 'object' && !Array.isArray(x.settings) ? { settings: x.settings } : {}) }));
|
|
28
36
|
} catch { return []; } // no file or unreadable: nothing is listed, so nothing loads
|
|
29
37
|
}
|
|
30
38
|
export function writeExtList(list, file = extListPath()) {
|
|
@@ -53,6 +61,53 @@ export function extSetEnabled(name, enabled, file = extListPath()) {
|
|
|
53
61
|
return { ok: true, text: `${name} ${enabled ? 'enabled' : 'disabled'}.` };
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
// ---- settings (contract v1): typed, stored on the listing entry ------------------------------------------------------
|
|
65
|
+
const SETTING_TYPES = new Set(['string', 'number', 'bool', 'enum']);
|
|
66
|
+
/** Declared settings -> clean defs. Bad entries (no key, unknown type, enum without choices) are dropped. */
|
|
67
|
+
export function normalizeSettingDefs(arr) {
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const d of Array.isArray(arr) ? arr : []) {
|
|
70
|
+
if (!d || typeof d.key !== 'string' || !/^[A-Za-z_][\w.-]*$/.test(d.key) || !SETTING_TYPES.has(d.type)) continue;
|
|
71
|
+
if (d.type === 'enum' && !(Array.isArray(d.choices) && d.choices.length)) continue;
|
|
72
|
+
out.push({ key: d.key, type: d.type, default: d.default, help: String(d.help || ''), ...(d.type === 'enum' ? { choices: d.choices.map(String) } : {}) });
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** A typed value from raw text (or an already-typed value). -> { ok:true, value } | { ok:false, error } */
|
|
77
|
+
export function coerceSetting(def, raw) {
|
|
78
|
+
const t = typeof raw === 'string' ? raw.trim() : raw;
|
|
79
|
+
if (def.type === 'string') return typeof raw === 'string' ? { ok: true, value: raw } : { ok: false, error: `${def.key} is a string.` };
|
|
80
|
+
if (def.type === 'number') {
|
|
81
|
+
const n = typeof t === 'number' ? t : (t === '' || t == null ? NaN : Number(t));
|
|
82
|
+
return Number.isFinite(n) ? { ok: true, value: n } : { ok: false, error: `${def.key} must be a number (got "${raw}").` };
|
|
83
|
+
}
|
|
84
|
+
if (def.type === 'bool') {
|
|
85
|
+
if (typeof t === 'boolean') return { ok: true, value: t };
|
|
86
|
+
if (/^(true|on|yes|1)$/i.test(String(t))) return { ok: true, value: true };
|
|
87
|
+
if (/^(false|off|no|0)$/i.test(String(t))) return { ok: true, value: false };
|
|
88
|
+
return { ok: false, error: `${def.key} must be true or false (got "${raw}").` };
|
|
89
|
+
}
|
|
90
|
+
return typeof raw === 'string' && def.choices.includes(raw) ? { ok: true, value: raw } : { ok: false, error: `${def.key} must be one of: ${def.choices.join(', ')} (got "${raw}").` };
|
|
91
|
+
}
|
|
92
|
+
/** Defaults overlaid with stored values; a stored value that no longer validates falls back to the default. */
|
|
93
|
+
export function resolveSettings(defs, stored = {}) {
|
|
94
|
+
const out = {};
|
|
95
|
+
for (const d of defs) {
|
|
96
|
+
const c = stored && Object.hasOwn(stored, d.key) ? coerceSetting(d, stored[d.key]) : { ok: false };
|
|
97
|
+
out[d.key] = c.ok ? c.value : d.default;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
export function readExtSettings(name, file = extListPath()) { return readExtList(file).find((x) => x.name === name)?.settings || {}; }
|
|
102
|
+
export function extStoreSetting(name, key, value, file = extListPath()) {
|
|
103
|
+
const list = readExtList(file);
|
|
104
|
+
const it = list.find((x) => x.name === name);
|
|
105
|
+
if (!it) return { ok: false, text: `${name} is not listed. quox ext add ${name}` };
|
|
106
|
+
it.settings = { ...(it.settings || {}), [key]: value };
|
|
107
|
+
writeExtList(list, file);
|
|
108
|
+
return { ok: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
56
111
|
// ---- resolution: env override, local, global ------------------------------------------------------------------------
|
|
57
112
|
export const envKey = (name) => `QUOX_EXT_${String(name).replace(/^@/, '').replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}_MODULE`;
|
|
58
113
|
const defaultImporter = (spec) => import(spec);
|
|
@@ -77,6 +132,24 @@ export function packageEntry(dir) {
|
|
|
77
132
|
} catch { return path.join(dir, 'index.js'); }
|
|
78
133
|
}
|
|
79
134
|
|
|
135
|
+
/** The entry file a bare specifier resolves to from here, or null (the importer may be a test double). */
|
|
136
|
+
function localEntry(name) {
|
|
137
|
+
try { const u = import.meta.resolve(name); return u.startsWith('file:') ? fileURLToPath(u) : null; } catch { return null; }
|
|
138
|
+
}
|
|
139
|
+
/** Nearest package.json at or above an entry file, or null. */
|
|
140
|
+
export function findPackageJson(entry) {
|
|
141
|
+
if (!entry) return null;
|
|
142
|
+
let dir = path.dirname(entry);
|
|
143
|
+
for (let i = 0; i < 8; i++) {
|
|
144
|
+
const f = path.join(dir, 'package.json');
|
|
145
|
+
if (fs.existsSync(f)) return f;
|
|
146
|
+
const up = path.dirname(dir);
|
|
147
|
+
if (up === dir) break;
|
|
148
|
+
dir = up;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
80
153
|
/**
|
|
81
154
|
* Find and import one extension. Order: QUOX_EXT_<NAME>_MODULE, local import(name), then the global npm root
|
|
82
155
|
* (dirname(execPath)/../lib/node_modules first, which costs no spawn; `npm root -g` once, cached, only if that misses).
|
|
@@ -87,11 +160,11 @@ export async function resolveExtension(name, { env = process.env, importer = def
|
|
|
87
160
|
const ov = env?.[envKey(name)];
|
|
88
161
|
if (ov) {
|
|
89
162
|
tried.push(`${envKey(name)}=${ov}`);
|
|
90
|
-
try { return { ok: true, mod: await importer(path.isAbsolute(ov) ? pathToFileURL(ov).href : ov), where: `env ${envKey(name)} (${ov})
|
|
163
|
+
try { return { ok: true, mod: await importer(path.isAbsolute(ov) ? pathToFileURL(ov).href : ov), where: `env ${envKey(name)} (${ov})`, entry: path.isAbsolute(ov) ? ov : null }; }
|
|
91
164
|
catch (e) { return { ok: false, error: e?.message || String(e), tried }; } // an override that fails is an error, never a silent fall-through
|
|
92
165
|
}
|
|
93
166
|
tried.push(`local import('${name}')`);
|
|
94
|
-
try { return { ok: true, mod: await importer(name), where: `local (${name})
|
|
167
|
+
try { return { ok: true, mod: await importer(name), where: `local (${name})`, entry: localEntry(name) }; }
|
|
95
168
|
catch (e) { if (!NOT_FOUND(e)) return { ok: false, error: e?.message || String(e), tried }; }
|
|
96
169
|
const roots = [];
|
|
97
170
|
const guess = path.join(path.dirname(execPath || ''), '..', 'lib', 'node_modules');
|
|
@@ -102,7 +175,7 @@ export async function resolveExtension(name, { env = process.env, importer = def
|
|
|
102
175
|
const dir = path.join(root, name);
|
|
103
176
|
tried.push(`global ${dir}`);
|
|
104
177
|
if (!exists(dir)) continue;
|
|
105
|
-
try { return { ok: true, mod: await importer(pathToFileURL(packageEntry(dir)).href), where: `global (${dir})
|
|
178
|
+
try { return { ok: true, mod: await importer(pathToFileURL(packageEntry(dir)).href), where: `global (${dir})`, entry: packageEntry(dir) }; }
|
|
106
179
|
catch (e) { return { ok: false, error: e?.message || String(e), tried }; }
|
|
107
180
|
}
|
|
108
181
|
if (spawned) break;
|
|
@@ -113,13 +186,26 @@ export async function resolveExtension(name, { env = process.env, importer = def
|
|
|
113
186
|
return { ok: false, notFound: true, tried };
|
|
114
187
|
}
|
|
115
188
|
|
|
116
|
-
|
|
189
|
+
export const CONTRACT_VERSION = 1; // the highest extension contract this host supports (EXTENSIONS.md)
|
|
190
|
+
/** "a b c" -> ['a','b','c']; arrays pass through. */
|
|
191
|
+
export const splitArgs = (a) => (Array.isArray(a) ? a.map(String) : String(a ?? '').split(/\s+/).filter(Boolean));
|
|
192
|
+
|
|
193
|
+
/** An extension module -> { name, version, contractVersion, commands, settings, create } or null. Accepts default export or the module itself. */
|
|
117
194
|
export function normalizeExtension(mod, fallbackName) {
|
|
118
195
|
const ext = mod?.default && typeof mod.default === 'object' ? mod.default : mod;
|
|
119
196
|
const create = typeof ext?.create === 'function' ? ext.create : (typeof mod?.createDeck === 'function' ? mod.createDeck : null); // createDeck: quoxdeck v1
|
|
120
197
|
if (!create) return null;
|
|
121
|
-
const commands = (Array.isArray(ext.commands) ? ext.commands : []).filter((c) => /^\/[a-z][\w-]*$/i.test(c?.name || '') && typeof c.run === 'function')
|
|
122
|
-
|
|
198
|
+
const commands = (Array.isArray(ext.commands) ? ext.commands : []).filter((c) => /^\/[a-z][\w-]*$/i.test(c?.name || '') && typeof c.run === 'function')
|
|
199
|
+
.map((c) => ({ ...c, subcommands: (Array.isArray(c.subcommands) ? c.subcommands : []).filter((x) => typeof x?.name === 'string' && x.name).map((x) => ({ name: x.name, help: String(x.help || '') })) }));
|
|
200
|
+
const cv = ext.contractVersion;
|
|
201
|
+
return { name: ext.name || fallbackName, version: ext.version || null, contractVersion: cv === undefined ? null : cv, commands, settings: normalizeSettingDefs(ext.settings), create };
|
|
202
|
+
}
|
|
203
|
+
/** null = supported; otherwise the refusal line shown in /ext list. */
|
|
204
|
+
export function contractRefusal(ext) {
|
|
205
|
+
const cv = ext.contractVersion;
|
|
206
|
+
if (cv == null) return null; // legacy: no contract declared
|
|
207
|
+
if (!Number.isInteger(cv) || cv < 1) return `invalid contractVersion ${JSON.stringify(cv)} (must be an integer, 1 or more)`;
|
|
208
|
+
return cv > CONTRACT_VERSION ? `needs extension contract v${cv}, this quoxon supports v${CONTRACT_VERSION}` : null;
|
|
123
209
|
}
|
|
124
210
|
|
|
125
211
|
// ---- the snapshot every extension gets ------------------------------------------------------------------------------
|
|
@@ -139,7 +225,7 @@ export function buildFleet(hosts) {
|
|
|
139
225
|
};
|
|
140
226
|
}
|
|
141
227
|
|
|
142
|
-
export function buildSnapshot({ org = null, items = [], health = {}, loops = [], event = null, fleet = null, evidence = null, sessions = null } = {}) {
|
|
228
|
+
export function buildSnapshot({ org = null, items = [], health = {}, loops = [], event = null, fleet = null, evidence = null, sessions = null, at = null, stale = false } = {}) {
|
|
143
229
|
const list = (items || []).map((it, i) => {
|
|
144
230
|
const row = {
|
|
145
231
|
n: i + 1, id: it.id ?? null, risk: itemRisk(it),
|
|
@@ -153,6 +239,8 @@ export function buildSnapshot({ org = null, items = [], health = {}, loops = [],
|
|
|
153
239
|
const h = {};
|
|
154
240
|
for (const [k, v] of Object.entries(health || {})) if (HEALTH.has(v)) h[k] = v;
|
|
155
241
|
const state = {
|
|
242
|
+
...(at ? { at } : {}),
|
|
243
|
+
...(stale ? { stale: true } : {}),
|
|
156
244
|
org: { id: org?.id ?? null, name: org?.name ?? null },
|
|
157
245
|
approvals: { count: list.length, items: list },
|
|
158
246
|
health: h,
|
|
@@ -161,7 +249,7 @@ export function buildSnapshot({ org = null, items = [], health = {}, loops = [],
|
|
|
161
249
|
evidence: { chain: CHAIN.has(evidence?.chain) ? evidence.chain : 'unknown', checked_at: evidence?.checked_at ?? null },
|
|
162
250
|
sessions: { active: Math.max(0, Number(sessions?.active) || 0) }
|
|
163
251
|
};
|
|
164
|
-
if (event) state.event = { kind: event.kind, text: event.text, level: event.level || 'info' };
|
|
252
|
+
if (event) state.event = { ...(event.seq != null ? { seq: event.seq } : {}), ...(event.id != null ? { id: String(event.id) } : {}), kind: event.kind, text: event.text, level: event.level || 'info' };
|
|
165
253
|
return state;
|
|
166
254
|
}
|
|
167
255
|
|
|
@@ -177,37 +265,213 @@ export function pickDraft(item, n, option) {
|
|
|
177
265
|
return null;
|
|
178
266
|
}
|
|
179
267
|
|
|
268
|
+
// ---- panels: pure helpers --------------------------------------------------------------------------------------------
|
|
269
|
+
const ANSI = /^\x1b\[[0-9;?]*[ -/]*[@-~]/;
|
|
270
|
+
/** Clip one line to `width` visible columns, keeping escape sequences (they take no width). Closes colour if it cut. */
|
|
271
|
+
export function clipLine(line, width) {
|
|
272
|
+
const str = String(line ?? '').replace(/[\r\n]+/g, ' ');
|
|
273
|
+
let out = ''; let w = 0; let sawEsc = false;
|
|
274
|
+
for (let i = 0; i < str.length;) {
|
|
275
|
+
const m = str[i] === '\x1b' ? ANSI.exec(str.slice(i, i + 32)) : null;
|
|
276
|
+
if (m) { out += m[0]; i += m[0].length; sawEsc = true; continue; }
|
|
277
|
+
const cp = String.fromCodePoint(str.codePointAt(i));
|
|
278
|
+
if (w + 1 > width) return out + (sawEsc ? '\x1b[0m' : '');
|
|
279
|
+
out += cp; w += 1; i += cp.length;
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
/** What the panel region shows for a render result: an array of strings, at most `height` lines of `width` columns. */
|
|
284
|
+
export function clipPanel(lines, width, height) {
|
|
285
|
+
const arr = Array.isArray(lines) ? lines : [];
|
|
286
|
+
return arr.slice(0, Math.max(0, height)).map((l) => clipLine(l, Math.max(0, width)));
|
|
287
|
+
}
|
|
288
|
+
/** Panel region height: min(12, 40% of the terminal rows), at least 1. */
|
|
289
|
+
export const panelHeight = (rows) => Math.max(1, Math.min(12, Math.floor((Number(rows) || 24) * 0.4)));
|
|
290
|
+
/** A keys.js event, or one typed character, as the name a panel's onKey receives (EXTENSIONS.md). null = nothing to deliver. */
|
|
291
|
+
export function panelKeyName(e) {
|
|
292
|
+
if (typeof e === 'string') return e === ' ' ? 'space' : e;
|
|
293
|
+
if (e?.type === 'text') return e.text === ' ' ? 'space' : e.text;
|
|
294
|
+
if (e?.type !== 'key') return null;
|
|
295
|
+
const n = String(e.name);
|
|
296
|
+
const m = /^(ctrl|alt)-(.)$/.exec(n);
|
|
297
|
+
if (m) return `${m[1]}+${m[2]}`;
|
|
298
|
+
return n === 'newline' ? 'enter' : n;
|
|
299
|
+
}
|
|
300
|
+
const isoOf = (ms) => new Date(ms).toISOString();
|
|
301
|
+
|
|
180
302
|
// ---- the host -------------------------------------------------------------------------------------------------------
|
|
181
303
|
/**
|
|
182
|
-
* handlers: { openApproval(n), preselect(n, option), changed() } are the window's own actions
|
|
183
|
-
*
|
|
304
|
+
* handlers: { openApproval(n, id), preselect(n, option, id), changed(), note(text, tone) } are the window's own actions;
|
|
305
|
+
* openApproval/preselect returning exactly false means "that id is not there", and the emit is refused.
|
|
306
|
+
* getSettings() feeds create() for extensions that declare no settings (legacy). listFile is where per-extension settings live.
|
|
307
|
+
* Loaded extensions all receive the same deduped, 1/s-throttled snapshot, plus a heartbeat every 15 s.
|
|
184
308
|
*/
|
|
185
309
|
export function createExtensionHost({ importer = defaultImporter, env = process.env, execPath, npmRoot, exists, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout,
|
|
186
|
-
handlers = {}, log = () => {}, getSettings = () => ({})
|
|
187
|
-
|
|
310
|
+
handlers = {}, log = () => {}, getSettings = () => ({}), listFile = undefined, perf = () => performance.now(),
|
|
311
|
+
watch = (f, cb) => fs.watch(f, { persistent: false }, cb), statFn = (f) => fs.statSync(f), readText = (f) => fs.readFileSync(f, 'utf8'),
|
|
312
|
+
setPoll = setInterval, clearPoll = clearInterval } = {}) {
|
|
313
|
+
const loaded = new Map(); // name -> { ext, inst, where, entry, settings }
|
|
314
|
+
const watches = new Map(); // name -> { pkgPath, entry, sig, version, watcher, debounce, poll, busy }
|
|
315
|
+
const failed = new Map(); // name -> error text, for /ext list
|
|
316
|
+
let lastInput = null;
|
|
188
317
|
const starting = new Map();
|
|
189
318
|
let lastSent = 0; let lastKey = null; let pending = null; let timer = null;
|
|
190
|
-
let event = null; const prevLoops = new Map(); let prevCount = null;
|
|
319
|
+
let event = null; let eventSig = null; const prevLoops = new Map(); let prevCount = null; let seq = 0;
|
|
320
|
+
let beat = null;
|
|
321
|
+
const closedInsts = new WeakSet(); const closings = new Set();
|
|
322
|
+
const panelErr = new Map(); // name -> retry-after ms
|
|
323
|
+
let focus = null; let shown = null; // panel name with the keys / panel name that is open
|
|
324
|
+
let invTimer = null; let lastInv = 0;
|
|
325
|
+
/** A new event gets the next seq only when kind, text, level or id differ from the one held; a repeat keeps its seq until alert_ack. */
|
|
326
|
+
const raise = (ev) => {
|
|
327
|
+
const sig = [ev.kind, ev.text, ev.level, ev.id ?? ''].join('\u0000');
|
|
328
|
+
if (event && eventSig === sig) return;
|
|
329
|
+
event = { seq: ++seq, ...ev }; eventSig = sig;
|
|
330
|
+
};
|
|
331
|
+
const listArg = () => (listFile ? [listFile] : []);
|
|
191
332
|
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
333
|
+
const stale = () => lastInput?.readOkAt != null && now() - Number(lastInput.readOkAt) > STALE_MS;
|
|
334
|
+
const snap = () => buildSnapshot({ ...lastInput, event, at: isoOf(now()), stale: stale() });
|
|
335
|
+
const dedupeKey = (state) => JSON.stringify({ ...state, at: undefined });
|
|
336
|
+
const sendAll = (state, force = false) => {
|
|
337
|
+
const key = dedupeKey(state);
|
|
338
|
+
if (!force && key === lastKey) return;
|
|
195
339
|
lastKey = key; lastSent = now();
|
|
196
340
|
for (const [name, l] of loaded) { try { l.inst?.update?.(state); } catch (e) { log(`extension ${name} update failed: ${e?.message || e}`); } }
|
|
341
|
+
if (panelNames().length) handlers.changed?.(); // panels re-render on every push
|
|
197
342
|
};
|
|
198
343
|
const flush = () => { timer = null; if (pending && loaded.size) { const s = pending; pending = null; sendAll(s); } };
|
|
344
|
+
const startBeat = () => {
|
|
345
|
+
if (beat || !loaded.size) return;
|
|
346
|
+
beat = setPoll(() => { if (!loaded.size || !lastInput) return; pending = null; sendAll(snap(), true); }, HEARTBEAT_MS);
|
|
347
|
+
beat?.unref?.();
|
|
348
|
+
};
|
|
349
|
+
const stopBeat = () => { if (beat) { clearPoll(beat); beat = null; } };
|
|
350
|
+
|
|
351
|
+
// ---- close: async-tolerant, 500 ms cap, at most once per instance --------------------------------------------------
|
|
352
|
+
/** Calls close() now (synchronously), then waits up to 500 ms for a returned promise. Never throws. */
|
|
353
|
+
function closeInst(name, inst) {
|
|
354
|
+
if (!inst || closedInsts.has(inst)) return Promise.resolve();
|
|
355
|
+
closedInsts.add(inst);
|
|
356
|
+
let r;
|
|
357
|
+
try { r = inst.close?.(); } catch (e) { log(`extension ${name} close failed: ${e?.message || e}`); return Promise.resolve(); }
|
|
358
|
+
if (!r || typeof r.then !== 'function') return Promise.resolve();
|
|
359
|
+
let to;
|
|
360
|
+
const timeout = new Promise((res) => { to = setTimer(() => res('timeout'), CLOSE_TIMEOUT_MS); });
|
|
361
|
+
const p = Promise.race([r.then(() => 'done', (e) => { log(`extension ${name} close failed: ${e?.message || e}`); return 'error'; }), timeout])
|
|
362
|
+
.then((v) => { if (v === 'timeout') log(`extension ${name} close() did not finish in ${CLOSE_TIMEOUT_MS}ms; dropped`); })
|
|
363
|
+
.finally(() => { clearTimer(to); closings.delete(p); });
|
|
364
|
+
closings.add(p);
|
|
365
|
+
return p;
|
|
366
|
+
}
|
|
199
367
|
|
|
368
|
+
// ---- panels ------------------------------------------------------------------------------------------------------
|
|
369
|
+
const panelOf = (name) => { const p = loaded.get(name)?.inst?.panel; return p && typeof p.render === 'function' ? p : null; };
|
|
370
|
+
function panelNames() { return [...loaded.keys()].filter((n) => panelOf(n)); }
|
|
371
|
+
const safe = (name, what, fn) => { try { return fn(); } catch (e) { log(`extension ${name} panel ${what} failed: ${e?.message || e}`); return undefined; } };
|
|
372
|
+
const invalidateFor = (name) => () => {
|
|
373
|
+
if (!loaded.has(name) && !starting.has(name)) return;
|
|
374
|
+
const wait = INVALIDATE_MS - (now() - lastInv);
|
|
375
|
+
if (wait <= 0 && !invTimer) { lastInv = now(); handlers.changed?.(); return; }
|
|
376
|
+
if (!invTimer) invTimer = setTimer(() => { invTimer = null; lastInv = now(); handlers.changed?.(); }, Math.max(wait, 0));
|
|
377
|
+
};
|
|
378
|
+
const dropPanelState = (name) => { panelErr.delete(name); if (focus === name) focus = null; if (shown === name) shown = null; };
|
|
379
|
+
|
|
380
|
+
// ---- emit: by stable id (contract v1) ----------------------------------------------------------------------------
|
|
200
381
|
const emitFor = (name) => (msg) => {
|
|
201
382
|
if (!loaded.has(name) || !msg || typeof msg !== 'object') return false; // ignored unless this extension is loaded
|
|
383
|
+
const result = (ok, reason) => { try { loaded.get(name)?.inst?.onEmitResult?.({ ok, ...(reason ? { reason } : {}), msg }); } catch (e) { log(`extension ${name} onEmitResult failed: ${e?.message || e}`); } return ok; };
|
|
202
384
|
try {
|
|
203
|
-
if (msg.type === '
|
|
204
|
-
if (msg.type
|
|
205
|
-
|
|
385
|
+
if (msg.type === 'alert_ack') { event = null; eventSig = null; handlers.changed?.(); return result(true); }
|
|
386
|
+
if (msg.type !== 'open_approval' && msg.type !== 'pick') return false;
|
|
387
|
+
let n = null;
|
|
388
|
+
if (msg.id != null && msg.id !== '') {
|
|
389
|
+
const idx = (lastInput?.items || []).findIndex((it) => it?.id != null && String(it.id) === String(msg.id));
|
|
390
|
+
if (idx < 0) return result(false, 'unknown or already resolved id');
|
|
391
|
+
n = idx + 1;
|
|
392
|
+
if (Number.isInteger(msg.n) && msg.n !== n) log(`extension ${name}: emit n=${msg.n} disagrees with id ${msg.id} (now [${n}]); id wins`);
|
|
393
|
+
} else if (loaded.get(name).ext.contractVersion == null && Number.isInteger(msg.n) && msg.n >= 1) {
|
|
394
|
+
n = msg.n; // legacy (no contractVersion) extensions may still address by n
|
|
395
|
+
} else return result(false, 'id is required');
|
|
396
|
+
const id = msg.id != null && msg.id !== '' ? String(msg.id) : undefined;
|
|
397
|
+
const r = msg.type === 'open_approval' ? handlers.openApproval?.(n, id) : handlers.preselect?.(n, msg.option, id);
|
|
398
|
+
return r === false ? result(false, 'unknown or already resolved id') : result(true);
|
|
206
399
|
} catch (e) { log(`extension ${name} emit failed: ${e?.message || e}`); }
|
|
207
400
|
return false;
|
|
208
401
|
};
|
|
209
402
|
|
|
403
|
+
const settingsFor = (ext, name) => {
|
|
404
|
+
if (!ext.settings.length) return getSettings(); // legacy: an extension that declares none gets the window settings
|
|
405
|
+
return resolveSettings(ext.settings, readExtSettings(name, ...listArg()));
|
|
406
|
+
};
|
|
407
|
+
const makeInst = (name, ext, settings) => ext.create({ emit: emitFor(name), log, settings, invalidate: invalidateFor(name) });
|
|
408
|
+
|
|
409
|
+
// ---- hot reload: a loaded extension's package.json changes on disk -> close, re-import (cache-busted), create again ----
|
|
410
|
+
const sigOf = (pkgPath) => {
|
|
411
|
+
let version = null; let mtime = 0;
|
|
412
|
+
try { version = JSON.parse(readText(pkgPath)).version ?? null; } catch { /* unreadable mid-install: treated as no version */ }
|
|
413
|
+
try { mtime = Math.floor(statFn(pkgPath).mtimeMs); } catch { return null; } // gone mid-install: wait for the next check
|
|
414
|
+
return { version, mtime, key: `${version}|${mtime}` };
|
|
415
|
+
};
|
|
416
|
+
const stopWatch = (name) => {
|
|
417
|
+
const w = watches.get(name); if (!w) return;
|
|
418
|
+
watches.delete(name);
|
|
419
|
+
if (w.debounce) clearTimer(w.debounce);
|
|
420
|
+
if (w.poll) clearPoll(w.poll);
|
|
421
|
+
try { w.watcher?.close?.(); } catch { /* already closed */ }
|
|
422
|
+
};
|
|
423
|
+
const openWatcher = (name, w) => {
|
|
424
|
+
try {
|
|
425
|
+
w.watcher = watch(w.pkgPath, () => { if (w.debounce) clearTimer(w.debounce); w.debounce = setTimer(() => { w.debounce = null; check(name); }, RELOAD_DEBOUNCE_MS); });
|
|
426
|
+
w.watcher?.on?.('error', () => { try { w.watcher?.close?.(); } catch { /* ignore */ } w.watcher = null; }); // the poll re-arms it
|
|
427
|
+
} catch { w.watcher = null; }
|
|
428
|
+
};
|
|
429
|
+
const startWatch = (name, entry) => {
|
|
430
|
+
stopWatch(name);
|
|
431
|
+
const pkgPath = findPackageJson(entry); if (!pkgPath) return;
|
|
432
|
+
const s = sigOf(pkgPath); if (!s) return;
|
|
433
|
+
const w = { pkgPath, entry, sig: s.key, version: s.version, watcher: null, debounce: null, poll: null, busy: false };
|
|
434
|
+
watches.set(name, w);
|
|
435
|
+
openWatcher(name, w);
|
|
436
|
+
w.poll = setPoll(() => { if (!w.watcher) openWatcher(name, w); check(name); }, RELOAD_POLL_MS);
|
|
437
|
+
w.poll?.unref?.();
|
|
438
|
+
};
|
|
439
|
+
async function check(name) {
|
|
440
|
+
const w = watches.get(name); if (!w || w.busy) return;
|
|
441
|
+
const s = sigOf(w.pkgPath); if (!s || s.key === w.sig) return;
|
|
442
|
+
w.busy = true;
|
|
443
|
+
const old = w.version; const was = loaded.get(name); const order = [...loaded.keys()];
|
|
444
|
+
w.sig = s.key; w.version = s.version; // recorded first: a failed reload is reported once, not every poll
|
|
445
|
+
try {
|
|
446
|
+
if (was) { loaded.delete(name); await closeInst(name, was.inst); } // async close: awaited up to 500 ms, then dropped
|
|
447
|
+
if (!watches.has(name)) return; // unloaded while closing
|
|
448
|
+
const mod = await importer(`${pathToFileURL(w.entry).href}?v=${s.version}-${s.mtime}`);
|
|
449
|
+
if (!watches.has(name)) return; // unloaded while importing
|
|
450
|
+
const ext = normalizeExtension(mod, name);
|
|
451
|
+
if (!ext) throw new Error('the package has no create()');
|
|
452
|
+
const refusal = contractRefusal(ext);
|
|
453
|
+
if (refusal) throw new Error(refusal);
|
|
454
|
+
const settings = settingsFor(ext, name);
|
|
455
|
+
const inst = makeInst(name, ext, settings);
|
|
456
|
+
loaded.set(name, { ext, inst, where: was?.where || null, entry: w.entry, settings });
|
|
457
|
+
if (was) { const cur = new Map(loaded); loaded.clear(); for (const n of order) if (cur.has(n)) loaded.set(n, cur.get(n)); for (const [n, v] of cur) if (!loaded.has(n)) loaded.set(n, v); } // keep the tab position
|
|
458
|
+
failed.delete(name);
|
|
459
|
+
if (focus === name) { if (panelOf(name)) safe(name, 'onFocus', () => panelOf(name).onFocus?.()); else dropPanelState(name); }
|
|
460
|
+
panelErr.delete(name);
|
|
461
|
+
lastKey = null; lastSent = 0; pending = null;
|
|
462
|
+
if (lastInput) sendAll(snap());
|
|
463
|
+
handlers.note?.(old === s.version ? `↻ ${name} reloaded` : `↻ ${name} updated ${old ?? '?'} -> ${s.version ?? '?'}`, 'ok');
|
|
464
|
+
} catch (e) {
|
|
465
|
+
const msg = `${name} reload failed: ${e?.message || e}`;
|
|
466
|
+
dropPanelState(name);
|
|
467
|
+
failed.set(name, e?.message || String(e));
|
|
468
|
+
handlers.note?.(msg, 'warn');
|
|
469
|
+
} finally { w.busy = false; if (!loaded.size) stopBeat(); handlers.changed?.(); }
|
|
470
|
+
}
|
|
471
|
+
|
|
210
472
|
const host = {
|
|
473
|
+
failure: (name) => failed.get(name) || null,
|
|
474
|
+
watching: () => [...watches.keys()],
|
|
211
475
|
armed: () => loaded.size > 0,
|
|
212
476
|
isLoaded: (name) => loaded.has(name),
|
|
213
477
|
loadedNames: () => [...loaded.keys()],
|
|
@@ -224,10 +488,15 @@ export function createExtensionHost({ importer = defaultImporter, env = process.
|
|
|
224
488
|
if (!r.ok) return { ok: false, text: `${name} could not load: ${r.error}` };
|
|
225
489
|
const ext = normalizeExtension(r.mod, name);
|
|
226
490
|
if (!ext) return { ok: false, text: `${name} could not load: the package has no create().` };
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
491
|
+
const refusal = contractRefusal(ext);
|
|
492
|
+
if (refusal) { failed.set(name, refusal); handlers.changed?.(); return { ok: false, refused: true, text: `${name} ${refusal}` }; }
|
|
493
|
+
let inst; const settings = settingsFor(ext, name);
|
|
494
|
+
try { inst = makeInst(name, ext, settings); } catch (e) { return { ok: false, text: `${name} could not start: ${e?.message || e}` }; }
|
|
495
|
+
loaded.set(name, { ext, inst, where: r.where, entry: r.entry || null, settings });
|
|
496
|
+
failed.delete(name);
|
|
497
|
+
if (r.entry) startWatch(name, r.entry);
|
|
498
|
+
lastKey = null; lastSent = 0; pending = null; event = null; eventSig = null; prevLoops.clear(); prevCount = null;
|
|
499
|
+
startBeat();
|
|
231
500
|
handlers.changed?.();
|
|
232
501
|
return { ok: true, text: `${ext.name} is on (found: ${r.where}).`, ext };
|
|
233
502
|
} finally { starting.delete(name); }
|
|
@@ -235,37 +504,128 @@ export function createExtensionHost({ importer = defaultImporter, env = process.
|
|
|
235
504
|
starting.set(name, p);
|
|
236
505
|
return p;
|
|
237
506
|
},
|
|
507
|
+
/** Sync: the extension is gone from the host at once; any async close() finishes in the background (see closing). */
|
|
238
508
|
unload(name) {
|
|
239
509
|
const l = loaded.get(name);
|
|
240
|
-
if (!l) return { ok: true, text: `${name} was not loaded.` };
|
|
241
|
-
loaded.delete(name);
|
|
242
|
-
if (
|
|
243
|
-
|
|
510
|
+
if (!l) { const had = watches.has(name) || failed.has(name); stopWatch(name); failed.delete(name); return { ok: true, text: had ? `${name} is off.` : `${name} was not loaded.` }; }
|
|
511
|
+
loaded.delete(name); stopWatch(name); failed.delete(name);
|
|
512
|
+
if (focus === name) safe(name, 'onBlur', () => l.inst?.panel?.onBlur?.());
|
|
513
|
+
dropPanelState(name);
|
|
514
|
+
if (!loaded.size) { pending = null; lastKey = null; stopBeat(); if (timer) { clearTimer(timer); timer = null; } if (invTimer) { clearTimer(invTimer); invTimer = null; } }
|
|
515
|
+
const closing = closeInst(name, l.inst);
|
|
244
516
|
handlers.changed?.();
|
|
245
|
-
return { ok: true, text: `${name} is off
|
|
517
|
+
return { ok: true, text: `${name} is off.`, closing };
|
|
518
|
+
},
|
|
519
|
+
/** Unloads everything; resolves once every close() has finished or been dropped (500 ms each, in parallel). */
|
|
520
|
+
unloadAll() { for (const n of [...loaded.keys()]) host.unload(n); return Promise.all([...closings]).then(() => undefined); },
|
|
521
|
+
/** Slash commands of loaded extensions only. -> [{ name, help, ext, subcommands }] */
|
|
522
|
+
commands: () => [...loaded.values()].flatMap((l) => l.ext.commands.map((c) => ({ name: c.name, help: c.help || '', ext: l.ext.name, subcommands: c.subcommands }))),
|
|
523
|
+
/** Subcommand names for Tab completion: complete('/fleet', 'st') -> [{ name, help }]. */
|
|
524
|
+
complete(cmd, prefix = '') {
|
|
525
|
+
const c = host.commands().find((x) => x.name === cmd);
|
|
526
|
+
return (c?.subcommands || []).filter((x) => x.name.startsWith(String(prefix).toLowerCase()));
|
|
246
527
|
},
|
|
247
|
-
unloadAll() { for (const n of [...loaded.keys()]) host.unload(n); },
|
|
248
|
-
/** Slash commands of loaded extensions only. -> [{ name, help, ext }] */
|
|
249
|
-
commands: () => [...loaded.values()].flatMap((l) => l.ext.commands.map((c) => ({ name: c.name, help: c.help || '', ext: l.ext.name }))),
|
|
250
528
|
async runCommand(cmd, args, ctx = {}) {
|
|
251
529
|
for (const l of loaded.values()) {
|
|
252
530
|
const c = l.ext.commands.find((x) => x.name === cmd);
|
|
253
|
-
if (c) {
|
|
531
|
+
if (c) {
|
|
532
|
+
const raw = Array.isArray(args) ? args.join(' ') : String(args ?? '');
|
|
533
|
+
const say = ctx.say || ((t, tone) => handlers.note?.(t, tone));
|
|
534
|
+
try { return { handled: true, result: await c.run(splitArgs(args), { ...ctx, say, raw, extension: l.ext.name, loaded: host.loadedNames() }) }; } catch (e) { return { handled: true, error: e?.message || String(e) }; }
|
|
535
|
+
}
|
|
254
536
|
}
|
|
255
537
|
return { handled: false };
|
|
256
538
|
},
|
|
539
|
+
|
|
540
|
+
// ---- settings ----
|
|
541
|
+
settingsInfo(name) {
|
|
542
|
+
const l = loaded.get(name);
|
|
543
|
+
if (!l) return { ok: false, text: `${name} is not loaded. /ext on ${name}` };
|
|
544
|
+
if (!l.ext.settings.length) return { ok: false, text: `${name} declares no settings.` };
|
|
545
|
+
return { ok: true, defs: l.ext.settings, values: resolveSettings(l.ext.settings, readExtSettings(name, ...listArg())) };
|
|
546
|
+
},
|
|
547
|
+
setSetting(name, key, raw) {
|
|
548
|
+
const info = host.settingsInfo(name); if (!info.ok) return info;
|
|
549
|
+
const def = info.defs.find((d) => d.key === key);
|
|
550
|
+
if (!def) return { ok: false, text: `${name} has no setting "${key}". Keys: ${info.defs.map((d) => d.key).join(', ')}` };
|
|
551
|
+
const c = coerceSetting(def, raw);
|
|
552
|
+
if (!c.ok) return { ok: false, text: c.error };
|
|
553
|
+
const st = extStoreSetting(name, key, c.value, ...listArg());
|
|
554
|
+
if (!st.ok) return st;
|
|
555
|
+
const l = loaded.get(name);
|
|
556
|
+
const next = resolveSettings(l.ext.settings, readExtSettings(name, ...listArg()));
|
|
557
|
+
l.settings = next;
|
|
558
|
+
try { l.inst?.onSettings?.(next); } catch (e) { log(`extension ${name} onSettings failed: ${e?.message || e}`); }
|
|
559
|
+
return { ok: true, text: `${name} ${key} = ${JSON.stringify(c.value)}` };
|
|
560
|
+
},
|
|
561
|
+
|
|
562
|
+
// ---- panels and tabs ----
|
|
563
|
+
panels: () => panelNames().map((n) => ({ name: n, title: String(panelOf(n).title || n), focused: focus === n, open: shown === n })),
|
|
564
|
+
focused: () => focus,
|
|
565
|
+
shown: () => shown,
|
|
566
|
+
regionHeight: panelHeight,
|
|
567
|
+
/** The lines for the open panel (or `name`), clipped to the region. Pure for the extension; errors become 'panel error'. */
|
|
568
|
+
renderPanel(name, { width = 80, height = 12, theme = { name: 'dark', color: true, unicode: true } } = {}) {
|
|
569
|
+
const p = panelOf(name); if (!p) return [];
|
|
570
|
+
const retry = panelErr.get(name);
|
|
571
|
+
if (retry != null && now() < retry) return clipPanel(['panel error'], width, height);
|
|
572
|
+
const fail = (why) => { panelErr.set(name, now() + PANEL_RETRY_MS); log(`extension ${name} panel ${why}`); return clipPanel(['panel error'], width, height); };
|
|
573
|
+
const t0 = perf(); let out;
|
|
574
|
+
try { out = p.render({ width, height, focused: focus === name, theme }); } catch (e) { return fail(`render failed: ${e?.message || e}`); }
|
|
575
|
+
if (perf() - t0 > PANEL_SLOW_MS) return fail(`render took over ${PANEL_SLOW_MS}ms`);
|
|
576
|
+
panelErr.delete(name);
|
|
577
|
+
return clipPanel(out, width, height);
|
|
578
|
+
},
|
|
579
|
+
focusPanel(name) {
|
|
580
|
+
if (!panelOf(name)) return { ok: false, text: loaded.has(name) ? `${name} has no panel.` : `${name} is not loaded. /ext on ${name}` };
|
|
581
|
+
if (focus === name) { shown = name; return { ok: true, text: `${name} panel focused. Esc returns to the composer.` }; }
|
|
582
|
+
if (focus) safe(focus, 'onBlur', () => panelOf(focus)?.onBlur?.());
|
|
583
|
+
focus = name; shown = name;
|
|
584
|
+
safe(name, 'onFocus', () => panelOf(name).onFocus?.());
|
|
585
|
+
handlers.changed?.();
|
|
586
|
+
return { ok: true, text: `${name} panel focused. Esc returns to the composer.` };
|
|
587
|
+
},
|
|
588
|
+
/** Esc: keys go back to the composer; the panel stays open. */
|
|
589
|
+
blur() {
|
|
590
|
+
if (!focus) return false;
|
|
591
|
+
const n = focus; focus = null;
|
|
592
|
+
safe(n, 'onBlur', () => panelOf(n)?.onBlur?.());
|
|
593
|
+
handlers.changed?.();
|
|
594
|
+
return true;
|
|
595
|
+
},
|
|
596
|
+
/** /ext tab off: blur and collapse. */
|
|
597
|
+
hidePanel() { host.blur(); const was = shown; shown = null; if (was) handlers.changed?.(); return Boolean(was); },
|
|
598
|
+
/** Ctrl+]: next panel (the first when the composer has the keys). -> the name, or null with no panels. */
|
|
599
|
+
cycle() {
|
|
600
|
+
const names = panelNames(); if (!names.length) return null;
|
|
601
|
+
const next = names[(names.indexOf(focus) + 1) % names.length]; // indexOf -1 -> the first
|
|
602
|
+
host.focusPanel(next); return next;
|
|
603
|
+
},
|
|
604
|
+
/** Route one key. Returns true when the host consumed it (a panel has the keys), false when the composer keeps it. */
|
|
605
|
+
key(key) {
|
|
606
|
+
if (!focus) return false;
|
|
607
|
+
if (key === 'ctrl-]' || key === 'ctrl+]') { host.cycle(); return true; }
|
|
608
|
+
if (key === 'escape' || key === 'esc') { host.blur(); return true; }
|
|
609
|
+
const p = panelOf(focus);
|
|
610
|
+
if (!p) { focus = null; return false; }
|
|
611
|
+
try { p.onKey?.(key); } catch (e) { panelErr.set(focus, now() + PANEL_RETRY_MS); log(`extension ${focus} panel onKey failed: ${e?.message || e}`); }
|
|
612
|
+
handlers.changed?.();
|
|
613
|
+
return true; // while a panel has the keys, nothing leaks into the composer
|
|
614
|
+
},
|
|
615
|
+
|
|
257
616
|
/** Push the current snapshot inputs: derives an event from changes, throttles to one update per second. */
|
|
258
617
|
push(input) {
|
|
618
|
+
lastInput = input;
|
|
259
619
|
if (!loaded.size) return;
|
|
260
620
|
const items = input?.items || [];
|
|
261
621
|
for (const l of input?.loops || []) {
|
|
262
622
|
const was = prevLoops.get(l.id);
|
|
263
|
-
if (was && was !== l.status && (l.status === 'done' || l.status === 'failed'))
|
|
623
|
+
if (was && was !== l.status && (l.status === 'done' || l.status === 'failed')) raise({ ...(l.eventId != null ? { id: l.eventId } : {}), kind: 'loop', text: `${l.title || l.id} ${l.status}`, level: l.status === 'failed' ? 'error' : 'ok' });
|
|
264
624
|
prevLoops.set(l.id, l.status);
|
|
265
625
|
}
|
|
266
|
-
if (prevCount != null && items.length > prevCount)
|
|
626
|
+
if (prevCount != null && items.length > prevCount) raise({ ...(input?.eventId != null ? { id: input.eventId } : {}), kind: 'approval', text: `${items.length} waiting`, level: 'warn' });
|
|
267
627
|
prevCount = items.length;
|
|
268
|
-
const state =
|
|
628
|
+
const state = snap();
|
|
269
629
|
const wait = THROTTLE_MS - (now() - lastSent);
|
|
270
630
|
if (wait <= 0 && !timer) { pending = null; sendAll(state); return; }
|
|
271
631
|
pending = state;
|