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,113 @@
1
+ /**
2
+ * SLOT-OUTBOX: a /say or @slot message that could not be sent because QuoxCORE did not answer (fetch failed,
3
+ * ECONNREFUSED, socket error, timeout, 5xx) is kept on disk and sent when it answers again, never lost.
4
+ * Local only (~/.quox/slot-outbox.json, mode 0600, per OS user; each entry also carries the profile it was typed in,
5
+ * and a window only ever sends its own profile's entries, so a message never changes org).
6
+ *
7
+ * Never for a 4xx (auth, permission, no such slot): that is the user's to fix, so the text stays in the prompt.
8
+ *
9
+ * ONE-SEND RULE (the slot-say endpoint has no idempotency key, so the client must be conservative):
10
+ * 1. An entry is flipped to `sending` and persisted BEFORE the request goes out.
11
+ * 2. Success removes it (it is never sent again).
12
+ * 3. A clean failure (the server did not answer) puts it back to `queued`.
13
+ * 4. An entry still `sending` when the file is next read by a process that is not flushing is `unsure`: the window
14
+ * died mid-request, so the message may have been delivered. `unsure` entries are never auto-sent; /outbox shows
15
+ * them and `/outbox send` (explicit) is the only thing that will try them.
16
+ * Residual risk, stated: a timeout where the server did receive the message but the reply was lost can duplicate it
17
+ * once on flush. Duplicate over lost is the chosen side for a message the owner typed and saw fail.
18
+ */
19
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { join, dirname } from 'node:path';
22
+ import { randomBytes } from 'node:crypto';
23
+
24
+ export const OUTBOX_CAP = 50;
25
+ export const defaultOutboxPath = (home = homedir()) => join(home, '.quox', 'slot-outbox.json');
26
+
27
+ /** A send result worth queueing: no HTTP status (fetch failed, refused, socket, timeout) or a 5xx. Never a 4xx, ambiguity, or a licence refusal. */
28
+ export function isConnectivityFailure(r) {
29
+ if (!r || r.ok || r.ambiguous || r.unlicensed) return false;
30
+ if (r.status == null) return true;
31
+ return Number(r.status) >= 500;
32
+ }
33
+
34
+ export function slotOutbox(path = defaultOutboxPath(), { now = () => Date.now() } = {}) {
35
+ const read = () => {
36
+ try { const d = JSON.parse(readFileSync(path, 'utf8')); return Array.isArray(d?.items) ? d : { items: [] }; }
37
+ catch { return { items: [] }; }
38
+ };
39
+ const write = (d) => {
40
+ try {
41
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
42
+ const tmp = `${path}.${process.pid}.tmp`;
43
+ writeFileSync(tmp, JSON.stringify(d), { mode: 0o600 });
44
+ renameSync(tmp, path);
45
+ return true;
46
+ } catch { return false; }
47
+ };
48
+ let flushing = false; // this process is mid-flush: its own `sending` entry is live, not crashed
49
+ const settle = (d) => { // a `sending` entry nobody in this process is sending is a crashed send; persist that verdict
50
+ if (flushing) return d;
51
+ let changed = false;
52
+ for (const it of d.items) if (it.status === 'sending') { it.status = 'unsure'; changed = true; }
53
+ if (changed) write(d);
54
+ return d;
55
+ };
56
+ const mine = (it, profile) => profile == null || it.profile == null || it.profile === profile;
57
+ return {
58
+ /** Entries in send order (oldest first), optionally only this profile's. */
59
+ list: ({ profile = null } = {}) => settle(read()).items.filter(i => mine(i, profile)),
60
+ /** -> { ok:true, item, count } | { ok:false, full:true, count }. The cap is on entries not yet sent. */
61
+ enqueue({ slot, text, profile = null }) {
62
+ const d = settle(read());
63
+ if (d.items.length >= OUTBOX_CAP) return { ok: false, full: true, count: d.items.length };
64
+ const item = { id: `ob_${now().toString(36)}_${randomBytes(3).toString('hex')}`, slot, text: String(text), profile, created_at: new Date(now()).toISOString(), status: 'queued', tries: 0 };
65
+ d.items.push(item);
66
+ if (!write(d)) return { ok: false, unsaved: true, count: d.items.length };
67
+ return { ok: true, item, count: d.items.filter(i => mine(i, profile)).length };
68
+ },
69
+ /** Remove the Nth entry (1-based, as /outbox lists this profile's). -> the dropped item, or null. */
70
+ drop(n, { profile = null } = {}) {
71
+ const d = settle(read());
72
+ const visible = d.items.filter(i => mine(i, profile));
73
+ const it = visible[Number(n) - 1];
74
+ if (!it) return null;
75
+ d.items = d.items.filter(i => i.id !== it.id);
76
+ write(d);
77
+ return it;
78
+ },
79
+ /**
80
+ * Send what is queued, in order, once. send(item) -> { ok:true } | { ok:false, retryable }. A retryable failure stops
81
+ * the flush (the server is still down; order is kept). A non-retryable one marks that entry `failed` and moves on.
82
+ * `includeUnsure` (explicit /outbox send only) also tries entries that may already have been delivered.
83
+ * -> { sent:[items], failed:[items {error}], pending:n, busy? }.
84
+ */
85
+ async flush(send, { profile = null, includeUnsure = false } = {}) {
86
+ if (flushing) return { sent: [], failed: [], pending: 0, busy: true };
87
+ flushing = true;
88
+ const sent = []; const failed = [];
89
+ try {
90
+ const ids = settle(read()).items.filter(i => mine(i, profile) && (i.status === 'queued' || (includeUnsure && i.status === 'unsure'))).map(i => i.id);
91
+ for (const id of ids) {
92
+ const d = read();
93
+ const it = d.items.find(i => i.id === id);
94
+ if (!it || !(it.status === 'queued' || it.status === 'unsure')) continue; // dropped or sent meanwhile
95
+ it.status = 'sending'; it.tries = (it.tries || 0) + 1; write(d); // persisted BEFORE the request
96
+ let r;
97
+ try { r = await send(it); } catch (e) { r = { ok: false, retryable: true, error: e?.message }; }
98
+ const after = read();
99
+ const cur = after.items.find(i => i.id === id);
100
+ if (r?.ok) { after.items = after.items.filter(i => i.id !== id); write(after); sent.push(it); continue; }
101
+ if (cur) { cur.status = r?.retryable === false ? 'failed' : 'queued'; if (r?.error) cur.error = String(r.error); write(after); }
102
+ if (r?.retryable === false) { failed.push({ ...it, error: r.error || 'refused' }); continue; }
103
+ break; // still not answering: keep the rest, in order, for the next recovery
104
+ }
105
+ } finally { flushing = false; }
106
+ return { sent, failed, pending: settle(read()).items.filter(i => mine(i, profile) && i.status === 'queued').length };
107
+ },
108
+ };
109
+ }
110
+
111
+ export const queuedLine = (slot, count) => `⏸ queued for ${slot}: will send when QuoxCORE answers (${count} queued) · /outbox`;
112
+ export const sentLine = (slot) => `✔ sent queued message to ${slot}`;
113
+ export const fullLine = (cap = OUTBOX_CAP) => `Outbox is full (${cap} queued). Not queued: your text is back in the prompt. /outbox shows them, /outbox drop N removes one, /outbox send retries now.`;
@@ -8,7 +8,7 @@
8
8
  import { findCommandEntry } from './commands.js';
9
9
 
10
10
  // Commands that need the human's own typing: never offered, never run from a model suggestion.
11
- export const HUMAN_TYPED = new Set(['approve', 'yestoall', 'deny', 'authorise', 'authorize', 'authenticate', 'override', 'restart', 'quit', 'exit', 'login']);
11
+ export const HUMAN_TYPED = new Set(['approve', 'yestoall', 'deny', 'authorise', 'authorize', 'authenticate', 'override', 'verify-email', 'restart', 'quit', 'exit', 'login']);
12
12
  // Pure views, no effect: may run without asking when explicit, and only with no arguments.
13
13
  export const READ_ONLY_VIEWS = new Set(['approvals', 'mind', 'which', 'context', 'rooms', 'active', 'slots']);
14
14
 
@@ -33,14 +33,45 @@ export function vetSuggestion(raw) {
33
33
  */
34
34
  export function namedCommand(text) {
35
35
  const src = String(text || '');
36
+ const closing = closingProposal(src);
37
+ if (closing !== undefined) return closing; // the answer's own closing question names THE command: that one or nothing
36
38
  const seen = new Set();
37
39
  for (const m of src.matchAll(/`(\/[a-z][^`\n]{0,118})`/gi)) {
38
40
  if (mentionsAPath(src, m.index, m[0].length, m[1])) continue; // `/health` the endpoint is not /health the command
39
41
  seen.add(m[1].trim().replace(/\s+/g, ' '));
40
42
  }
41
43
  if (seen.size !== 1) return null;
42
- const v = vetSuggestion([...seen][0]);
43
- return v.ok ? v : null;
44
+ return offerable([...seen][0]);
45
+ }
46
+
47
+ // Commands that SEND or WRITE something: a mention is never an offer (the model must call terminal_suggest_command).
48
+ export const WRITE_COMMANDS = new Set(['broadcast', 'roar', 'say', 'page', 'ack', 'reply', 'r', 'ask', 'aside', 'afk', 'back', 'observe', 'awaken', 'poke', 'wake', 'nudge', 'ping', 'slap', 'feedback', 'gripe', 'gripe!', 'note', 'dragon', 'wish', 'challenge', 'huh']);
49
+
50
+ // vetted AND safe to offer from a mention: not a write/send, and no required <arg> left unfilled.
51
+ function offerable(line) {
52
+ const v = vetSuggestion(line);
53
+ if (!v.ok) return null;
54
+ if (WRITE_COMMANDS.has(v.id) || WRITE_COMMANDS.has(line.slice(1).split(/\s+/)[0].toLowerCase())) return null;
55
+ const entry = findCommandEntry(line.slice(1).split(/\s+/)[0]);
56
+ const bare = line.trim().split(/\s+/).length === 1;
57
+ if (bare && /</.test(String(entry?.usage || ''))) return null; // "/ack <broadcast id>" needs its argument
58
+ return v;
59
+ }
60
+
61
+ // "Do you want me to run X?" / "Shall I run X?" -> undefined (no such question) | the offer for X | null.
62
+ // X as a slash command is offered when safe; X as a `quox <verb>` CLI line is offered only as the matching read-only slash view.
63
+ function closingProposal(src) {
64
+ const m = /(?:do you want me to|want me to|would you like me to|shall i|should i|can i)\s+(?:run|try|use|show you)\s+`?([^?`\n]{1,120}?)`?\s*(?:and\b[^?\n]*)?\?\s*$/i.exec(src.trim());
65
+ if (!m) return undefined;
66
+ const x = m[1].trim();
67
+ if (x.startsWith('/')) return offerable(x.replace(/\s+/g, ' '));
68
+ const cli = /^!?quox\s+([a-z][\w-]*)\s*$/i.exec(x);
69
+ if (cli) {
70
+ const slash = '/' + cli[1].toLowerCase();
71
+ const v = offerable(slash);
72
+ return v && v.readOnly ? v : null;
73
+ }
74
+ return null;
44
75
  }
45
76
 
46
77
  // A URL path, not a command (owner live 05:26: "the `/health` endpoint is erroring" offered "Run /health?").
@@ -48,8 +48,19 @@ export function inkStdout(out, { tap = null } = {}) {
48
48
  if (cb) out.write('', cb); // nothing to send: still a barrier behind the earlier writes
49
49
  return true;
50
50
  } : null;
51
- return new Proxy(out, {
51
+ // Resize: Ink repaints its last committed tree at the new width the moment 'resize' fires, which wraps every 78-wide rule for a
52
+ // beat (owner 2026-10-27: top rule and model label wrapped after 79 -> 58). Listeners for 'resize' run RESIZE_SETTLE_MS late, so
53
+ // the app (which flags its own listener `resizeFirst`) commits the new column count first and Ink paints the right width once.
54
+ const lateOf = new WeakMap();
55
+ const lateListener = (fn) => {
56
+ if (fn.resizeFirst) return fn;
57
+ if (!lateOf.has(fn)) lateOf.set(fn, (...a) => { const t = setTimeout(() => fn(...a), RESIZE_SETTLE_MS); t.unref?.(); });
58
+ return lateOf.get(fn);
59
+ };
60
+ const proxy = new Proxy(out, {
52
61
  get(target, prop) {
62
+ if ((prop === 'on' || prop === 'addListener' || prop === 'once') && typeof target[prop] === 'function') return (ev, fn, ...r) => { target[prop](ev, ev === 'resize' && typeof fn === 'function' ? lateListener(fn) : fn, ...r); return proxy; };
63
+ if ((prop === 'off' || prop === 'removeListener') && typeof target[prop] === 'function') return (ev, fn, ...r) => { target[prop](ev, ev === 'resize' && typeof fn === 'function' ? lateListener(fn) : fn, ...r); return proxy; };
53
64
  if (prop === 'write' && write) return write;
54
65
  if (prop === 'rows') return Infinity;
55
66
  if (prop === 'termRows') return target.rows;
@@ -61,7 +72,20 @@ export function inkStdout(out, { tap = null } = {}) {
61
72
  },
62
73
  set(target, prop, value) { return Reflect.set(target, prop, value, target); }
63
74
  });
75
+ return proxy;
76
+ }
77
+ const RESIZE_SETTLE_MS = 40;
78
+
79
+ /**
80
+ * The width to print history at, read from the terminal NOW. The `cols` state lags a resize (the listener attaches after mount,
81
+ * and the commit is async), and a <Static> item printed at the stale width is wider than the new terminal, so the terminal
82
+ * breaks it mid-word. Never wider than the state either (a pinned `columns` prop wins when it is narrower).
83
+ */
84
+ export function liveCols(stdout, stateCols) {
85
+ const n = Number(stdout?.columns);
86
+ return Number.isFinite(n) && n > 0 ? Math.min(stateCols, Math.max(MIN_STATIC_COLS, n - 1)) : stateCols;
64
87
  }
88
+ const MIN_STATIC_COLS = 20;
65
89
 
66
90
  /** Real terminal height, or null when unknown (not a TTY, tests). */
67
91
  export function termRows(stdout) {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * HITL-INBOX-BAR (owner 2026-10-26: "more programmatic output, so nice and clear"): when a tool's result is a list, the
3
+ * facts are drawn as a table from the tool result, above whatever the model then says. Keyed by tool name; the server
4
+ * puts a bounded `card` on tool.finished (services/collector/lib/conversationToolEvents.js RESULT_CARDS). To add a
5
+ * list answer: add a renderer here and the matching projection there.
6
+ */
7
+ import { ageOf, fromOf } from './inboxView.js';
8
+
9
+ const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, Math.max(0, n - 1))}…` : t; };
10
+ const pad = (s, n) => { const t = clip(s, n); return t + ' '.repeat(Math.max(0, n - [...t].length)); };
11
+
12
+ const RENDERERS = {
13
+ /** inbox_pending rows -> `# when kind priority title state` */
14
+ inbox_pending(card, { cols, now }) {
15
+ const rows = card.rows || [];
16
+ const tw = Math.max(16, Math.min(60, cols - 46));
17
+ const out = [{ head: true, text: `${pad('#', 3)}${pad('when', 6)}${pad('kind', 10)}${pad('prio', 8)}${pad('title', tw)} state` }];
18
+ rows.forEach((r, i) => out.push({ text: `${pad(i + 1, 3)}${pad(ageOf(r.created_at, now), 6)}${pad(r.kind || fromOf(r), 10)}${pad(r.priority || '', 8)}${pad(r.title || 'untitled', tw)} ${r.status || ''}` }));
19
+ if (Number.isInteger(card.total) && card.total > rows.length) out.push({ head: true, text: `showing ${rows.length} of ${card.total} · /inbox` });
20
+ return out;
21
+ },
22
+ };
23
+
24
+ /** -> [{ text, head? }] for a settled tool block that carries a card; [] otherwise. Never throws. */
25
+ export function toolCardLines(b, cols = 100, now = Date.now()) {
26
+ const fn = b && b.card && Array.isArray(b.card.rows) && RENDERERS[b.tool];
27
+ if (!fn) return [];
28
+ try { return fn(b.card, { cols, now }); } catch { return []; }
29
+ }
30
+ export const cardTools = () => Object.keys(RENDERERS);
@@ -11,6 +11,7 @@ import { stripMarks } from './palette.js';
11
11
  import { textGate } from './events.js';
12
12
  import { join } from 'node:path';
13
13
  import { homedir } from 'node:os';
14
+ import { redactSecrets } from './secretShape.js';
14
15
 
15
16
  export const MAX_FIELD = 4000;
16
17
  export const SILENT_GAP_MS = 10000;
@@ -33,7 +34,7 @@ const SCRUBS = [
33
34
  export function scrub(s) {
34
35
  let out = String(s);
35
36
  for (const [re, rep] of SCRUBS) out = out.replace(re, rep);
36
- return out;
37
+ return redactSecrets(out); // SECRET-GUARD: bank/PIN/card shapes, after the key patterns
37
38
  }
38
39
 
39
40
  /** Strings are scrubbed and truncated; non-strings become a scrubbed JSON string (never a raw object). */
@@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
7
  import { join } from 'node:path';
8
8
 
9
- export const DOCS_URL = 'https://cli.quox.ai/docs/start/'; // #1100: CLI docs "Start here" (cli.quox.ai/docs/ itself has no index page)
9
+ export const DOCS_URL = 'https://quox.ai/cli/'; // #1100: CLI docs start page (cli.quox.ai retired 2026-10-26, moved to quox.ai/cli)
10
10
  export const TERMS_URL = 'https://quox.ai/terms';
11
11
  export const PRIVACY_URL = 'https://quox.ai/privacy';
12
12
  export const LICENCE = 'Quox Community Source Licence 1.0 (source-available)';
@@ -109,7 +109,7 @@ export function listWhatsNew(arg = '', entries = ENTRIES) {
109
109
  // Startup preference: quoxon.whatsNewStartup = 'on' | 'off' (default on). Both live in the synced profile settings.
110
110
  export const startupEnabled = (settings) => String(settings?.whatsNewStartup || 'on').toLowerCase() !== 'off';
111
111
 
112
- const activeCommands = (registry) => registry.filter(e => e.kind === 'command' && e.status === 'active');
112
+ const activeCommands = (registry) => registry.filter(e => e.kind === 'command' && e.status === 'active' && !e.dyn);
113
113
 
114
114
  /**
115
115
  * Commands the user has not been shown, newest first (registry is oldest first, so reversed). With no record yet
@@ -87,3 +87,84 @@
87
87
  {"id":"footer-tool-count","date":"2026-10-19T12:00:00Z","since":"0.3.1","title":"Turn footer counts tools properly","text":"The line under an answer now reads 1 tool or 3 tools. Some builds printed [object Object]1 tools after a turn that used a tool."}
88
88
  {"id":"cancelled-reads-cancelled","date":"2026-10-19T14:00:00Z","since":"0.3.1","title":"Cancelled reads cancelled","text":"A stopped objective in the activity list now says cancelled (a neutral circle), not failed. Expired and rejected say their own word too, and an empty question no longer draws a stray rule above the composer."}
89
89
  {"id":"one-rule-above-composer","date":"2026-10-19T15:00:00Z","since":"0.3.1","title":"One rule above the composer","text":"Under an approval card the composer no longer draws a blank line and a second rule: the card's closing rule is the only one."}
90
+ {"id":"install-claude-code-codex","date":"2026-10-19T19:30:00Z","since":"0.3.1","title":"Install Claude Code and Codex from Quox","text":"Quox works best with both Claude and OpenAI models. If this server has no Claude Code or Codex, Quox offers to install it the moment you need it (a y/N card), or run quox provider install claude-code. It is the vendor's own npm package, downloaded to your server.","shell":"quox provider install claude-code","sha":"e802e90ce"}
91
+ {"id":"polish8-card-lists","date":"2026-10-19T19:40:00Z","since":"0.3.1","title":"Approval cards keep list lines, ask loosening takes --recommend","text":"A walk card now shows a file list one item per line under WHY, and text with HTML escapes shows as plain characters. A session can add its advice to a test-edit approval with --recommend y or n.","shell":"quox ask loosening --help"}
92
+ {"id":"polish8-exec-entities","date":"2026-10-19T20:00:00Z","since":"0.3.1","title":"Approval cards show commands as typed","text":"An approval for a command no longer shows HTML escapes like > in the command, reason or request lines.","shell":"quox ask loosening --help"}
93
+ {"id":"restart-min-gap","date":"2026-10-19T20:10:00Z","since":"0.3.1","title":"At most one auto-restart offer every 15 minutes","text":"The window no longer offers a restart within 15 minutes of starting; the newest settled build is offered once after that, and /restart still works any time.","try":"/restart"}
94
+ {"id":"named-offer-one-readonly","date":"2026-10-19T20:20:00Z","since":"0.3.1","title":"Only one complete read-only command is offered from an answer","text":"When an answer merely mentions a command, the window no longer offers a write like /broadcast or one missing its required text; it offers nothing unless there is exactly one safe command."}
95
+ {"id":"fun-commands-try-these","date":"2026-10-19T20:30:00Z","since":"0.3.1","title":"Ask Quox for fun commands and get a real list","text":"Ask for fun or cool commands, or what Quox can do, and the answer is a hand-picked set (the dragon verbs, /challenge, /spawn, /crystallize and more), each with a one-line reason to try it. /help ends with a short Try these block.","try":"/help"}
96
+ {"id":"q-shortcut","date":"2026-10-19T20:30:00Z","since":"0.3.1","title":"A short q command for quox","text":"Run quox shortcut add to make q start quox. It never replaces a q you already have, and quox shortcut remove undoes it.","shell":"quox shortcut add --help"}
97
+ {"id":"note-notesq","date":"2026-10-19T20:30:00Z","title":"/note saves a NotesQ note","text":"/note <text> now creates a NotesQ note and /notes lists them. The developer observer note moved to /devnote (// text still works).","try":"/note buy milk","since":"0.3.0"}
98
+ {"id":"findings-reconcile","date":"2026-10-19T21:15:00Z","since":"0.3.1","title":"Fixed security findings stop raising alarms","text":"quox engagement reconcile checks each open finding against its linked issue and fix commit. A finding whose fix is on main or in the running build now reads \"fix deployed, awaiting verification\" in /sitrep instead of an alarm. It is closed only when the issue is closed AND the fix is deployed, a critical never closes on its own, and quox engagement reconciliations shows the audit trail.","shell":"quox engagement reconcile --dry-run"}
99
+ {"id":"walk-recheck","date":"2026-10-19T22:40:00Z","since":"0.3.1","title":"The walkthrough ends by re-checking the server","text":"After your last answer the approvals walkthrough fetches the list fresh and ends with \"Nothing else is currently waiting for you.\", or tells you how many new ones arrived, or that it could not re-check. No need to run /approvals again.","try":"/walkthrough"}
100
+ {"id":"slash-plugins","date":"2026-10-19T23:55:00Z","since":"0.3.1","title":"/plugins lists your installed extensions","text":"/plugins (also /extensions, /addons, /apps) shows the plugins your org has installed with their state and version, and a mistyped slash command now suggests the nearest real one.","try":"/plugins"}
101
+ {"id":"qpe-rows","date":"2026-10-19T23:59:00Z","since":"0.3.1","title":"The proof engine rows are coloured, lined up and three deep","text":"The QPE rows now read as three rows on one grid: the score coloured by health with its sparkline, 24 h change and release-proven jobs; what got fixed and broke, the last run and who ran it, the next run; coverage coloured by ratio with the weakest area named. Narrow windows drop whole items rather than cutting words. Status rows can now send coloured cells (quox rows contract).","try":"/qpe rows 3"}
102
+ {"id":"qpe-content","date":"2026-10-20T03:30:00Z","since":"0.3.1","title":"A fourth QPE line: your cli.quox.ai content","text":"The proof engine lines now add, not just respread: the newest broken case by name, what the newest release is waiting on, the next run (and why today's was deferred), and a fourth line for cli.quox.ai content: pages backed by proof, guides, things to try, journeys and command pages. One call draws all four. /qpe 4 (or unset) shows them all.","try":"/qpe 4"}
103
+ {"id":"slash-todo","date":"2026-10-20T10:00:00Z","since":"0.3.1","title":"/todo adds Action Tracker items, /todos moves them","text":"/todo <text> adds an item and confirms with its id. /todos lists every item by column as td1 (to do), do1 (doing), dn1 (done); then /todo move do2 done, /todo td3 doing. From a shell: quox actions add \"<text>\" [--due tomorrow], quox actions move <id> done, quox actions list (every page).","try":"/todos"}
104
+ {"id":"approve-says-who","date":"2026-10-23T12:00:00Z","since":"0.3.1","title":"Approving or denying says who, what and where","text":"After you answer a card, the confirmation now reads: You approved VULCAN-SEC to run a shell command on web01. Denials use the same shape. Anything the card did not say is left out.","try":"/approvals"}
105
+ {"id":"bar-approvals","date":"2026-10-23T12:00:00Z","since":"0.3.1","title":"The line above the input says what is waiting","text":"The divider above the input box now always shows your approvals: ✔ nothing waiting, ⏸ 5 approvals waiting, or approvals ? when the inbox cannot be read or you are signed out (never a false zero). On narrow windows the model name goes first, the approvals note stays whole.","try":"/approvals"}
106
+ {"id":"done-cmd","date":"2026-10-23T14:00:00Z","since":"0.3.1","title":"/done marks Action Tracker items done","text":"Type /done td1,td2 (ids from /todos) to mark several items done at once; /todo move td1,td2 done takes lists too.","try":"/done"}
107
+ {"id":"server-chip","date":"2026-10-23T20:00:00Z","since":"0.3.1","title":"The status line names the server you are on","text":"If the machine Quox runs on is a registered server, the status line now shows it, for example server dev01 (fleet), and the remote instance when your profile talks to another one.","shell":"quox fleet hosts"}
108
+ {"id":"evidence-bundle","date":"2026-10-23T19:10:00Z","since":"0.3.1","title":"/evidence exports this conversation as a signed bundle","text":"/evidence writes every envelope, WARD receipt and VOLT run of this conversation to ~/quox-evidence/ as a signed zip and prints how to check it offline (quox evidence verify <file>). /proof now lists the envelope ids of the conversation, newest first. /ward, /envelope, /envelopes, /aee and /volt point at the same evidence.","try":"/evidence"}
109
+ {"id":"server-go","date":"2026-10-23T21:00:00Z","since":"0.3.1","title":"Open a Quox window on any dev server","text":"quox server list shows your registered dev servers and which are reachable; quox server go <name> opens Quox on one. The status line now shows the server purpose (for example main dev). /server lists them in the app.","try":"/server"}
110
+ {"id":"dragon-companion","date":"2026-10-23T21:00:00Z","since":"0.3.1","title":"Dragon companions: your attached agent's dragon greets you","text":"Dragon companions: your attached agent's dragon greets you. /dragon companion on|greeting|off.","try":"/dragon companion status"}
111
+ {"id":"stop-cmd","date":"2026-10-23T21:00:00Z","since":"0.3.1","title":"/stop and /cancel stop the running turn","text":"Type /stop (or /cancel) to stop the current turn, same as Esc. /clear now cancels a running turn too, and a stopped turn no longer leaves the status line animating.","try":"/stop"}
112
+ {"id":"approvals-ux","date":"2026-10-23T22:00:00Z","since":"0.3.1","title":"Approvals: readable rows, full content, walkthrough covers them","text":"/approvals rows now show what each approval says (the start of an X post), /approvals N shows the full content, a bare y after a list asks which one instead of going to chat, and /walkthrough walks plain approvals as cards.","try":"/walkthrough"}
113
+ {"id": "dragon-transient", "date": "2026-10-23T23:00:00Z", "since": "0.3.1", "title": "The dragon comes and goes", "text": "The dragon companion no longer stays on screen. It appears when you attach, at key moments (a new approval, a failed turn, a long task finishing) and when you type /dragon, then leaves after a few seconds or on your next keypress.", "try": "/dragon"}
114
+ {"id": "resume-attach-coherent", "date": "2026-10-24T01:00:00Z", "since": "0.3.1", "title": "Resume keeps the right agent", "text": "Resuming a conversation now attaches you to the agent it was with, so the footer and your next turn agree. After a restart, if the saved attachment and conversation differ, you get one warning and a fresh conversation. Resumed titles never show painted screen characters.", "try": "/resume"}
115
+ {"id": "dragon-smooth", "date": "2026-10-24T03:30:00Z", "since": "0.3.1", "title": "Companion dragons animate smoothly, and bigger", "text": "Lucid, Hyperion and Seer now play through frames drawn off the main thread, so they move as smoothly as the Quox dragon and no longer stutter the screen. They are larger, stay for 10 seconds (a keypress in the first 2 seconds will not send them away), every typed /attach greets, and /dragon companion calls the dragon too.", "try": "/dragon"}
116
+ {"id": "say-last-target", "date": "2026-10-24T19:00:00Z", "since": "0.3.1", "title": "/say remembers who you were talking to", "text": "If the first word of a /say is not a slot (a URL, a path, plain text), Quox offers to send the whole message to the slot you last said something to. A URL is never taken as a slot name.", "try": "/say"}
117
+ {"id": "full-envelope-ids", "date": "2026-10-25T08:00:00Z", "since": "0.3.1", "title": "/proof and tool lines show the whole envelope id", "text": "Envelope ids, WARD seqs and bundle paths now wrap instead of being cut off, even in a narrow window. /proof puts the full id on its own line to copy, with the quox aee get and quox evidence verify commands, and the model can quote the id too.", "try": "/proof"}
118
+ {"id": "contacts-from-chat", "date": "2026-10-25T09:00:00Z", "since": "0.3.1", "title": "Add and list contacts by just asking", "text": "Ask Quox to add Sam Lee to your contacts with a number and it does, through the same approval rules as the CLI; ask to list your contacts and it pages through all of them. /contact new now builds a valid quox contacts add command (--mobile and --tel become --phone, multi-word names are quoted) and refuses flags the command does not have. quox contacts list pages with --offset.", "try": "/contact new"}
119
+ {"id": "contacts-send-at", "date": "2026-10-25T09:00:00Z", "since": "0.3.1", "title": "Schedule an SMS for a time, and see why a send failed", "text": "quox contacts send now takes --at (10am, 14:30 or a full ISO time, read in Europe/London unless you pass --tz) and the server delivers it then, even if you close the terminal. The answer states the exact time, and quox contacts scheduled list or cancel manages the queue. A failed send now says why in plain words.", "shell": "quox contacts send <contact> --channel sms --at 10am \"hello\""}
120
+ {"id": "attach-agent-first", "date": "2026-10-25T12:00:00Z", "since": "0.3.1", "title": "/attach seer now picks the agent first", "text": "When a name is both an agent and a slot, /attach <name> attaches the agent and tells you the slot exists. Use /attach slot:<name> for the slot or /attach agent:<name> to be explicit. A name that is only a slot still attaches the slot.", "try": "/attach slot:seer"}
121
+ {"id": "dragons-sentinel-drago", "date": "2026-10-25T19:00:00Z", "since": "0.3.1", "title": "Two new dragons: Sentinel (Warden) and Drago", "text": "Two new dragons: Sentinel (Warden) and Drago. Attach Warden or the security lead and they greet you, wait with you at approvals and show when something needs attention. Sentinel shows its shield icon on smaller terminals.", "try": "/attach sentinel"}
122
+ {"id": "dragons-cipher", "date": "2026-10-25T21:00:00Z", "since": "0.3.1", "title": "New dragon: Cipher", "text": "New dragon: Cipher, the network operations dragon. Attach cipher and it greets you, listens while you work, waits with you at approvals and shows when something needs attention.", "try": "/attach cipher"}
123
+ {"id": "objective-offer", "date": "2026-10-25T22:00:00Z", "since": "0.3.1", "title": "Ask for something your agent cannot do, get an objective", "text": "Ask an attached agent for a multi-step job it has no tools for and Quox now offers (y or n) to make it an objective that runs until done. You approve the plan, then each step.", "try": "/attach seer"}
124
+ {"id": "dragon-jobs", "date": "2026-10-25T23:00:00Z", "since": "0.3.1", "title": "Dragons now report real state", "text": "Each companion dragon has one job and says it from a live read: Sentinel the WARD state of your last answer, Cipher route health, Seer the plan, Hyperion the fleet, Drago open security findings, Lucid recovery. If a read fails it says it could not check.", "try": "/attach cipher"}
125
+ {"id": "dragon-summons", "date": "2026-10-25T23:30:00Z", "since": "0.3.1", "title": "Dragons turn up when your work finishes", "text": "With the dragon companion on, a finished or failed objective or loop calls Seer for a few seconds with the real result, even with no agent attached. Never while you are typing, and each dragon waits a minute before it can be called again.", "try": "/dragon companion status"}
126
+ {"id": "loop-card", "date": "2026-10-26T00:00:00Z", "since": "0.3.1", "title": "Watch a loop as it runs", "text": "A loop you start from chat now shows a live card that updates in place: turns, receipts witnessed, and the last lines of its output. Milestones and errors land as chat lines under it, and a finished loop prints its proof command.", "try": "/loop list"}
127
+ {"id": "loop-offer", "date": "2026-10-26T01:00:00Z", "since": "0.3.1", "title": "Ask for a loop and Quox offers one", "text": "Say \"keep checking until it is green\" or \"run it as a loop\" and Quox offers to create a loop with a tool list worked out from your goal (read-only unless the goal writes a file or runs a command). You see exactly which tools it may use and approve it before it starts. A fixed cadence like \"every 15 minutes\" is offered as a schedule instead.", "try": "/loop list"}
128
+ {"id": "dragon-fit", "date": "2026-10-26T01:00:00Z", "since": "0.3.1", "title": "Dragons fit your window, or tell you why not", "text": "/dragon fire and /dragon-fly now size to your real window and say how many rows they need when it is too small. /detach brings the Q dragon back to greet you when dragons are on.", "try": "/dragon-fly"}
129
+ {"id":"restart-waits-server","date":"2026-10-26T02:10:00Z","title":"Auto restart waits for Quox to come back","text":"When a new version lands while the Quox server is restarting, the window now waits for the server before it restarts, and picks its slot back up once the server answers.","try":"/restart","since":"0.3.1"}
130
+ {"id":"keep-going-plain-words","date":"2026-10-26T04:00:00Z","title":"Ask for a watch in plain words","text":"Say \"keep an eye on quox.ai for the next 10 minutes\" or \"watch the build until it is green\" and Quox offers to keep going for you, no need to say loop. One key to start it, one to just answer once.","try":"/watch quox.ai for downtime","since":"0.3.1"}
131
+ {"id": "objective-offer-reports-back", "date": "2026-10-26T05:30:00Z", "title": "Objectives report back in the chat", "text": "Say a long multi-step ask to an advisory agent and Quox offers to run it as an objective. The plan routes each step to an agent that holds the tool, a fallback like \"if something goes wrong, save the draft\" is honoured, and when it ends the chat tells you what happened and where things are.", "try": "/attach seer", "since": "0.3.1"}
132
+ {"id": "loop-card-polish", "date": "2026-10-26T13:00:00Z", "since": "0.3.1", "title": "The loop card keeps its own count", "text": "The card now counts the loop's own turns (turn 2 of 5, not the message count), shows the whole runway, fills a receipts bar as each one is witnessed, and says a milestone once. With an empty composer, press Down to reach its keys. A finished loop calls its dragon.", "try": "/loop list"}
133
+ {"id": "org-picker", "date": "2026-10-26T16:30:00Z", "since": "0.3.1", "title": "Switch org in one keystroke", "text": "Type /org for a list of your orgs, arrow to one and press Enter: this window moves there at once and the status bar follows. Press d instead to also make it your default. /org hampshire switches straight away when only one org matches, and quox org switch with no argument gives a numbered list.", "try": "/org"}
134
+ {"id":"quoxcli-0-4-0","date":"2026-10-26T18:00:00Z","since":"0.4.0","title":"quoxon extensions, an inbox bar, history and mouse editing","text":"Add and manage extensions with quox ext add, quox ext remove and quox ext list, or /ext in the window. /inbox and a notification bar show what is waiting. Up and Ctrl+R search your history. /mouse turns on optional mouse editing. Plan approval cards now list their steps, and live loop cards no longer freeze the window.","try":"/ext","shell":"quox ext list"}
135
+ {"id": "dragons-vulcan-quoxperticus", "date": "2026-10-27T09:00:00Z", "since": "0.3.1", "title": "Two new dragons: Vulcan and Quoxperticus", "text": "Two new dragons: Vulcan, the forge dragon, and Quoxperticus, the fleet conductor. Attach either agent and the dragon greets you, listens while you work, waits with you at approvals and shows when something needs attention.", "try": "/attach vulcan"}
136
+ {"id": "quoxon-2026-10-27-honest-window", "date": "2026-10-27T12:30:00Z", "since": "0.4.0", "title": "A calmer, more honest window", "text": "When your model hits a usage limit, the window offers a working one (y/N). Auto-restart waits at least 30 minutes between restarts and only when you are idle; inside that time a new version shows a quiet line and Enter restarts. The status bar no longer says nothing is waiting while items are open or a source can't be read. /say and /observe reach your slots in your other orgs (name@org picks one). /approvals opens faster, and a refused loop shows why instead of fake progress.", "try": "/approvals"}
137
+ {"id": "quoxon-2026-10-27-delete-confirm", "date": "2026-10-27T16:30:00Z", "since": "0.4.0", "title": "Deletes need the name typed", "text": "An approval that deletes something (delete_*, remove_*, purge_*, destroy_*, drop_*, wipe_*) now leads with DELETES and the object, and a single y no longer approves it: y asks you to type the name, Esc or n goes back, n on the card still denies. Bulk approve (/yestoall, /approve all) skips deletes and tells you how many are left to confirm one by one.", "try": "/approvals"}
138
+ {"id": "quoxon-2026-10-28-approval-burst", "date": "2026-10-28T00:30:00Z", "since": "0.4.0", "title": "A burst of approvals is one card", "text": "When one agent asks to run the same tool several times within 5 minutes (say nine wire_alerts), you get ONE card: 'VIGIL wants to run wire_alerts on 9 items'. y approves all, n denies all, p walks them one by one, Tab puts it away. Deletes and risky asks are never in the batch; they keep their own card and confirm. The inbox arrival line collapses the same way.", "try": "/approvals"}
139
+ {"id": "quoxon-2026-10-28-burst-across-polls", "date": "2026-10-28T04:00:00Z", "since": "0.4.0", "title": "Pack installs on many hosts are one card", "text": "DevSuite pack installs filed a few seconds apart for different hosts used to arrive as one card each. They now merge into ONE card (y approves all, n denies all, p picks), and the card says who asked in plain words ('DevSuite (skill-pack sync)') instead of an internal key. Restart, deploy and other disruptive asks still never batch.", "try": "/approvals"}
140
+ {"id": "quoxon-2026-10-28-afk-quiet-cards", "date": "2026-10-28T06:45:00Z", "since": "0.4.0", "title": "Away means quiet", "text": "After /afk or /sleep, new approvals no longer open a card or arm y/n (a stray key cannot answer one). They are only counted: \"⏸ N approvals waiting · /back to review\". /back, or any message you type, opens them as before. Nothing is ever answered for you."}
141
+ {"id": "quoxon-2026-10-28-agent-profile-offer", "date": "2026-10-28T15:00:00Z", "since": "0.4.0", "title": "Opened as an agent? Quox offers your own login", "text": "Starting quox on an agent profile while you have a signed-in human profile now asks once (y/N) to restart as you. Typing \"attach to hitl slot\" offers /slot hitl instead of going to the model."}
142
+ {"id": "quoxon-2026-10-28-secret-paste-guard", "date": "2026-10-28T17:00:00Z", "since": "0.4.0", "title": "Pasted credentials are caught", "text": "A message that looks like bank details, a PIN, a password, a card number or an API key now asks \"Don't send it?\" (y keeps it out, n sends anyway). Nothing is sent or saved on y, and history and transcripts store [REDACTED:kind] in place of the value either way."}
143
+ {"id": "quoxon-2026-10-28-do-not-disturb", "date": "2026-10-28T18:00:00Z", "since": "0.4.0", "title": "Sleep means sleep: /donotdisturb and /morningreport", "text": "/donotdisturb (or /dnd, optionally until 07:30) collects everything quietly: no notices, approval cards, bells or dragon. The status line shows \"🌙 do not disturb · N waiting · /morningreport\". /morningreport groups what came in (incidents first, repeats as ×N) and offers to turn it off. /afk and /sleep imply it, saying you are going to sleep offers it, and a restarted window restores it."}
144
+ {"id": "quoxon-2026-10-29-provider-test-json", "date": "2026-10-29T12:00:00Z", "since": "0.4.0", "title": "quox provider test --json says why", "text": "quox provider test --json now prints every provider's result (ok, error, reason code) and a summary, instead of nothing, and still exits 1 when one fails."}
145
+ {"id": "quoxon-2026-10-29-connect-server-install", "date": "2026-10-29T13:00:00Z", "since": "0.4.0", "title": "quox connect and quox server install", "text": "quox connect links this machine to a Quox server. quox server install sets up a new server on this machine: checks, download, start, owner account and a one-time sign-in link. Try quox server install --dry-run first. The sign-in link is not printed into piped or --json output unless you pass --print-login-link.", "shell": "quox server install --dry-run", "sha": "34a547724"}
146
+ {"id": "quoxon-2026-10-29-morningreport-fallback", "date": "2026-10-29T14:00:00Z", "since": "0.4.0", "title": "/morningreport shows the digest when nothing was held", "text": "With do not disturb off, /morningreport now says nothing was held and shows the same While you were away digest as /away, so you no longer need a second command.", "sha": "c4f2a3c90"}
147
+ {"id": "quoxon-2026-10-29-never-answered-offer", "date": "2026-10-29T15:00:00Z", "since": "0.4.0", "title": "A server that never answered now offers to connect elsewhere", "text": "If this machine has never reached the server a profile points at, Quox now asks whether to connect to a different one instead of waiting on a restart that is not happening. Say y, type the address, and it checks the server, shows its certificate and signs you in."}
148
+ {"id": "quoxon-2026-10-29-approval-outcome", "date": "2026-10-29T16:00:00Z", "since": "0.4.0", "title": "You now see whether an approved action worked", "text": "After you approve a request from another session, Quox follows up with one line saying whether it finished or failed and why. The failed-to-execute notice now carries the approval id, so quox inbox show <id> opens it."}
149
+ {"id": "quoxon-2026-10-29-deck-lazy-offer", "date": "2026-10-29T17:00:00Z", "since": "0.4.0", "title": "/deck now offers to install what it needs", "text": "If quoxdeck is missing, or its audio headers (libasound2-dev) are, /deck on asks once whether to install them, shows the exact commands, and runs them with your own sudo prompt. Say n and it prints the commands to run later."}
150
+ {"id":"quoxon-2026-10-29-connect-no-terminal","date":"2026-10-29T08:50:36Z","since":"0.4.0","title":"quox connect can run with no terminal","text":"quox connect now takes --email, --password-stdin and --fingerprint <sha256>, so a script or chat can connect and sign in with no prompts. quox server status shows the certificate fingerprint to compare, a certificate Quox does not trust now says so in plain words with the fix, and the same email on a different server no longer trips the same-user warning."}
151
+ {"id":"quoxcli-2026-10-29-tls-trust-hint","date":"2026-10-29T09:26:52Z","since":"0.4.0","title":"A certificate Quox does not trust now says so on login","text":"If a server uses a certificate this machine does not trust yet, quox login now says so in plain words and prints the exact quox connect command to trust it (it shows the fingerprint and asks y/N; --trust-cert or --fingerprint for no terminal), instead of just \"fetch failed\"."}
152
+ {"id":"quoxon-2026-10-29-gripe-queue","date":"2026-10-29T09:46:49Z","since":"0.4.0","title":"/gripe no longer loses a gripe when Quox is slow","text":"If /gripe! times out or the server errors, the gripe is saved on your machine and filed when Quox answers again, without making a duplicate issue if the first try had actually gone through. /gripes shows what is waiting and what was filed."}
153
+ {"id":"quoxon-2026-10-29-gripe-paste-lines","date":"2026-10-29T10:10:00Z","since":"0.4.0","title":"/gripe keeps the lines of what you paste","text":"A multi-line paste into /gripe now reaches the issue with its lines intact instead of one run-on line, and the confirm says how many more lines will be filed."}
154
+ {"id":"quoxon-2026-10-29-advice-independent","date":"2026-10-29T11:00:00Z","since":"0.4.0","title":"Test-edit approvals no longer echo the asker as advice","text":"On a test-edit approval card, ADVICE now cites the diff when it is known, or says there is no independent view, instead of repeating the asker's own recommendation; the starred recommended-by line stays."}
155
+ {"id":"quoxon-2026-10-29-resume-no-duplicate","date":"2026-10-29T12:00:00Z","since":"0.4.0","title":"quox claude --resume no longer starts a second copy","text":"Resuming a named session now finds its tmux session whatever the capitals and attaches to it, and refuses with the pid and the attach command when that conversation is already running anywhere. --force starts a second copy on purpose."}
156
+ {"id": "quoxon-2026-10-29-sleep-one-step", "date": "2026-10-29T09:00:00Z", "since": "0.4.0", "title": "/sleep is one step", "text": "/sleep (or /afk going to bed) sets you asleep with do not disturb on in one command. A bare /dnd now only ever turns it on; /dnd off ends it. Plain /afk offers do not disturb with one y/N."}
157
+ {"id": "quoxon-2026-10-29-roster-sessions", "date": "2026-10-29T17:30:00Z", "since": "0.4.0", "title": "/roster shows attached agents and duplicate sessions", "text": "/roster now lists the live agent attaches in your org and every claude --resume process on this box, grouped by name. A conversation running more than once is flagged with each pid. The same attach list is quox agent attaches."}
158
+ {"id": "quoxon-2026-10-29-announce-everything", "date": "2026-10-29T20:00:00Z", "since": "0.4.0", "title": "Quox announces what it does", "text": "Memory saves, approvals, slot messages, connect and vault create now print one Quox line saying what happened, success or failure, and the chat transcript shows the same line for what a turn did (a saved memory, an approval, a delegation). Silent under --json and --quiet.", "shell": "quox memory create --type fact --content hello"}
159
+ {"id": "quoxon-2026-10-29-walkthrough-command", "date": "2026-10-29T22:30:00Z", "since": "0.4.0", "title": "The walkthrough shows the command", "text": "Each approval card in the walkthrough now shows the exact command in full, never cut off, and the host it runs on. When the reason was guessed from the command rather than stated by the asker, it is labelled so.", "try": "/walkthrough"}
160
+ {"id": "quoxon-2026-10-29-chat-quox-lines", "date": "2026-10-29T22:31:00Z", "since": "0.4.0", "title": "quox chat prints what Quox did", "text": "quox chat, streaming or --no-stream, now prints one Quox line on stderr for what a turn did (a saved memory, an approval, a delegation). Silent under --json and --quiet.", "shell": "quox chat --no-stream \"remember that the proof tag is QA-1036\""}
161
+ {"id": "quoxon-2026-10-30-inbox-unread-quiet-noreply", "date": "2026-10-30T07:00:00Z", "since": "0.4.0", "title": "Undo a read, quieter no-reply, honest timeouts", "text": "/inbox unread N puts an item back as unread. The no reply from a slot notice is dim and skipped when that slot spoke in the last 10 minutes. An approved action whose result timed out is titled no result in time (it may still have run) instead of FAILED.", "try": "/inbox unread 1"}
162
+ {"id": "quoxon-2026-10-30-reattach-deploy-line", "date": "2026-10-30T08:00:00Z", "since": "0.4.0", "title": "A slot reattach during a deploy says so", "text": "When QuoxCORE is being redeployed, a slow slot reattach now says it is restarting and reconnecting, instead of suggesting a stale URL or QUOX_COLLECTOR_URL. A URL that never answered still gets the full diagnostic.", "try": "/slot"}
163
+ {"id":"quoxon-2026-10-30-inbox-unread-cli","date":"2026-10-30T08:27:43Z","since":"0.4.0","title":"Mark unread from a shell, read items whole","text":"quox inbox unread <id> undoes a mark-as-read from a shell, and /inbox unread all flips back every read item. /inbox N now shows the full body of every item (it missed the stored body before), the sender and subject for mail, and the actions that apply.","try":"/inbox 1","shell":"quox inbox unread <id>"}
164
+ {"id":"quoxon-2026-10-30-presence-owner-level","date":"2026-10-30T08:56:21Z","since":"0.4.0","title":"Good morning waits for you; /back agrees across windows","text":"LUCID no longer says good morning at 08:00 while you are asleep or away: it waits until you are back. If another window already marked you present, /back reports that same away span instead of its own.","try":"/back"}
165
+ {"id":"quoxon-2026-10-30-tui-mail","date":"2026-10-30T09:30:00Z","since":"0.4.0","title":"Read your email in the terminal: /mail","text":"/mail lists your newest triaged email (sender, subject, age, zone) and /mail N opens one with its body excerpt and why it was triaged that way. Read only: nothing is sent, moved or deleted. With no mailbox connected it names the connect command. /inbox now points at /mail.","try":"/mail"}
166
+ {"id":"quoxon-2026-10-30-dnd-survives-restart","date":"2026-10-30T10:40:24Z","since":"0.4.0","title":"Do not disturb now survives restarts","text":"Do not disturb is saved on the server for you, not just in one window, so an auto-restart no longer drops it, even when the server is slow to answer at that moment: the window keeps retrying until it can read it.","try":"/dnd"}
167
+ {"id":"quoxon-2026-10-30-reply-address","date":"2026-10-30T11:00:00Z","since":"0.4.0","title":"Messages from slot holders now show their reply address","text":"A message from a session that holds a slot no longer says (no reply address): the TUI looks up the slot the sender holds, so Tab or /r replies to it. It says so only when the sender truly holds no slot."}
168
+ {"id":"quoxon-2026-10-30-approval-row-background","date":"2026-10-30T12:00:00Z","since":"0.4.0","title":"Approval rows flag background work","text":"/approvals rows now say when a command runs in the background, including screen, tmux and docker detached runs."}
169
+ {"id":"quoxon-2026-10-30-confirm-target-once","date":"2026-10-30T22:00:00Z","since":"0.4.0","title":"Approval confirmations name the hosts once","text":"The line after you approve a multi-host command no longer repeats the host list twice."}
170
+ {"id":"quoxon-2026-10-30-away-retry","date":"2026-10-30T22:10:00Z","since":"0.4.0","title":"While you were away retries a slow source","text":"If a source times out right after sign-in, the digest asks it once more before it says it cannot see it."}
@@ -97,7 +97,17 @@ export function createWindowOrg({ auth, baseClient, now = () => Date.now() }) {
97
97
  */
98
98
  export function createWindowClient({ config, auth, createClient }) {
99
99
  const windowOrg = createWindowOrg({ auth, baseClient: createClient(config, auth) });
100
- return { client: createClient(config, windowOrg.auth), auth: windowOrg.auth, windowOrg };
100
+ // A client on the SAME user's token for another org, without moving this window (cached; each has its own overlay).
101
+ const scoped = new Map();
102
+ const clientForOrg = async (orgId) => {
103
+ if (scoped.has(orgId)) return scoped.get(orgId);
104
+ let cl;
105
+ if (orgId === (() => { try { return auth.getOrgId(); } catch { return null; } })()) cl = createClient(config, auth);
106
+ else { const sub = createWindowOrg({ auth, baseClient: createClient(config, auth) }); await sub.switchTo(orgId); cl = createClient(config, sub.auth); }
107
+ scoped.set(orgId, cl);
108
+ return cl;
109
+ };
110
+ return { client: createClient(config, windowOrg.auth), auth: windowOrg.auth, windowOrg, clientForOrg };
101
111
  }
102
112
 
103
113
  /**
@@ -0,0 +1,137 @@
1
+ // DEVFLEET: the dev-server list behind `quox server list|go|mark` and the TUI /server picker.
2
+ // A dev server is a Bastion host (`quox fleet hosts`) that has a local entry in dev-servers.json
3
+ // (purpose, and optionally project, repo, workdir, ci, user, jump). Bastion supplies address + state;
4
+ // the entry supplies what the server is FOR. Pure functions here; the file read/write is the only IO.
5
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
6
+ import { homedir, hostname } from 'node:os';
7
+ import { join, dirname } from 'node:path';
8
+ import { matchServer, localIps } from '../quoxon/serverChip.js';
9
+ import { resolveEndpoint } from '../api/endpoints.js';
10
+
11
+ const FIELDS = ['purpose', 'project', 'repo', 'workdir', 'ci', 'user', 'jump', 'lastDeploy'];
12
+ const SAFE = /^[A-Za-z0-9._@:\/~+-]+$/;
13
+
14
+ export function registryPath(env = process.env) {
15
+ return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'quox', 'dev-servers.json');
16
+ }
17
+
18
+ /** -> { servers: { [name]: entry } }; a missing file is empty, a corrupt one throws (never clobbered). */
19
+ export function readRegistry(path = registryPath()) {
20
+ let raw;
21
+ try { raw = readFileSync(path, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return { servers: {} }; throw e; }
22
+ const j = JSON.parse(raw);
23
+ return { servers: j && typeof j.servers === 'object' && j.servers ? j.servers : {}, defaults: j && typeof j.defaults === 'object' && j.defaults ? j.defaults : {} };
24
+ }
25
+
26
+ /** Additive upsert of one server's entry; unspecified fields are kept. Atomic write. */
27
+ export function markServer(name, fields, path = registryPath()) {
28
+ if (!name || !SAFE.test(name)) throw new Error(`invalid server name: ${name}`);
29
+ const reg = readRegistry(path);
30
+ const next = { ...(reg.servers[name] || {}) };
31
+ for (const k of FIELDS) if (fields[k] != null && fields[k] !== '') next[k] = String(fields[k]);
32
+ reg.servers[name] = next;
33
+ mkdirSync(dirname(path), { recursive: true });
34
+ const tmp = `${path}.${process.pid}.tmp`;
35
+ writeFileSync(tmp, JSON.stringify(reg, null, 2) + '\n', { mode: 0o600 });
36
+ renameSync(tmp, path);
37
+ return next;
38
+ }
39
+
40
+ function writeCache(reg, path) {
41
+ mkdirSync(dirname(path), { recursive: true });
42
+ const tmp = `${path}.${process.pid}.tmp`;
43
+ writeFileSync(tmp, JSON.stringify(reg, null, 2) + '\n', { mode: 0o600 });
44
+ renameSync(tmp, path);
45
+ }
46
+
47
+ const pickFields = (e) => Object.fromEntries(FIELDS.filter(k => e && e[k] != null && e[k] !== '').map(k => [k, String(e[k])]));
48
+
49
+ /**
50
+ * The registry lives SERVER-SIDE (org-scoped, collector); the local file is an offline cache only.
51
+ * -> { servers, source: 'server' | 'cache', migrated }. A good server read refreshes the cache. If the server
52
+ * has no entries and the cache has some, they are uploaded once (migration). Unreachable or a non-registry
53
+ * answer -> the cache (a corrupt cache throws, never clobbered).
54
+ */
55
+ export async function loadRegistry({ client, path = registryPath() } = {}) {
56
+ let remote = null;
57
+ let defaults = {}; // local-only preferences (e.g. defaults.jump, the ssh ProxyJump alias): never uploaded, kept across cache refreshes
58
+ try { defaults = readRegistry(path).defaults; } catch { /* corrupt cache: no local defaults */ }
59
+ try {
60
+ const ep = resolveEndpoint('devServerRegistry.list');
61
+ const d = await client.get(ep.path, { service: ep.service, retries: 0, timeout: 5000 });
62
+ if (d && typeof d.servers === 'object' && d.servers && !Array.isArray(d.servers)) remote = d.servers;
63
+ } catch { /* offline: fall back to the cache */ }
64
+ if (!remote) return { servers: readRegistry(path).servers, defaults, source: 'cache', migrated: 0 };
65
+ let migrated = 0;
66
+ let intact = true;
67
+ if (!Object.keys(remote).length) {
68
+ let local = {};
69
+ try { local = readRegistry(path).servers; } catch { /* unreadable cache: nothing to migrate */ }
70
+ for (const [name, e] of Object.entries(local)) {
71
+ try {
72
+ const ep = resolveEndpoint('devServerRegistry.put', { name });
73
+ await client.put(ep.path, { service: ep.service, body: pickFields(e) });
74
+ remote[name] = pickFields(e); migrated++;
75
+ } catch { intact = false; break; /* the cache keeps what did not upload; the next read retries */ }
76
+ }
77
+ // A failed upload must never be followed by a cache overwrite: the cache is then the only copy.
78
+ if (!intact) return { servers: { ...local, ...remote }, defaults, source: 'cache', migrated };
79
+ }
80
+ try { writeCache({ servers: remote, defaults }, path); } catch { /* cache is best effort */ }
81
+ return { servers: remote, defaults, source: 'server', migrated };
82
+ }
83
+
84
+ /** Upsert one entry on the server (additive), then refresh the cache. Offline -> throws, nothing written. */
85
+ export async function saveServer({ client, name, fields, path = registryPath() }) {
86
+ if (!name || !SAFE.test(name)) throw new Error(`invalid server name: ${name}`);
87
+ const ep = resolveEndpoint('devServerRegistry.put', { name });
88
+ let r;
89
+ try { r = await client.put(ep.path, { service: ep.service, body: pickFields(fields) }); }
90
+ catch (e) { throw new Error(`could not save to the Quox server (${e?.message || 'failed'}); nothing was recorded`); }
91
+ let entry = r && r.entry && typeof r.entry === 'object' ? pickFields(r.entry) : null;
92
+ if (!entry) { try { entry = markServer(name, fields, path); } catch { entry = pickFields(fields); } }
93
+ else { try { const reg = readRegistry(path); reg.servers[name] = entry; writeCache(reg, path); } catch { /* cache is best effort */ } }
94
+ return entry;
95
+ }
96
+
97
+ /** Bastion host list + registry -> rows for each registered server. `here` = { hostname, ips }. */
98
+ export function buildRows(hosts, registry, here = {}) {
99
+ const list = Array.isArray(hosts) ? hosts : [];
100
+ const me = matchServer(list, here);
101
+ return Object.entries(registry?.servers || {}).map(([name, e]) => {
102
+ const h = list.find(x => x && x.id === name) || null;
103
+ return {
104
+ name, purpose: e.purpose || null, project: e.project || null, repo: e.repo || null, workdir: e.workdir || null,
105
+ ci: e.ci || null, lastDeploy: e.lastDeploy || null,
106
+ ip: h?.ip || null,
107
+ registered: Boolean(h),
108
+ reachable: h ? (h.status?.state ?? h.state) === 'active' : false,
109
+ current: Boolean(me && me.name === name),
110
+ user: e.user || null, jump: e.jump || null, defaultJump: registry?.defaults?.jump || null
111
+ };
112
+ });
113
+ }
114
+
115
+ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
116
+
117
+ /**
118
+ * What `go` will run. -> { error } | { local: true, cmd, args, cwd, text } | { cmd:'ssh', args, text }.
119
+ * Remote: the user's own ssh config/keys via the jump host alias; nothing is copied anywhere.
120
+ */
121
+ export function planGo(row) {
122
+ if (!row) return { error: 'unknown server' };
123
+ if (!row.registered) return { error: `${row.name} is not in the Bastion host registry (quox fleet hosts), so go cannot reach it` };
124
+ if (!row.reachable) return { error: `${row.name} is not reachable according to the Bastion registry; try: quox fleet ping ${row.name}` };
125
+ if (row.current) {
126
+ const cwd = row.workdir || process.cwd();
127
+ return { local: true, cmd: 'quox', args: [], cwd, text: `cd ${cwd} && quox (this is ${row.name}; local)` };
128
+ }
129
+ if (!row.ip) return { error: `${row.name} has no address in the Bastion registry` };
130
+ const target = `${row.user || 'control'}@${row.ip}`;
131
+ const remote = `${row.workdir ? `cd ${shq(row.workdir)} && ` : ''}exec quox`;
132
+ const jump = row.jump || row.defaultJump || null; // a per-server --jump, else the registry's defaults.jump, else a direct ssh
133
+ const args = ['-t', ...(jump ? ['-o', `ProxyJump=${jump}`] : []), target, remote];
134
+ return { cmd: 'ssh', args, text: `ssh ${args.slice(0, 4).join(' ')} ${shq(remote)}` };
135
+ }
136
+
137
+ export const hereIdentity = () => ({ hostname: hostname(), ips: localIps() });