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,118 @@
1
+ /**
2
+ * Smooth companion playback. Rendering a dragonlab frame costs 100 ms to over a second (Hyperion), and the old path did it
3
+ * synchronously inside every Ink render, so the whole TUI hitched and the dragon juddered. The classic Q dragon is smooth
4
+ * because its frames are cheap and the shared clock paces them. This does the same for companions: time is quantised to
5
+ * the clock's frame step, frames are rendered OFF the main thread (a worker, see renderWorker.js) a few steps ahead of the
6
+ * playhead into a cache, and the renderer only ever READS the cache. A frame that is not ready yet is held (the last one
7
+ * shown stays), never rendered inline. The same quantised time returns the same frame object, so an unchanged frame costs
8
+ * Ink no output at all.
9
+ */
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+
12
+ /** One animation step, the shared clock's FRAME_MS (about 12 fps). */
13
+ export const STEP_MS = 83;
14
+ /** How many steps ahead of the playhead the worker is asked to fill. */
15
+ export const LOOKAHEAD = 3;
16
+ const CACHE_MAX = 240;
17
+
18
+ export const quantise = (ms) => Math.max(0, Math.round((Number(ms) || 0) / STEP_MS) * STEP_MS);
19
+
20
+ /**
21
+ * Where the art goes. Sized to the classic dragon's footprint (15 rows when the terminal is short, 22 when it is tall
22
+ * enough that the live region still fits), not the old 39x11 cap. Below ART_MIN a one-line label is used instead.
23
+ */
24
+ export const ART_MIN_COLS = 64, ART_MIN_ROWS = 30;
25
+ export function companionSlot(cols, rows) {
26
+ const big = cols >= ART_MIN_COLS && rows >= ART_MIN_ROWS;
27
+ if (!big) return { showArt: false, cols: 0, rows: 0 };
28
+ const tall = rows >= 40;
29
+ return { showArt: true, cols: Math.max(28, Math.min(tall ? 66 : 52, cols - 26)), rows: tall ? 22 : 15 };
30
+ }
31
+
32
+ /**
33
+ * Cache + playhead. `backend.render(req) -> Promise<frame>` does the real work somewhere else; at most one request is in
34
+ * flight, and the next one asked for is always the soonest frame not cached yet (current step first, then lookahead).
35
+ */
36
+ export function createPlayhead(backend, { onFirst = () => {}, lookahead = LOOKAHEAD, clock = () => Date.now() } = {}) {
37
+ const cache = new Map(), pending = new Set();
38
+ const conc = Math.max(1, backend.size || 1); // the backend may be a pool: keep every worker busy
39
+ let last = null, disposed = false, failed = null, renders = 0, ema = 100;
40
+ const keyOf = (r) => `${r.scene}|${r.time}|${r.motionTime}|${r.cols}x${r.rows}`;
41
+ const put = (k, f) => { cache.set(k, f); if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value); };
42
+ function pump(wants) {
43
+ for (const next of wants) {
44
+ if (pending.size >= conc || disposed || failed) return;
45
+ const k = keyOf(next);
46
+ if (cache.has(k) || pending.has(k)) continue;
47
+ pending.add(k); renders++;
48
+ const began = clock();
49
+ Promise.resolve().then(() => backend.render(next)).then((f) => {
50
+ ema = ema * 0.7 + (clock() - began) * 0.3;
51
+ if (f) { put(k, f); const first = !last; last = f; if (first) onFirst(); }
52
+ }, (e) => { failed = e; }).finally(() => { pending.delete(k); });
53
+ }
54
+ }
55
+ return {
56
+ /**
57
+ * The frame to draw for this scene/time: the cached one for this step, else the newest earlier one (held), else the last
58
+ * one shown. null before the first ever arrives. The worker is asked for the step the playhead will be at when the
59
+ * render lands (now + the measured render time), so a slow character still plays at the rate it can render, never stalls the TUI.
60
+ */
61
+ frame(req, { still = false } = {}) {
62
+ const base = (i) => ({ ...req, time: quantise(req.time) + i * STEP_MS, motionTime: quantise(req.motionTime) + i * STEP_MS });
63
+ const lead = still ? 0 : Math.min(6, Math.ceil(ema / STEP_MS));
64
+ pump(still ? [base(0)] : Array.from({ length: Math.max(lookahead, conc * 2) + 1 }, (_, i) => base(lead + i)));
65
+ for (let back = 0; back <= 4; back++) {
66
+ const hit = cache.get(keyOf(base(-back)));
67
+ if (hit) { last = hit; return hit; }
68
+ }
69
+ return last;
70
+ },
71
+ get error() { return failed; },
72
+ get renders() { return renders; },
73
+ get size() { return cache.size; },
74
+ get busy() { return pending.size; },
75
+ get lead() { return Math.min(6, Math.ceil(ema / STEP_MS)); },
76
+ dispose() { disposed = true; cache.clear(); backend.dispose?.(); },
77
+ };
78
+ }
79
+
80
+ /**
81
+ * The real backend: worker threads that load the character once and render on request (a small pool, because the heavier
82
+ * characters take several frame steps to render and the pool renders upcoming steps in parallel). Rejects if no worker can start.
83
+ */
84
+ export async function createWorkerBackend({ root, id, still = false, mono = false, size = null }, { Worker, cpus = null } = {}) {
85
+ const W = Worker || (await import('node:worker_threads')).Worker;
86
+ const avail = cpus ?? (await import('node:os')).availableParallelism?.() ?? 2;
87
+ const n = Math.max(1, Math.min(size ?? 3, avail - 1));
88
+ const file = fileURLToPath(new URL('./renderWorker.js', import.meta.url));
89
+ const waiting = new Map();
90
+ let seq = 0, dead = null;
91
+ const fail = (e) => { dead = e; for (const w of waiting.values()) w.reject(e); waiting.clear(); };
92
+ const spawn = () => new Promise((resolve, reject) => {
93
+ const worker = new W(file, { workerData: { root, id } });
94
+ worker.unref?.();
95
+ worker.once('error', (e) => { fail(e); reject(e); });
96
+ worker.on('message', (m) => {
97
+ if (m.type === 'ready') return resolve({ worker, load: 0 });
98
+ const w = waiting.get(m.seq); if (!w) return;
99
+ waiting.delete(m.seq); w.slot.load--;
100
+ if (m.type === 'frame') w.resolve(m.frame); else w.reject(new Error(m.code || 'render failed'));
101
+ });
102
+ worker.once('exit', (c) => { fail(new Error(`render worker exited (${c})`)); reject(dead); });
103
+ });
104
+ const slots = await Promise.all(Array.from({ length: n }, spawn));
105
+ return {
106
+ size: n,
107
+ render: (req) => new Promise((resolve, reject) => {
108
+ if (dead) return reject(dead);
109
+ const slot = slots.reduce((a, b) => (b.load < a.load ? b : a));
110
+ const s = ++seq; slot.load++; waiting.set(s, { resolve, reject, slot });
111
+ slot.worker.postMessage({ seq: s, req: { ...req, still, mono } });
112
+ }),
113
+ dispose() { fail(new Error('disposed')); for (const s of slots) s.worker.terminate(); },
114
+ };
115
+ }
116
+
117
+ /** Test/fallback helper: path-to-URL for a dragonlab module. */
118
+ export const labModule = (root, ...p) => pathToFileURL([root, 'integration', 'companions-v1', ...p].join('/')).href;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Dragon jobs: each companion dragon has ONE truthful job, and its caption comes from a real read.
3
+ * sentinel WARD state of the last answer's envelope (POST /ward/verify-receipt, the route /proof uses)
4
+ * cipher route health: kuma monitors up/down (the `quox kuma` read)
5
+ * seer the plan: QuoxPlan focus stream and x of y phases
6
+ * hyperion fleet: hosts up of total (the Bastion host read)
7
+ * drago open security findings (the engagement store)
8
+ * lucid recovery: the last failure this session saw, and whether a later turn completed (no network read)
9
+ * Rules: every read is bounded (timeoutMs); a failed or silent read says "Couldn't check <thing> (<why>)" and never a number;
10
+ * results (failures too) are cached for JOB_TTL_MS so a dragon that appears often does not hammer the server; nothing here
11
+ * polls, run() is only called when the dragon is about to be shown; read-only. Pure apart from the injected reads.
12
+ * A result is { caption, mood } where mood is 'ok' | 'concerned' | null (null = unknown or neutral). The host may pick a
13
+ * concerned scene for 'concerned'; it never celebrates on null.
14
+ */
15
+ export const JOB_FOR = Object.freeze({ sentinel: 'ward', cipher: 'routes', seer: 'plan', hyperion: 'fleet', drago: 'findings', lucid: 'recovery' });
16
+ export const JOB_TTL_MS = 30000;
17
+ export const JOB_TIMEOUT_MS = 6000;
18
+ const LABEL = { ward: 'WARD', routes: 'routes', plan: 'plan', fleet: 'fleet', findings: 'findings' };
19
+ const READ_FOR = { ward: 'verifyReceipt', routes: 'routes', plan: 'plan', fleet: 'fleet', findings: 'findings' };
20
+
21
+ const why = (e) => (e?.status ? `HTTP ${e.status}` : /timed out/i.test(e?.message || '') ? 'timed out' : String(e?.message || 'failed').slice(0, 60));
22
+ const ago = (ms) => { const m = Math.floor(ms / 60000); return m < 1 ? 'under a minute ago' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.floor(m / 60)} h ago` : `${Math.floor(m / 1440)} d ago`; };
23
+ const failed = (job, reason) => ({ caption: `Couldn't check ${LABEL[job]} (${reason}).`, mood: null });
24
+
25
+ /** Pure: a read's data -> { caption, mood }. */
26
+ export function captionFor(job, d) {
27
+ if (job === 'ward') {
28
+ const r = d?.result || {}, seq = r.entry?.seq;
29
+ if (r.status === 'WITNESSED') return { caption: `Last answer witnessed by WARD (seq ${seq}, signed tip).`, mood: 'ok' };
30
+ if (r.status === 'UNTIPPED') return { caption: `Last answer recorded (seq ${seq}) but no signed tip yet.`, mood: null };
31
+ if (r.status === 'NOT_WITNESSED') return { caption: 'Last answer not witnessed yet.', mood: null };
32
+ if (r.status) return { caption: `WARD check failed: ${r.status}${r.reason ? ` ${r.reason}` : ''}.`, mood: 'concerned' };
33
+ return failed('ward', 'no status in the answer');
34
+ }
35
+ if (job === 'routes') {
36
+ if (!d.total) return { caption: 'No monitors to read.', mood: null };
37
+ const stale = d.ageSeconds > 600 ? ` (snapshot ${Math.round(d.ageSeconds / 60)} min old)` : '';
38
+ if (d.down > 0) return { caption: `Routes: ${d.up} of ${d.total} up. Down: ${d.downNames.slice(0, 3).join(', ')}${d.downNames.length > 3 ? ` and ${d.downNames.length - 3} more` : ''}.`, mood: 'concerned' };
39
+ if (d.up < d.total) return { caption: `Routes: ${d.up} of ${d.total} up${stale}.`, mood: null }; // pending, maintenance, unknown: not counted up
40
+ const ping = Number.isFinite(d.avgPing) && !stale ? `, average ${Math.round(d.avgPing)} ms` : '';
41
+ return { caption: `Routes: ${d.up} of ${d.total} up${stale || ping}.`, mood: stale ? null : 'ok' };
42
+ }
43
+ if (job === 'plan') {
44
+ if (!d) return { caption: 'Nothing in focus on the plan.', mood: null };
45
+ if (d.total === 0) return { caption: `Plan: ${d.id} (no phases listed).`, mood: null };
46
+ return { caption: Number.isFinite(d.total) ? `Plan: ${d.id}, ${d.done} of ${d.total} phases.` : `Plan: ${d.id} (phases not read).`, mood: null };
47
+ }
48
+ if (job === 'fleet') {
49
+ if (!d.total) return { caption: 'No hosts to read.', mood: null };
50
+ const bits = ['degraded', 'unhealthy', 'offline', 'unknown'].filter(k => d[k]).map(k => `${d[k]} ${k}`);
51
+ return { caption: `Fleet: ${d.healthy} of ${d.total} hosts up${bits.length ? ` (${bits.join(', ')})` : ''}.`, mood: d.healthy === d.total ? 'ok' : 'concerned' };
52
+ }
53
+ if (job === 'findings') {
54
+ if (!d.open) return { caption: 'No open security findings recorded.', mood: 'ok' };
55
+ return { caption: `Open findings: ${d.open}${d.severe ? ` (${d.severe} critical or high)` : ''}.`, mood: d.severe ? 'concerned' : null };
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * reads: { verifyReceipt(envelopeId), routes(), plan(), fleet(), findings() }, each resolving to data or throwing (verifyReceipt may
62
+ * resolve { ok:false, error }). context(): { envelopeId } of the last answer. observe(event): the session's own events (lucid).
63
+ */
64
+ export function createDragonJobs({ reads = {}, context = () => ({}), now = Date.now, ttlMs = JOB_TTL_MS, timeoutMs = JOB_TIMEOUT_MS } = {}) {
65
+ const cache = new Map(), inflight = new Map();
66
+ let lastFailure = null, recoveredAt = null;
67
+ const keyOf = (job) => (job === 'ward' ? `ward:${context()?.envelopeId || ''}` : job);
68
+
69
+ function recovery() {
70
+ if (!lastFailure) return { caption: 'No failures seen in this session.', mood: null };
71
+ const when = ago(now() - lastFailure.at);
72
+ return recoveredAt != null
73
+ ? { caption: `Recovered: ${lastFailure.what} ${when}, a later turn completed.`, mood: 'ok' }
74
+ : { caption: `Last failure: ${lastFailure.what} ${when}, not recovered yet.`, mood: 'concerned' };
75
+ }
76
+ async function fetchOne(job) {
77
+ const read = reads[READ_FOR[job]];
78
+ if (typeof read !== 'function') return null;
79
+ let timer;
80
+ try {
81
+ const arg = job === 'ward' ? context()?.envelopeId : undefined;
82
+ const data = await Promise.race([
83
+ Promise.resolve().then(() => read(arg)),
84
+ new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('timed out')), timeoutMs); timer.unref?.(); }),
85
+ ]);
86
+ if (job === 'ward' && data && data.ok === false) return failed(job, data.error || 'failed');
87
+ return captionFor(job, data);
88
+ } catch (e) { return failed(job, why(e)); }
89
+ finally { clearTimeout(timer); }
90
+ }
91
+
92
+ return {
93
+ /** Feed the session's events (turn.finished, error): the only input lucid's job has. */
94
+ observe(ev) {
95
+ if (!ev) return;
96
+ if (ev.type === 'error') { lastFailure = { at: now(), what: 'stream error' }; recoveredAt = null; }
97
+ else if (ev.type === 'turn.finished') {
98
+ if (ev.status === 'failed' || ev.status === 'unconfirmed') { lastFailure = { at: now(), what: `turn ${ev.status}` }; recoveredAt = null; }
99
+ else if (ev.status === 'completed' && lastFailure && recoveredAt == null) recoveredAt = now();
100
+ }
101
+ },
102
+ peek(dragonId) {
103
+ const job = JOB_FOR[dragonId];
104
+ if (!job) return null;
105
+ if (job === 'recovery') return recovery();
106
+ const hit = cache.get(keyOf(job));
107
+ return hit && now() - hit.at < ttlMs ? hit.value : null;
108
+ },
109
+ /** The dragon's job result, from cache (30 s) or one bounded read. null = this dragon has no job here. */
110
+ async run(dragonId) {
111
+ const job = JOB_FOR[dragonId];
112
+ if (!job) return null;
113
+ if (job === 'recovery') return recovery();
114
+ if (job === 'ward' && !context()?.envelopeId) return { caption: 'No receipt to check yet: nothing from this session is witnessed.', mood: null };
115
+ const key = keyOf(job), hit = cache.get(key);
116
+ if (hit && now() - hit.at < ttlMs) return hit.value;
117
+ if (inflight.has(key)) return inflight.get(key);
118
+ const p = fetchOne(job).then((value) => { if (value) cache.set(key, { at: now(), value }); return value; }).finally(() => inflight.delete(key));
119
+ inflight.set(key, p);
120
+ return p;
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,15 @@
1
+ /** Worker thread: loads one dragonlab character and renders frames on request, so the TUI thread never does. See frames.js. */
2
+ import { parentPort, workerData } from 'node:worker_threads';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ const { root, id } = workerData;
6
+ const mod = await import(pathToFileURL(`${root}/integration/companions-v1/characters.mjs`).href);
7
+ const actor = await mod.loadCharacter(id);
8
+ parentPort.on('message', ({ seq, req }) => {
9
+ try {
10
+ const r = actor.render(req.scene, req.time, { cols: req.cols, rows: req.rows, still: req.still, mono: req.mono, motionTime: req.motionTime });
11
+ if (!r || !r.ok) parentPort.postMessage({ type: 'error', seq, code: r?.code || 'render failed' });
12
+ else parentPort.postMessage({ type: 'frame', seq, frame: r.frame });
13
+ } catch (e) { parentPort.postMessage({ type: 'error', seq, code: e.message }); }
14
+ });
15
+ parentPort.postMessage({ type: 'ready' });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Dragon companions (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus): which one, whether to show it, and where its art lives.
3
+ * Pure. The art, animation and activity reducer stay in quoxai/dragonlab (integration/companions-v1);
4
+ * Quox only chooses and loads. See docs/dragons/COMPANION-UX.md for the decisions behind each rule.
5
+ */
6
+ import { resolve, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ /** The dragonlab art bundled with the CLI (cli/vendor/dragonlab, refreshed by scripts/sync-dragonlab.sh). */
10
+ export const VENDORED_DRAGONLAB = fileURLToPath(new URL('../../vendor/dragonlab', import.meta.url));
11
+
12
+ /** Registry agent ids (and display names) to approved dragon. Others, including quox/commanderq, get none. */
13
+ export const AGENT_DRAGON = Object.freeze({ lucid: 'lucid', hyperion: 'hyperion', atlas: 'hyperion', seer: 'seer', sentinel: 'sentinel', warden: 'sentinel', security_lead: 'drago', drago: 'drago', cipher: 'cipher', vulcan: 'vulcan', forge: 'vulcan', quoxperticus: 'quoxperticus' });
14
+ export const COMPANION_MODES = ['on', 'greeting', 'off'];
15
+ /** One-line change: the owner decides this after his Mac check (docs/dragons/COMPANION-UX.md section 4). */
16
+ export const DEFAULT_COMPANION_MODE = 'on';
17
+
18
+ export function dragonForAgent(agent) {
19
+ const k = String(agent ?? '').trim().toLowerCase();
20
+ return Object.hasOwn(AGENT_DRAGON, k) ? AGENT_DRAGON[k] : null;
21
+ }
22
+
23
+ export function normaliseMode(v) {
24
+ const s = String(v ?? '').trim().toLowerCase();
25
+ return COMPANION_MODES.includes(s) ? s : null;
26
+ }
27
+
28
+ /** env QUOX_DRAGON_COMPANION beats the saved profile setting beats the default. */
29
+ export function companionMode(settings = {}, env = process.env) {
30
+ return normaliseMode(env.QUOX_DRAGON_COMPANION) || normaliseMode(settings?.dragonCompanion) || DEFAULT_COMPANION_MODE;
31
+ }
32
+
33
+ /**
34
+ * Motion policy from the existing Quox switches. lite or a dumb terminal = off; reduced motion = still
35
+ * (no entrance, every channel frozen, captions still update); NO_COLOR = mono render.
36
+ */
37
+ export function motionPolicy(env = process.env) {
38
+ const lite = Boolean(env.QUOX_TUI_LITE) || env.TERM === 'dumb';
39
+ const quiet = env.QUOX_REDUCED_MOTION === '1' || String(env.QUOX_MOTION || '').trim().toLowerCase() === 'quiet';
40
+ return { off: lite, still: quiet, mono: 'NO_COLOR' in env };
41
+ }
42
+
43
+ /**
44
+ * Where dragonlab lives: QUOX_DRAGONLAB beats the saved quoxon.dragonLab beats `bundled` (hosts pass
45
+ * VENDORED_DRAGONLAB). With no bundled path and no override, null = art not available.
46
+ */
47
+ export function dragonLabRoot(settings = {}, env = process.env, bundled = null) {
48
+ const p = env.QUOX_DRAGONLAB || settings?.dragonLab;
49
+ if (typeof p === 'string' && p.trim()) return resolve(p.trim());
50
+ return bundled ? resolve(bundled) : null;
51
+ }
52
+ export const companionModuleFile = (root, id) => join(root, 'integration', 'companions-v1', `${id}.mjs`);
53
+ export const viewModuleFile = (root) => join(root, 'integration', 'companions-v1', 'view.mjs');
54
+
55
+ /**
56
+ * Everything a host needs, or { enabled:false, why } (the reason is what `/dragon companion status` prints).
57
+ * `exists` is injected so this stays pure.
58
+ */
59
+ export function companionPlan({ agent, settings = {}, env = process.env, isTTY = true, exists = () => true, bundled = null }) {
60
+ const mode = companionMode(settings, env), policy = motionPolicy(env);
61
+ const id = dragonForAgent(agent);
62
+ const no = (why) => ({ enabled: false, why, mode, id });
63
+ if (mode === 'off') return no('companion is off');
64
+ if (!isTTY) return no('output is not a terminal');
65
+ if (policy.off) return no('QUOX_TUI_LITE or a dumb terminal turns decoration off');
66
+ if (!id) return no(agent ? `${agent} has no approved dragon` : 'no agent attached');
67
+ const root = dragonLabRoot(settings, env, bundled);
68
+ if (!root) return no('dragon art not found (the bundled copy is missing; set QUOX_DRAGONLAB or quoxon.dragonLab to a dragonlab checkout)');
69
+ const file = companionModuleFile(root, id);
70
+ if (!exists(file) || !exists(viewModuleFile(root))) return no(`dragon art not found at ${root}`);
71
+ return { enabled: true, mode, id, root, file, viewFile: viewModuleFile(root), still: policy.still, mono: policy.mono, persist: mode === 'on' };
72
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Fetch one conversation's signed evidence zip, write it, verify what was written. One implementation for
3
+ * `quox evidence bundle` and the TUI's /evidence. The server scopes by the caller's org and refuses anything else.
4
+ */
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { resolveEndpoint } from '../api/endpoints.js';
9
+ import { verifyEvidenceBundle } from './bundleVerify.js';
10
+
11
+ export function defaultBundlePath(corr, now = new Date()) {
12
+ const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
13
+ const safe = String(corr).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 60);
14
+ return path.join(os.homedir(), 'quox-evidence', `${safe}-${ts}.zip`);
15
+ }
16
+
17
+ /** -> the id of the caller's newest chat conversation, or null. */
18
+ export async function newestConversationId(client) {
19
+ const ep = resolveEndpoint('conversations.chat.list');
20
+ const data = await client.get(ep.path, { service: ep.service, query: { limit: 1 } });
21
+ const c = (data && data.conversations && data.conversations[0]) || null;
22
+ return c ? c.id : null;
23
+ }
24
+
25
+ /**
26
+ * @returns {Promise<{ path: string, corr: string, bytes: number, envelopes: number, receipts: number, verify: object }>}
27
+ * @throws Error with the HTTP status named when the server refuses (404 = not your conversation or no evidence).
28
+ */
29
+ export async function exportConversationBundle({ client, corr, out, now = new Date() }) {
30
+ const ep = resolveEndpoint('evidence.bundle', { corr });
31
+ const res = await client.get(ep.path, { service: ep.service, query: { format: 'zip' }, raw: true, timeout: 120000 });
32
+ if (!res.ok) {
33
+ let detail = res.statusText;
34
+ try { const j = await res.json(); detail = j.error || JSON.stringify(j); } catch { /* keep statusText */ }
35
+ const e = new Error(res.status === 404
36
+ ? `No evidence for conversation ${corr} in your org (HTTP 404: ${detail}). Tool calls made before this fix are filed under per-command ids and are not part of the conversation.`
37
+ : `Evidence bundle failed: HTTP ${res.status}: ${detail}`);
38
+ e.status = res.status;
39
+ throw e;
40
+ }
41
+ const buf = Buffer.from(await res.arrayBuffer());
42
+ const file = path.resolve(out || defaultBundlePath(corr, now));
43
+ fs.mkdirSync(path.dirname(file), { recursive: true });
44
+ fs.writeFileSync(file, buf);
45
+ const verify = await verifyEvidenceBundle(fs.readFileSync(file)); // what was written, not what was sent
46
+ return { path: file, corr, bytes: buf.length, envelopes: verify.manifest?.counts?.envelopes ?? 0, receipts: verify.manifest?.counts?.ward_receipts ?? 0, receiptRows: verify.receipts || [], verify };
47
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Offline verifier for a conversation evidence bundle (collector lib/conversationEvidenceBundle.js).
3
+ * Needs nothing but the zip: file hashes against manifest.json, the envelope list hash, the Ed25519 signature
4
+ * over the canonical manifest, and the counts. Never reaches the network. The collector builds; this only checks.
5
+ */
6
+ import crypto from 'node:crypto';
7
+ import JSZip from 'jszip';
8
+
9
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
10
+
11
+ // Same canonical form as services/collector/lib/bundleSigner.js (sorted keys at every level, no whitespace).
12
+ export function canonicalize(val) {
13
+ if (val === null || val === undefined) return JSON.stringify(val);
14
+ if (typeof val !== 'object') return JSON.stringify(val);
15
+ if (Array.isArray(val)) return `[${val.map((v) => canonicalize(v)).join(',')}]`;
16
+ const keys = Object.keys(val).filter((k) => val[k] !== undefined).sort();
17
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(val[k])}`).join(',')}}`;
18
+ }
19
+
20
+ const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
21
+
22
+ export function envelopesHash(envelopes) {
23
+ return sha256(Buffer.from(JSON.stringify(
24
+ envelopes.map((e) => ({ id: e.id ?? null, ts: e.ts ?? null, intent: e.intent ?? null, corr: e.corr ?? null, reply_to: e.reply_to ?? null, from: e.from ?? null, to: e.to ?? null })),
25
+ ), 'utf8'));
26
+ }
27
+
28
+ /**
29
+ * @param {Buffer|Uint8Array} zipBytes
30
+ * @returns {Promise<{ ok: boolean, checks: {name:string, ok:boolean, detail?:string}[], manifest: object|null, signature: object|null }>}
31
+ */
32
+ export async function verifyEvidenceBundle(zipBytes) {
33
+ const checks = [];
34
+ const add = (name, ok, detail) => { checks.push({ name, ok: !!ok, ...(detail ? { detail } : {}) }); return !!ok; };
35
+ let zip;
36
+ try { zip = await JSZip.loadAsync(zipBytes); } catch (e) {
37
+ add('zip readable', false, e.message);
38
+ return { ok: false, checks, manifest: null, signature: null };
39
+ }
40
+ const read = async (name) => { const f = zip.file(name); return f ? Buffer.from(await f.async('nodebuffer')) : null; };
41
+ const manifestBuf = await read('manifest.json');
42
+ const sigBuf = await read('signature.json');
43
+ if (!add('manifest.json present', manifestBuf)) return { ok: false, checks, manifest: null, signature: null };
44
+ if (!add('signature.json present', sigBuf)) return { ok: false, checks, manifest: null, signature: null };
45
+ let manifest; let signature;
46
+ try { manifest = JSON.parse(manifestBuf.toString('utf8')); signature = JSON.parse(sigBuf.toString('utf8')); } catch (e) {
47
+ add('manifest and signature parse', false, e.message);
48
+ return { ok: false, checks, manifest: null, signature: null };
49
+ }
50
+
51
+ // 1. signature over the canonical manifest
52
+ try {
53
+ const signed = Buffer.from(canonicalize(manifest), 'utf8');
54
+ const pub = crypto.createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, Buffer.from(signature.public_key, 'base64')]), format: 'der', type: 'spki' });
55
+ const sigOk = signature.alg === 'ed25519' && crypto.verify(null, signed, pub, Buffer.from(signature.signature, 'base64'));
56
+ add('signature (Ed25519 over the manifest)', sigOk, sigOk ? `key ${signature.key_id}` : 'signature does not match the manifest');
57
+ add('signed manifest hash', signature.manifest_sha256 === sha256(signed), signature.manifest_sha256 === sha256(signed) ? undefined : 'manifest_sha256 differs');
58
+ } catch (e) { add('signature (Ed25519 over the manifest)', false, e.message); }
59
+
60
+ // 2. every listed file hashes to what the (signed) manifest says
61
+ const bufs = {};
62
+ for (const [name, want] of Object.entries(manifest.files || {})) {
63
+ const b = await read(name);
64
+ bufs[name] = b;
65
+ add(`file ${name}`, b && sha256(b) === want, !b ? 'missing from the zip' : (sha256(b) === want ? undefined : 'hash differs from the manifest'));
66
+ }
67
+
68
+ // 3. envelope list hash + counts
69
+ let envelopes = [];
70
+ try { envelopes = JSON.parse((bufs['envelopes.json'] || Buffer.from('[]')).toString('utf8')); } catch { envelopes = []; }
71
+ add('envelope list hash', envelopesHash(envelopes) === manifest.envelopes_sha256, envelopesHash(envelopes) === manifest.envelopes_sha256 ? undefined : 'recomputed hash differs');
72
+ add('envelope count', envelopes.length === manifest.counts?.envelopes, `${envelopes.length} in envelopes.json, ${manifest.counts?.envelopes} in the manifest`);
73
+ let receipts = [];
74
+ try { receipts = JSON.parse((bufs['ward-receipts.json'] || Buffer.from('[]')).toString('utf8')); } catch { receipts = []; }
75
+ add('receipt count', receipts.length === manifest.counts?.ward_receipts, `${receipts.length} in ward-receipts.json, ${manifest.counts?.ward_receipts} in the manifest`);
76
+ return { ok: checks.every((c) => c.ok), checks, manifest, signature, receipts };
77
+ }
@@ -0,0 +1,146 @@
1
+ # quoxon extensions: contract v1
2
+
3
+ Optional npm packages that add a surface to quoxon (a MIDI pad deck, a status bar, a fleet board). Core ships none and knows none by name.
4
+ An extension can OPEN an approval or PRESELECT an answer. It can never resolve one, and it never polls Quox: data is pushed to it.
5
+
6
+ ## Loading
7
+
8
+ Nothing loads unless the package is installed AND listed in `~/.quox/extensions.json`
9
+ (`quox ext add|remove|list <pkg>`; `/ext list|on|off <name>` inside quoxon). Install with `npm i -g <pkg>`.
10
+ Set env `QUOX_EXT_LIST=<path>` to use another list file (default `~/.quox/extensions.json`), for `quox ext` and quoxon alike (dev and tests).
11
+ Resolution order: env `QUOX_EXT_<NAME>_MODULE` (name upper-cased, non-alphanumerics to `_`), a local `import(name)`,
12
+ then the global npm root. `/ext list` names where each was found; a miss lists where it looked.
13
+
14
+ **contractVersion** (integer, this document is `1`). The host refuses an extension whose `contractVersion` is above what it supports,
15
+ with a line in `/ext list`: `needs extension contract v2, this quoxon supports v1`. An extension with no `contractVersion` is a
16
+ pre-contract (legacy) extension: it loads, and `emit` may use `n` without `id` (see F2). Declare `contractVersion: 1` to get the strict rules.
17
+
18
+ ## Module
19
+
20
+ Default export (or the module itself):
21
+
22
+ ```ts
23
+ interface Extension {
24
+ name: string; version?: string;
25
+ contractVersion?: 1;
26
+ commands?: Command[];
27
+ settings?: SettingDef[];
28
+ create(ctx: { emit(msg: Emit): boolean; log(text: string): void; settings: Record<string, string|number|boolean>; invalidate(): void }): Instance;
29
+ }
30
+ interface Instance {
31
+ update?(state: State): void; // deduped; see Freshness
32
+ close?(): void | Promise<void>; // see Close
33
+ panel?: Panel; // see Panels (one panel per extension)
34
+ onSettings?(next: Record<string, string|number|boolean>): void;
35
+ onEmitResult?(r: { ok: boolean; reason?: string; msg: Emit }): void;
36
+ }
37
+ ```
38
+
39
+ `panel`, `onSettings` and `onEmitResult` live on the object `create()` returns. There is no module-level `onSettings`.
40
+
41
+ ## Commands
42
+
43
+ ```ts
44
+ interface Command {
45
+ name: `/${string}`; help?: string;
46
+ subcommands?: { name: string; help?: string }[]; // feed /help and Tab completion
47
+ run(args: string[], ctx: { raw: string; say(text: string, tone?: 'ok'|'warn'|'accent'|'dim'): void; extension: string; loaded: string[] }): string | { text: string; tone?: string } | Promise<...>;
48
+ }
49
+ ```
50
+
51
+ `args` is the text after the command split on whitespace; `ctx.raw` is the same text unsplit. Commands exist only while the extension is loaded.
52
+ A thrown error is printed as `<cmd>: <message>`, never crashes the window.
53
+
54
+ ## State
55
+
56
+ `update(state)` is deduped (a push identical except `at` is dropped) and sent at most once per second.
57
+
58
+ ```ts
59
+ interface State {
60
+ at: string; // ISO time of this push
61
+ stale?: true; // present only when the host's last successful server read is over 60 s old
62
+ org: { id: string|null; name: string|null };
63
+ approvals: { count: number; items: { n: number; id: string|null; risk: 'low'|'high'; title: string; agent: string|null; options?: { key: string; label: string }[] }[] };
64
+ health: Record<string, 'ok'|'warn'|'down'>;
65
+ loops: { id: string; title: string; turn: number; max: number; status: string }[];
66
+ fleet: { total: number; online: number; offline: number; warn: number; hosts: { name: string; state: 'ok'|'warn'|'down' }[] }; // hosts max 16, may be empty
67
+ evidence: { chain: 'ok'|'broken'|'unknown'; checked_at: string|null }; // PLACEHOLDER: always 'unknown' until quoxon holds a chain status
68
+ sessions: { active: number };
69
+ event?: { seq: number; id?: string; kind: string; text: string; level: 'info'|'ok'|'warn'|'error' };
70
+ }
71
+ ```
72
+
73
+ **Freshness.** `state.at` is set on every push. While any extension is loaded the host pushes a heartbeat every 15 s, even when nothing
74
+ changed, so `at` keeps moving. `stale: true` means the data may be old (the server was not read for over 60 s): show it as such.
75
+
76
+ **Event ids.** `event.seq` is a positive integer, monotonic within one quoxon window session and never reused; a new event gets the next number,
77
+ the seq changes only when `kind`, `text`, `level` or `id` changes: a repeat of the held event keeps its seq until `alert_ack`. `event.id` appears only when the server supplied one.
78
+
79
+ `fleet` comes from a 30 s poll of the host list while an extension is loaded.
80
+
81
+ ## emit
82
+
83
+ ```ts
84
+ type Emit =
85
+ | { type: 'open_approval'; id: string; n?: number }
86
+ | { type: 'pick'; id: string; n?: number; option: string }
87
+ | { type: 'alert_ack' };
88
+ ```
89
+
90
+ `id` (from `approvals.items[].id`) is REQUIRED for `open_approval` and `pick`. `n` is display-only; when `id` and `n` disagree, `id` wins
91
+ (logged). An unknown, missing or already-resolved `id` is refused through `onEmitResult({ ok:false, reason, msg })`, and is never retargeted
92
+ to another item. `pick` only fills the composer with the answer command: the user presses Enter, high risk still needs the step-up PIN.
93
+ `alert_ack` clears the event. emit returns `true` when accepted and is ignored once the extension is unloaded.
94
+ (Legacy extensions without `contractVersion` may omit `id` and use `n`.)
95
+
96
+ ## Panels and tabs
97
+
98
+ ```ts
99
+ interface Panel {
100
+ title: string;
101
+ render(a: { width: number; height: number; focused: boolean; theme: Theme }): string[]; // pure
102
+ onKey?(key: string): boolean; // true = consumed, see Keys
103
+ onFocus?(): void; onBlur?(): void;
104
+ }
105
+ ```
106
+
107
+ ```ts
108
+ interface Theme { name: 'dark'|'light'|'mono'; color: boolean; unicode: boolean }
109
+ ```
110
+
111
+ The host owns the tab strip, shown only when one or more panels exist. The panel region sits above the composer, height
112
+ `min(12, 40% of terminal rows)`, collapsed until a panel is focused or opened. `/ext tab <name>` focuses (and opens) a panel; `/ext tab off`
113
+ collapses; Ctrl+] cycles focus through the panels; Esc returns keys to the composer (the panel stays open). While focused, keys go to
114
+ `onKey` first; a key it does not consume is dropped. The panel never receives `escape` or `ctrl+]`: the host consumes both.
115
+
116
+ **Keys.** `onKey(key)` gets one of `up down left right enter tab backspace space home end pageup pagedown`, otherwise the single typed
117
+ character (`a`, `?`, `/`), or `ctrl+<c>` / `alt+<c>` for the modifier combinations the terminal reports. Pasted text arrives one
118
+ character at a time.
119
+
120
+ `render` must be pure and fast. The 50 ms budget is wall-clock per render call (`performance.now`). The host clips the result to `height` lines and `width` columns. A render that throws, or takes over 50 ms,
121
+ shows `panel error` in its place and is retried no sooner than 5 s later; it never crashes the window. The panel re-renders on every state
122
+ push and on `invalidate()` (passed to `create`, throttled to 4 per second).
123
+
124
+ ## Settings
125
+
126
+ ```ts
127
+ interface SettingDef { key: string; type: 'string'|'number'|'bool'|'enum'; default: string|number|boolean; help: string; choices?: string[] }
128
+ ```
129
+
130
+ Values live in `~/.quox/extensions.json` on the extension's entry as `settings: { <key>: value }`. `create({ settings })` receives the resolved
131
+ object (defaults overlaid with stored values). `/ext set <name> <key> <value>` validates by type (number: `Number()` and it must be a finite number; bool: `true|yes|on|1` or
132
+ `false|no|off|0`, case-insensitive, anything else is rejected with a message; enum: an exact match to one of `choices`; string: any text), stores it, and calls `onSettings(next)` on the running instance.
133
+ `/ext settings <name>` lists each key with its value, default and help. The extension must be loaded and listed.
134
+
135
+ ## Close
136
+
137
+ `close()` may return a promise. The host awaits it for at most 500 ms, then drops the instance anyway and logs it. It must be idempotent
138
+ (the host may call it twice) and must free every timer, interval, watcher, device and process listener, because hot reload calls it on each change.
139
+
140
+ ## Hot reload
141
+
142
+ While an extension is loaded, quoxon watches its `package.json` (`fs.watch`, 2 s debounce, plus a 30 s stat poll because global installs
143
+ replace directories). When the version or mtime changes it awaits `close()` on the old instance (500 ms cap), drops its commands and panel,
144
+ re-imports the entry with a cache-busting `?v=<version>-<mtime>`, and calls `create()` again, then prints `↻ <name> updated <old> -> <new>`.
145
+ Only the entry module is re-imported. A failed reload leaves the old instance closed, prints the error once, and shows `failed: <error>` in
146
+ `/ext list` until the next change. Watchers and timers are cleared on `/ext off`, unload and exit.