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