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.
Files changed (175) hide show
  1. package/CHANGELOG.md +61 -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/inbox.js +6 -0
  9. package/lib/api/endpoints/inboxq.js +19 -0
  10. package/lib/api/endpoints/index.js +2 -0
  11. package/lib/api/endpoints/mon.js +122 -0
  12. package/lib/api/endpoints/tools.js +13 -0
  13. package/lib/api/endpoints/v1.js +13 -0
  14. package/lib/commands/admin.js +35 -0
  15. package/lib/commands/agentAttach.js +15 -0
  16. package/lib/commands/approvals.js +193 -3
  17. package/lib/commands/ask.js +1 -1
  18. package/lib/commands/auth.js +64 -11
  19. package/lib/commands/backup.js +11 -4
  20. package/lib/commands/capabilities.js +3 -2
  21. package/lib/commands/chat.js +15 -0
  22. package/lib/commands/claude.js +4 -1
  23. package/lib/commands/compliance.js +109 -0
  24. package/lib/commands/connect.js +69 -0
  25. package/lib/commands/dev.js +6 -8
  26. package/lib/commands/doctor.js +22 -3
  27. package/lib/commands/evidence.js +2 -1
  28. package/lib/commands/gripe.js +24 -13
  29. package/lib/commands/inbox.js +37 -3
  30. package/lib/commands/inboxq.js +147 -0
  31. package/lib/commands/kuma.js +78 -5
  32. package/lib/commands/licenceLinkView.js +311 -0
  33. package/lib/commands/linkedin.js +19 -0
  34. package/lib/commands/loop.js +30 -3
  35. package/lib/commands/lucid.js +1 -0
  36. package/lib/commands/mail.js +161 -20
  37. package/lib/commands/memory.js +68 -4
  38. package/lib/commands/memorySync.js +158 -0
  39. package/lib/commands/mon.js +55 -144
  40. package/lib/commands/notes.js +29 -7
  41. package/lib/commands/notesSeal.js +259 -0
  42. package/lib/commands/objective.js +5 -2
  43. package/lib/commands/offload.js +2 -3
  44. package/lib/commands/plan.js +17 -0
  45. package/lib/commands/provider.js +29 -8
  46. package/lib/commands/qlarity.js +32 -1
  47. package/lib/commands/run.js +2 -1
  48. package/lib/commands/server.js +37 -0
  49. package/lib/commands/setup.js +127 -75
  50. package/lib/commands/skills.js +4 -1
  51. package/lib/commands/slot.js +49 -9
  52. package/lib/commands/status.js +92 -44
  53. package/lib/commands/tool.js +30 -0
  54. package/lib/commands/vault.js +6 -3
  55. package/lib/commands/walkthrough.js +14 -2
  56. package/lib/commands/watch.js +11 -2
  57. package/lib/commands/wazuh.js +824 -0
  58. package/lib/commands/wazuhIntake.js +317 -0
  59. package/lib/commands/wazuhReport.js +192 -0
  60. package/lib/commands/workflow.js +47 -4
  61. package/lib/commands/x.js +50 -0
  62. package/lib/core/announce.js +86 -0
  63. package/lib/core/auth.js +20 -2
  64. package/lib/core/callMe.js +118 -0
  65. package/lib/core/client.js +27 -1
  66. package/lib/core/commandRegistrars.js +8 -0
  67. package/lib/core/config.js +28 -2
  68. package/lib/core/confirm.js +68 -7
  69. package/lib/core/crossOrgApprovals.js +69 -0
  70. package/lib/core/errors.js +52 -1
  71. package/lib/core/governedSession.js +135 -5
  72. package/lib/core/inboxResolution.js +3 -2
  73. package/lib/core/loginGuard.js +73 -0
  74. package/lib/core/offload.js +37 -0
  75. package/lib/core/org-override.js +5 -1
  76. package/lib/core/output.js +1 -1
  77. package/lib/core/profileTrust.js +182 -0
  78. package/lib/core/quoxglyph.js +22 -1
  79. package/lib/core/router.js +14 -0
  80. package/lib/core/stepUpPrompt.js +99 -0
  81. package/lib/core/version.js +1 -1
  82. package/lib/core/walkthrough.js +9 -5
  83. package/lib/dragon/select.js +2 -2
  84. package/lib/quoxon/EXTENSIONS.md +142 -19
  85. package/lib/quoxon/api.js +185 -34
  86. package/lib/quoxon/app.js +1032 -152
  87. package/lib/quoxon/approvalBatch.js +117 -0
  88. package/lib/quoxon/approvalFacts.js +56 -2
  89. package/lib/quoxon/approvalSentence.js +8 -4
  90. package/lib/quoxon/approvalsView.js +69 -11
  91. package/lib/quoxon/autoOpen.js +94 -0
  92. package/lib/quoxon/backgroundWork.js +59 -0
  93. package/lib/quoxon/bang.js +1 -1
  94. package/lib/quoxon/capabilities.js +7 -2
  95. package/lib/quoxon/commandShort.js +7 -0
  96. package/lib/quoxon/commands.js +40 -10
  97. package/lib/quoxon/connect.js +5 -3
  98. package/lib/quoxon/deckLazy.js +58 -0
  99. package/lib/quoxon/destructive.js +35 -0
  100. package/lib/quoxon/dnd.js +71 -0
  101. package/lib/quoxon/dragonCompanion.js +37 -7
  102. package/lib/quoxon/dragonMoments.js +78 -0
  103. package/lib/quoxon/echo.js +1 -0
  104. package/lib/quoxon/events.js +43 -7
  105. package/lib/quoxon/extensions.js +398 -38
  106. package/lib/quoxon/gripeQueue.js +135 -0
  107. package/lib/quoxon/inboxView.js +125 -22
  108. package/lib/quoxon/keys.js +2 -2
  109. package/lib/quoxon/loopCard.js +5 -1
  110. package/lib/quoxon/loopCards.js +17 -8
  111. package/lib/quoxon/loopStream.js +20 -9
  112. package/lib/quoxon/mailView.js +71 -0
  113. package/lib/quoxon/main.js +20 -5
  114. package/lib/quoxon/markdown.js +1 -1
  115. package/lib/quoxon/model.js +15 -0
  116. package/lib/quoxon/neverAnswered.js +20 -0
  117. package/lib/quoxon/objects/away.js +43 -5
  118. package/lib/quoxon/onboarding/connectServer.js +162 -0
  119. package/lib/quoxon/onboarding/copy.js +6 -0
  120. package/lib/quoxon/onboarding/index.js +5 -43
  121. package/lib/quoxon/onboarding/probe.js +20 -1
  122. package/lib/quoxon/onboarding/view.js +16 -3
  123. package/lib/quoxon/orgSwitch.js +22 -0
  124. package/lib/quoxon/print.js +2 -0
  125. package/lib/quoxon/profileSwitch.js +7 -0
  126. package/lib/quoxon/promptHistory.js +3 -1
  127. package/lib/quoxon/render.js +63 -14
  128. package/lib/quoxon/roster.js +10 -5
  129. package/lib/quoxon/rosterSessions.js +44 -0
  130. package/lib/quoxon/screenContext.js +1 -1
  131. package/lib/quoxon/secretShape.js +86 -0
  132. package/lib/quoxon/slash/agents.js +2 -1
  133. package/lib/quoxon/slash/argSources.js +9 -2
  134. package/lib/quoxon/slash/args.js +4 -2
  135. package/lib/quoxon/slash/broadcast.js +18 -5
  136. package/lib/quoxon/slash/broadcastRun.js +13 -2
  137. package/lib/quoxon/slash/cliLeaves.json +63 -1
  138. package/lib/quoxon/slash/hatWalk.js +1 -1
  139. package/lib/quoxon/slash/palette.js +22 -1
  140. package/lib/quoxon/slash/paletteController.js +10 -4
  141. package/lib/quoxon/slash/restart.js +7 -4
  142. package/lib/quoxon/slash/stepUp.js +5 -5
  143. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  144. package/lib/quoxon/slashSpinner.js +35 -0
  145. package/lib/quoxon/slotAttach.js +8 -0
  146. package/lib/quoxon/slotChat.js +17 -0
  147. package/lib/quoxon/slotFormat.js +1 -1
  148. package/lib/quoxon/slotOutbox.js +113 -0
  149. package/lib/quoxon/suggestCommand.js +1 -1
  150. package/lib/quoxon/term.js +25 -1
  151. package/lib/quoxon/transcript.js +2 -1
  152. package/lib/quoxon/welcome.js +1 -1
  153. package/lib/quoxon/whatsnew.js +1 -1
  154. package/lib/quoxon/whatsnew.jsonl +36 -0
  155. package/lib/server/installFlow.js +168 -0
  156. package/lib/server/status.js +15 -1
  157. package/lib/toolOutputShape.js +55 -0
  158. package/package.json +3 -2
  159. package/vendor/dragonlab/SOURCE +3 -3
  160. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  161. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  162. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  163. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  164. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  165. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  166. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  167. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  168. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  169. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  170. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  171. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  172. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  173. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  174. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  175. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -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 = os.homedir()) => path.join(home, '.quox', 'extensions.json');
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
- /** An extension module -> { name, version, commands, create } or null. Accepts default export or the module itself. */
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
- return { name: ext.name || fallbackName, version: ext.version || null, commands, create };
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. getSettings() feeds create().
183
- * Loaded extensions all receive the same deduped, 1/s-throttled snapshot.
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
- const loaded = new Map(); // name -> { ext, inst, where }
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 sendAll = (state) => {
193
- const key = JSON.stringify(state);
194
- if (key === lastKey) return;
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 === '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; }
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
- 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;
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 (!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}`); }
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) { 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) }; } }
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')) event = { kind: 'loop', text: `${l.title || l.id} ${l.status}`, level: l.status === 'failed' ? 'error' : 'ok' };
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) event = { kind: 'approval', text: `${items.length} waiting`, level: 'warn' };
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 = buildSnapshot({ ...input, event });
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;