quoxcli 0.3.1 → 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 (306) 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/actions.js +15 -0
  6. package/lib/api/endpoints/admin.js +27 -0
  7. package/lib/api/endpoints/agent-attach.js +7 -0
  8. package/lib/api/endpoints/blog.js +16 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/compliance.js +25 -0
  11. package/lib/api/endpoints/contactsq.js +4 -0
  12. package/lib/api/endpoints/devServerRegistry.js +25 -0
  13. package/lib/api/endpoints/engagement.js +26 -0
  14. package/lib/api/endpoints/evidence.js +20 -0
  15. package/lib/api/endpoints/hat.js +14 -0
  16. package/lib/api/endpoints/inbox.js +6 -0
  17. package/lib/api/endpoints/inboxq.js +19 -0
  18. package/lib/api/endpoints/index.js +14 -0
  19. package/lib/api/endpoints/loops.js +26 -0
  20. package/lib/api/endpoints/mon.js +295 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/quoxseo.js +6 -0
  23. package/lib/api/endpoints/server.js +21 -0
  24. package/lib/api/endpoints/setup.js +6 -0
  25. package/lib/api/endpoints/tools.js +13 -0
  26. package/lib/api/endpoints/v1.js +47 -0
  27. package/lib/commands/actions.js +81 -11
  28. package/lib/commands/admin.js +35 -0
  29. package/lib/commands/agent.js +11 -4
  30. package/lib/commands/agentAttach.js +61 -7
  31. package/lib/commands/approvals.js +198 -3
  32. package/lib/commands/ask.js +4 -2
  33. package/lib/commands/auth.js +64 -11
  34. package/lib/commands/backup.js +11 -4
  35. package/lib/commands/blog.js +110 -0
  36. package/lib/commands/capabilities.js +3 -2
  37. package/lib/commands/chat.js +142 -28
  38. package/lib/commands/chatCompanion.js +27 -0
  39. package/lib/commands/chatState.js +45 -0
  40. package/lib/commands/claude.js +7 -33
  41. package/lib/commands/compliance.js +109 -0
  42. package/lib/commands/connect.js +69 -0
  43. package/lib/commands/contacts.js +85 -4
  44. package/lib/commands/contactsSendAt.js +21 -0
  45. package/lib/commands/dev.js +6 -8
  46. package/lib/commands/doctor.js +22 -3
  47. package/lib/commands/engagement.js +90 -0
  48. package/lib/commands/evidence.js +155 -0
  49. package/lib/commands/ext.js +23 -0
  50. package/lib/commands/fleet.js +25 -0
  51. package/lib/commands/gripe.js +24 -13
  52. package/lib/commands/hat.js +76 -0
  53. package/lib/commands/image.js +68 -0
  54. package/lib/commands/inbox.js +37 -3
  55. package/lib/commands/inboxq.js +147 -0
  56. package/lib/commands/kuma.js +1123 -0
  57. package/lib/commands/kumaSla.js +174 -0
  58. package/lib/commands/licence.js +39 -1
  59. package/lib/commands/licenceLinkView.js +311 -0
  60. package/lib/commands/linkedin.js +19 -0
  61. package/lib/commands/loop.js +318 -0
  62. package/lib/commands/lucid.js +1 -0
  63. package/lib/commands/mail.js +161 -20
  64. package/lib/commands/memory.js +68 -4
  65. package/lib/commands/memorySync.js +158 -0
  66. package/lib/commands/mon.js +55 -144
  67. package/lib/commands/notes.js +29 -7
  68. package/lib/commands/notesSeal.js +259 -0
  69. package/lib/commands/objective.js +53 -3
  70. package/lib/commands/offload.js +2 -3
  71. package/lib/commands/org.js +21 -4
  72. package/lib/commands/plan.js +17 -0
  73. package/lib/commands/provider.js +87 -8
  74. package/lib/commands/qlarity.js +32 -1
  75. package/lib/commands/quoxseo.js +31 -0
  76. package/lib/commands/research.js +79 -0
  77. package/lib/commands/rows.js +9 -0
  78. package/lib/commands/run.js +20 -2
  79. package/lib/commands/scope.js +2 -2
  80. package/lib/commands/server.js +150 -0
  81. package/lib/commands/setup.js +127 -75
  82. package/lib/commands/shortcut.js +41 -0
  83. package/lib/commands/skills.js +24 -5
  84. package/lib/commands/slot.js +49 -9
  85. package/lib/commands/status.js +92 -44
  86. package/lib/commands/tool.js +30 -0
  87. package/lib/commands/vault.js +6 -3
  88. package/lib/commands/vm.js +2 -2
  89. package/lib/commands/walkthrough.js +14 -2
  90. package/lib/commands/watch.js +11 -2
  91. package/lib/commands/wazuh.js +824 -0
  92. package/lib/commands/wazuhIntake.js +317 -0
  93. package/lib/commands/wazuhReport.js +192 -0
  94. package/lib/commands/workflow.js +47 -4
  95. package/lib/commands/x.js +50 -0
  96. package/lib/commands/youtube.js +172 -0
  97. package/lib/core/actionItems.js +115 -0
  98. package/lib/core/agentAlias.js +2 -0
  99. package/lib/core/agentFamilies.js +7 -0
  100. package/lib/core/announce.js +86 -0
  101. package/lib/core/auth.js +20 -2
  102. package/lib/core/callMe.js +118 -0
  103. package/lib/core/client.js +37 -3
  104. package/lib/core/commandRegistrars.js +17 -0
  105. package/lib/core/config.js +29 -3
  106. package/lib/core/confirm.js +68 -7
  107. package/lib/core/crossOrgApprovals.js +69 -0
  108. package/lib/core/errors.js +52 -1
  109. package/lib/core/findingReconcile.js +90 -0
  110. package/lib/core/global-flags.js +1 -1
  111. package/lib/core/governedSession.js +136 -8
  112. package/lib/core/inboxResolution.js +3 -2
  113. package/lib/core/liveSessionsReport.js +1 -1
  114. package/lib/core/loginGuard.js +73 -0
  115. package/lib/core/loopGlyph.js +97 -0
  116. package/lib/core/offload.js +73 -7
  117. package/lib/core/org-override.js +5 -1
  118. package/lib/core/output.js +8 -8
  119. package/lib/core/profileTrust.js +182 -0
  120. package/lib/core/quoxglyph.js +22 -1
  121. package/lib/core/router.js +14 -0
  122. package/lib/core/sessionHeartbeat.js +12 -8
  123. package/lib/core/shortcut.js +83 -0
  124. package/lib/core/stepUpPrompt.js +99 -0
  125. package/lib/core/version.js +1 -1
  126. package/lib/core/vm.js +5 -0
  127. package/lib/core/walkthrough.js +9 -5
  128. package/lib/dragon/companionView.js +34 -0
  129. package/lib/dragon/frames.js +118 -0
  130. package/lib/dragon/jobs.js +123 -0
  131. package/lib/dragon/renderWorker.js +15 -0
  132. package/lib/dragon/select.js +72 -0
  133. package/lib/evidence/bundleExport.js +47 -0
  134. package/lib/evidence/bundleVerify.js +77 -0
  135. package/lib/quoxon/EXTENSIONS.md +146 -0
  136. package/lib/quoxon/api.js +364 -27
  137. package/lib/quoxon/app.js +1629 -183
  138. package/lib/quoxon/approvalBatch.js +117 -0
  139. package/lib/quoxon/approvalFacts.js +64 -8
  140. package/lib/quoxon/approvalSentence.js +48 -0
  141. package/lib/quoxon/approvalsView.js +124 -7
  142. package/lib/quoxon/autoOpen.js +94 -0
  143. package/lib/quoxon/backgroundWork.js +59 -0
  144. package/lib/quoxon/bang.js +1 -1
  145. package/lib/quoxon/capabilities.js +7 -2
  146. package/lib/quoxon/commandShort.js +22 -3
  147. package/lib/quoxon/commands.js +110 -15
  148. package/lib/quoxon/connect.js +5 -3
  149. package/lib/quoxon/countdownLine.js +10 -1
  150. package/lib/quoxon/deck.js +30 -0
  151. package/lib/quoxon/deckLazy.js +58 -0
  152. package/lib/quoxon/demo.autoplay.js +1 -1
  153. package/lib/quoxon/demo.js +3 -1
  154. package/lib/quoxon/destructive.js +35 -0
  155. package/lib/quoxon/dividerAocl.js +4 -3
  156. package/lib/quoxon/dividerRule.js +3 -3
  157. package/lib/quoxon/dnd.js +71 -0
  158. package/lib/quoxon/dragonCompanion.js +285 -0
  159. package/lib/quoxon/dragonModes.js +1 -0
  160. package/lib/quoxon/dragonMoments.js +160 -0
  161. package/lib/quoxon/dragonReads.js +68 -0
  162. package/lib/quoxon/dragonView.js +2 -2
  163. package/lib/quoxon/echo.js +1 -0
  164. package/lib/quoxon/events.js +67 -10
  165. package/lib/quoxon/extensions.js +636 -0
  166. package/lib/quoxon/gripeQueue.js +135 -0
  167. package/lib/quoxon/inboxView.js +182 -0
  168. package/lib/quoxon/installCli.js +79 -0
  169. package/lib/quoxon/keys.js +4 -4
  170. package/lib/quoxon/loopCard.js +460 -0
  171. package/lib/quoxon/loopCards.js +189 -0
  172. package/lib/quoxon/loopStream.js +98 -0
  173. package/lib/quoxon/mailView.js +71 -0
  174. package/lib/quoxon/main.js +29 -5
  175. package/lib/quoxon/markdown.js +1 -1
  176. package/lib/quoxon/model.js +15 -0
  177. package/lib/quoxon/mouse.js +12 -1
  178. package/lib/quoxon/neverAnswered.js +20 -0
  179. package/lib/quoxon/objectiveOffer.js +82 -0
  180. package/lib/quoxon/objects/away.js +43 -5
  181. package/lib/quoxon/objects/index.js +22 -4
  182. package/lib/quoxon/objects/model.js +33 -0
  183. package/lib/quoxon/objects/proof.js +12 -4
  184. package/lib/quoxon/onboarding/connectServer.js +162 -0
  185. package/lib/quoxon/onboarding/copy.js +6 -0
  186. package/lib/quoxon/onboarding/index.js +5 -43
  187. package/lib/quoxon/onboarding/probe.js +20 -1
  188. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  189. package/lib/quoxon/onboarding/view.js +16 -3
  190. package/lib/quoxon/orgSwitch.js +49 -1
  191. package/lib/quoxon/planSteps.js +51 -0
  192. package/lib/quoxon/print.js +8 -3
  193. package/lib/quoxon/profileSwitch.js +7 -0
  194. package/lib/quoxon/promptHistory.js +3 -1
  195. package/lib/quoxon/qdragonFly.js +7 -6
  196. package/lib/quoxon/qmotion.js +17 -0
  197. package/lib/quoxon/render.js +187 -41
  198. package/lib/quoxon/roster.js +36 -17
  199. package/lib/quoxon/rosterSessions.js +44 -0
  200. package/lib/quoxon/rows.js +244 -1
  201. package/lib/quoxon/screenContext.js +1 -1
  202. package/lib/quoxon/secretShape.js +86 -0
  203. package/lib/quoxon/serverChip.js +42 -0
  204. package/lib/quoxon/session.js +10 -0
  205. package/lib/quoxon/slash/agents.js +2 -1
  206. package/lib/quoxon/slash/argSources.js +9 -2
  207. package/lib/quoxon/slash/args.js +4 -2
  208. package/lib/quoxon/slash/broadcast.js +18 -5
  209. package/lib/quoxon/slash/broadcastRun.js +13 -2
  210. package/lib/quoxon/slash/cliCommand.js +42 -1
  211. package/lib/quoxon/slash/cliLeaves.json +139 -2
  212. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  213. package/lib/quoxon/slash/hatWalk.js +1 -1
  214. package/lib/quoxon/slash/palette.js +23 -2
  215. package/lib/quoxon/slash/paletteController.js +10 -4
  216. package/lib/quoxon/slash/qpeCmd.js +35 -0
  217. package/lib/quoxon/slash/restart.js +31 -4
  218. package/lib/quoxon/slash/resume.js +18 -1
  219. package/lib/quoxon/slash/stepUp.js +5 -5
  220. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  221. package/lib/quoxon/slash/todoCmd.js +122 -0
  222. package/lib/quoxon/slashSpinner.js +35 -0
  223. package/lib/quoxon/slotAttach.js +8 -0
  224. package/lib/quoxon/slotChat.js +17 -0
  225. package/lib/quoxon/slotFormat.js +1 -1
  226. package/lib/quoxon/slotOutbox.js +113 -0
  227. package/lib/quoxon/suggestCommand.js +34 -3
  228. package/lib/quoxon/term.js +25 -1
  229. package/lib/quoxon/toolCards.js +30 -0
  230. package/lib/quoxon/transcript.js +2 -1
  231. package/lib/quoxon/welcome.js +1 -1
  232. package/lib/quoxon/whatsnew.js +1 -1
  233. package/lib/quoxon/whatsnew.jsonl +81 -0
  234. package/lib/quoxon/windowOrg.js +11 -1
  235. package/lib/server/devServers.js +137 -0
  236. package/lib/server/installFlow.js +168 -0
  237. package/lib/server/status.js +15 -1
  238. package/lib/toolOutputShape.js +55 -0
  239. package/package.json +4 -2
  240. package/vendor/dragonlab/SOURCE +4 -0
  241. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  242. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  243. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  244. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  245. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  246. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  247. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  248. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  249. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  250. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  251. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  252. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  253. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  254. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  255. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  256. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  257. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  258. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  259. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  260. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  261. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  262. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  263. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  264. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  265. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  266. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  267. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  268. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  269. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  270. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  271. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  272. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  273. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  274. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  275. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  276. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  277. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  278. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  279. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  280. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  281. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  282. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  283. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  284. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  285. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  286. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  287. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  288. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  289. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  290. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  291. package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
  292. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  293. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  294. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  295. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  296. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  297. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  298. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  299. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  300. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  301. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
  302. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  303. package/vendor/dragonlab/lib/power-effects.js +294 -0
  304. package/vendor/dragonlab/lib/power-registry.js +74 -0
  305. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  306. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
@@ -0,0 +1,636 @@
1
+ /**
2
+ * Extension host (QUOXON, 2026-10-26; contract: EXTENSIONS.md). Optional npm packages that add surfaces to quoxon
3
+ * (a MIDI pad deck, a status bar, ...). Nothing loads unless the package is installed AND listed in
4
+ * ~/.quox/extensions.json (`quox ext add <pkg>`, `/ext list|on|off <name>`). Core knows nothing about any one extension:
5
+ * it pushes one generic snapshot (buildSnapshot) and accepts three emit actions (open_approval, pick, alert_ack).
6
+ * An extension can OPEN an approval or PRESELECT an answer; it can never resolve one.
7
+ */
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { execFileSync } from 'node:child_process';
12
+ import { pathToFileURL, fileURLToPath } from 'node:url';
13
+
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
23
+ export const notInstalled = (name) => `not installed: npm i -g ${name}`;
24
+ const HEALTH = new Set(['ok', 'warn', 'down']);
25
+ const CHAIN = new Set(['ok', 'broken', 'unknown']);
26
+ const FLEET_MAX = 16;
27
+
28
+ // ---- the listing: ~/.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
30
+ const validName = (n) => typeof n === 'string' && /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i.test(n);
31
+
32
+ export function readExtList(file = extListPath()) {
33
+ try {
34
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'));
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 } : {}) }));
36
+ } catch { return []; } // no file or unreadable: nothing is listed, so nothing loads
37
+ }
38
+ export function writeExtList(list, file = extListPath()) {
39
+ fs.mkdirSync(path.dirname(file), { recursive: true });
40
+ fs.writeFileSync(file, JSON.stringify({ extensions: list }, null, 2) + '\n');
41
+ }
42
+ export function extAdd(name, file = extListPath()) {
43
+ if (!validName(name)) return { ok: false, text: `"${name}" is not a package name.` };
44
+ const list = readExtList(file);
45
+ const had = list.find((x) => x.name === name);
46
+ if (had) had.enabled = true; else list.push({ name, enabled: true });
47
+ writeExtList(list, file);
48
+ return { ok: true, text: `${name} is listed. It loads in quoxon once installed (npm i -g ${name}).` };
49
+ }
50
+ export function extRemove(name, file = extListPath()) {
51
+ const list = readExtList(file);
52
+ if (!list.some((x) => x.name === name)) return { ok: false, text: `${name} is not listed.` };
53
+ writeExtList(list.filter((x) => x.name !== name), file);
54
+ return { ok: true, text: `${name} is no longer listed.` };
55
+ }
56
+ export function extSetEnabled(name, enabled, file = extListPath()) {
57
+ const list = readExtList(file);
58
+ const it = list.find((x) => x.name === name);
59
+ if (!it) return { ok: false, text: `${name} is not listed. quox ext add ${name}` };
60
+ it.enabled = enabled; writeExtList(list, file);
61
+ return { ok: true, text: `${name} ${enabled ? 'enabled' : 'disabled'}.` };
62
+ }
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
+
111
+ // ---- resolution: env override, local, global ------------------------------------------------------------------------
112
+ export const envKey = (name) => `QUOX_EXT_${String(name).replace(/^@/, '').replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}_MODULE`;
113
+ const defaultImporter = (spec) => import(spec);
114
+ const NOT_FOUND = (e) => e?.code === 'ERR_MODULE_NOT_FOUND' || e?.code === 'MODULE_NOT_FOUND';
115
+
116
+ let npmRootCache; // `npm root -g` runs at most once per process
117
+ export function globalNpmRoot(run = execFileSync) {
118
+ if (npmRootCache !== undefined) return npmRootCache;
119
+ try { npmRootCache = String(run('npm', ['root', '-g'], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] })).trim() || null; } catch { npmRootCache = null; }
120
+ return npmRootCache;
121
+ }
122
+ export const _resetNpmRootCache = () => { npmRootCache = undefined; };
123
+
124
+ /** The file a package directory imports: exports["."] (import/default), module, main, index.js. */
125
+ export function packageEntry(dir) {
126
+ try {
127
+ const pj = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
128
+ const ex = pj.exports;
129
+ const root = typeof ex === 'string' ? ex : (ex?.['.'] ?? ex);
130
+ const pick = typeof root === 'string' ? root : (root?.import || root?.default || (typeof root?.import === 'object' ? root.import.default : null));
131
+ return path.join(dir, pick || pj.module || pj.main || 'index.js');
132
+ } catch { return path.join(dir, 'index.js'); }
133
+ }
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
+
153
+ /**
154
+ * Find and import one extension. Order: QUOX_EXT_<NAME>_MODULE, local import(name), then the global npm root
155
+ * (dirname(execPath)/../lib/node_modules first, which costs no spawn; `npm root -g` once, cached, only if that misses).
156
+ * -> { ok:true, mod, where } | { ok:false, notFound:true, tried:[...] } | { ok:false, error }
157
+ */
158
+ export async function resolveExtension(name, { env = process.env, importer = defaultImporter, execPath = process.execPath, npmRoot = globalNpmRoot, exists = fs.existsSync } = {}) {
159
+ const tried = [];
160
+ const ov = env?.[envKey(name)];
161
+ if (ov) {
162
+ tried.push(`${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 }; }
164
+ catch (e) { return { ok: false, error: e?.message || String(e), tried }; } // an override that fails is an error, never a silent fall-through
165
+ }
166
+ tried.push(`local import('${name}')`);
167
+ try { return { ok: true, mod: await importer(name), where: `local (${name})`, entry: localEntry(name) }; }
168
+ catch (e) { if (!NOT_FOUND(e)) return { ok: false, error: e?.message || String(e), tried }; }
169
+ const roots = [];
170
+ const guess = path.join(path.dirname(execPath || ''), '..', 'lib', 'node_modules');
171
+ roots.push(guess);
172
+ let spawned = false;
173
+ for (let i = 0; i < 2; i++) {
174
+ for (const root of roots.splice(0)) {
175
+ const dir = path.join(root, name);
176
+ tried.push(`global ${dir}`);
177
+ if (!exists(dir)) continue;
178
+ try { return { ok: true, mod: await importer(pathToFileURL(packageEntry(dir)).href), where: `global (${dir})`, entry: packageEntry(dir) }; }
179
+ catch (e) { return { ok: false, error: e?.message || String(e), tried }; }
180
+ }
181
+ if (spawned) break;
182
+ spawned = true;
183
+ const g = npmRoot();
184
+ if (g && path.resolve(g) !== path.resolve(guess)) roots.push(g);
185
+ }
186
+ return { ok: false, notFound: true, tried };
187
+ }
188
+
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. */
194
+ export function normalizeExtension(mod, fallbackName) {
195
+ const ext = mod?.default && typeof mod.default === 'object' ? mod.default : mod;
196
+ const create = typeof ext?.create === 'function' ? ext.create : (typeof mod?.createDeck === 'function' ? mod.createDeck : null); // createDeck: quoxdeck v1
197
+ if (!create) return null;
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;
209
+ }
210
+
211
+ // ---- the snapshot every extension gets ------------------------------------------------------------------------------
212
+ function itemRisk(it) {
213
+ return it?.privileged === true || it?.payload?.privileged === true || it?.risk === 'high' || it?.payload?.risk === 'high' ? 'high' : 'low';
214
+ }
215
+ const hostState = (s) => (s === 'ok' || s === 'warn' || s === 'down' ? s : (/^(down|offline|failed|unreachable)$/i.test(s || '') ? 'down' : (/^(warn|warning|degraded)$/i.test(s || '') ? 'warn' : 'ok')));
216
+
217
+ /** Pure. fleet input: [{id|name, health|state}] or null. */
218
+ export function buildFleet(hosts) {
219
+ if (!Array.isArray(hosts)) return { total: 0, online: 0, offline: 0, warn: 0, hosts: [] };
220
+ const list = hosts.map((h) => ({ name: String(h.name ?? h.id ?? '').slice(0, 60), state: hostState(h.state ?? h.health) }));
221
+ return {
222
+ total: list.length, online: list.filter((h) => h.state !== 'down').length,
223
+ offline: list.filter((h) => h.state === 'down').length, warn: list.filter((h) => h.state === 'warn').length,
224
+ hosts: list.slice(0, FLEET_MAX)
225
+ };
226
+ }
227
+
228
+ export function buildSnapshot({ org = null, items = [], health = {}, loops = [], event = null, fleet = null, evidence = null, sessions = null, at = null, stale = false } = {}) {
229
+ const list = (items || []).map((it, i) => {
230
+ const row = {
231
+ n: i + 1, id: it.id ?? null, risk: itemRisk(it),
232
+ title: String(it.title || it.summary || it.payload?.title || it.payload?.question || it.payload?.summary || '').slice(0, 120),
233
+ agent: it.agent || it.requester || it.payload?.agent || null
234
+ };
235
+ const opts = Array.isArray(it.payload?.options) ? it.payload.options.filter((o) => o?.key != null).map((o) => ({ key: String(o.key), label: String(o.label ?? o.key) })) : [];
236
+ if (opts.length) row.options = opts;
237
+ return row;
238
+ });
239
+ const h = {};
240
+ for (const [k, v] of Object.entries(health || {})) if (HEALTH.has(v)) h[k] = v;
241
+ const state = {
242
+ ...(at ? { at } : {}),
243
+ ...(stale ? { stale: true } : {}),
244
+ org: { id: org?.id ?? null, name: org?.name ?? null },
245
+ approvals: { count: list.length, items: list },
246
+ health: h,
247
+ loops: (loops || []).map((l) => ({ id: l.id, title: String(l.title || ''), turn: Number(l.turn) || 0, max: Number(l.max) || 0, status: l.status || 'running' })),
248
+ fleet: Array.isArray(fleet) ? buildFleet(fleet) : (fleet && typeof fleet === 'object' ? { total: fleet.total | 0, online: fleet.online | 0, offline: fleet.offline | 0, warn: fleet.warn | 0, hosts: (fleet.hosts || []).slice(0, FLEET_MAX) } : buildFleet(null)),
249
+ evidence: { chain: CHAIN.has(evidence?.chain) ? evidence.chain : 'unknown', checked_at: evidence?.checked_at ?? null },
250
+ sessions: { active: Math.max(0, Number(sessions?.active) || 0) }
251
+ };
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' };
253
+ return state;
254
+ }
255
+
256
+ /** What the composer is filled with when an extension picks an option; the user still presses Enter. null = nothing to preselect. */
257
+ export function pickDraft(item, n, option) {
258
+ if (!item) return null;
259
+ const opt = String(option ?? '').trim();
260
+ if (!opt) return null;
261
+ const keys = (item.payload?.options || []).map((o) => String(o.key));
262
+ if (keys.length) return keys.includes(opt) ? `/answer ${n} ${opt}` : null;
263
+ if (/^(y|yes|approve)$/i.test(opt)) return `/approve ${n}`;
264
+ if (/^(n|no|deny)$/i.test(opt)) return `/deny ${n} `;
265
+ return null;
266
+ }
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
+
302
+ // ---- the host -------------------------------------------------------------------------------------------------------
303
+ /**
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.
308
+ */
309
+ export function createExtensionHost({ importer = defaultImporter, env = process.env, execPath, npmRoot, exists, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout,
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;
317
+ const starting = new Map();
318
+ let lastSent = 0; let lastKey = null; let pending = null; let timer = 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] : []);
332
+
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;
339
+ lastKey = key; lastSent = now();
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
342
+ };
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
+ }
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) ----------------------------------------------------------------------------
381
+ const emitFor = (name) => (msg) => {
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; };
384
+ try {
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);
399
+ } catch (e) { log(`extension ${name} emit failed: ${e?.message || e}`); }
400
+ return false;
401
+ };
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
+
472
+ const host = {
473
+ failure: (name) => failed.get(name) || null,
474
+ watching: () => [...watches.keys()],
475
+ armed: () => loaded.size > 0,
476
+ isLoaded: (name) => loaded.has(name),
477
+ loadedNames: () => [...loaded.keys()],
478
+ where: (name) => loaded.get(name)?.where || null,
479
+ /** emit as one extension (tests and the shim); extensions get their own bound emit in create(). */
480
+ emit: (msg, name) => (name ? emitFor(name)(msg) : [...loaded.keys()].some((n) => emitFor(n)(msg))),
481
+ async load(name) {
482
+ if (loaded.has(name)) return { ok: true, text: `${name} is already loaded.` };
483
+ if (starting.has(name)) return starting.get(name);
484
+ const p = (async () => {
485
+ try {
486
+ const r = await resolveExtension(name, { env, importer, execPath, npmRoot, exists });
487
+ if (!r.ok && r.notFound) return { ok: false, notFound: true, text: `${name} ${notInstalled(name)} (looked in: ${r.tried.join('; ')})`, tried: r.tried };
488
+ if (!r.ok) return { ok: false, text: `${name} could not load: ${r.error}` };
489
+ const ext = normalizeExtension(r.mod, name);
490
+ if (!ext) return { ok: false, text: `${name} could not load: the package has no create().` };
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();
500
+ handlers.changed?.();
501
+ return { ok: true, text: `${ext.name} is on (found: ${r.where}).`, ext };
502
+ } finally { starting.delete(name); }
503
+ })();
504
+ starting.set(name, p);
505
+ return p;
506
+ },
507
+ /** Sync: the extension is gone from the host at once; any async close() finishes in the background (see closing). */
508
+ unload(name) {
509
+ const l = loaded.get(name);
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);
516
+ handlers.changed?.();
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()));
527
+ },
528
+ async runCommand(cmd, args, ctx = {}) {
529
+ for (const l of loaded.values()) {
530
+ const c = l.ext.commands.find((x) => x.name === cmd);
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
+ }
536
+ }
537
+ return { handled: false };
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
+
616
+ /** Push the current snapshot inputs: derives an event from changes, throttles to one update per second. */
617
+ push(input) {
618
+ lastInput = input;
619
+ if (!loaded.size) return;
620
+ const items = input?.items || [];
621
+ for (const l of input?.loops || []) {
622
+ const was = prevLoops.get(l.id);
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' });
624
+ prevLoops.set(l.id, l.status);
625
+ }
626
+ if (prevCount != null && items.length > prevCount) raise({ ...(input?.eventId != null ? { id: input.eventId } : {}), kind: 'approval', text: `${items.length} waiting`, level: 'warn' });
627
+ prevCount = items.length;
628
+ const state = snap();
629
+ const wait = THROTTLE_MS - (now() - lastSent);
630
+ if (wait <= 0 && !timer) { pending = null; sendAll(state); return; }
631
+ pending = state;
632
+ if (!timer) timer = setTimer(flush, Math.max(wait, 0));
633
+ }
634
+ };
635
+ return host;
636
+ }