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,318 @@
1
+ /**
2
+ * Loop commands (QLOOP-LIVE): observe a QLoop loop as it runs.
3
+ *
4
+ * quox loop watch <id> [--spool|--quiet] [--since n] live, one QuoxGlyph line per event
5
+ * quox loop status <id> snapshot
6
+ * quox loop list running + recent loops (org)
7
+ *
8
+ * `watch` reconnects with ?since=<last event_seq> when the stream drops, and exits with the loop's
9
+ * outcome: 0 completed, 1 failed, 2 halted, 3 interrupted, 4 connection lost for good.
10
+ * Events come from GET /api/v1/loops/:id/events (design: ~/quoxon-work/qloop-live-design.md).
11
+ */
12
+
13
+ import { formatOutput, getOutputFormat } from '../core/output.js';
14
+ import { resolveEndpoint } from '../api/endpoints.js';
15
+ import { loopLine, renderEvent, exitCodeFor, elapsedLabel } from '../core/loopGlyph.js';
16
+ import { QuoxApiError, QuoxAuthError, QuoxUserError } from '../core/errors.js';
17
+
18
+ const MAX_SILENT_RETRIES = 10;
19
+ const BACKOFF_BASE_MS = 1000;
20
+ const BACKOFF_CAP_MS = 10000;
21
+
22
+ const sleepReal = (ms) => new Promise((r) => setTimeout(r, ms));
23
+
24
+ /** SQLite "YYYY-MM-DD HH:MM:SS" (UTC, no zone) or ISO to epoch ms; null if unreadable. */
25
+ function toMs(ts) {
26
+ if (!ts) return null;
27
+ const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts) ? `${ts.replace(' ', 'T')}Z` : ts;
28
+ const n = Date.parse(iso);
29
+ return Number.isNaN(n) ? null : n;
30
+ }
31
+
32
+ const LIVE_STATES = new Set(['pending', 'starting', 'decomposing', 'running', 'waiting']);
33
+
34
+ /**
35
+ * One row, one vocabulary. The list route counts turn events, which loops driven by the runner never write, so a
36
+ * finished loop could read 0 there. The server now sends a real 0 (started, ran nothing) and null (no record at
37
+ * all); a number from the server is kept, null stays unknown unless the loop is live. objective_status is the objective row's word ('cancelled' for a
38
+ * human halt); `state` is the loop's, so the row says one thing and the raw word is kept as objective_row_status.
39
+ */
40
+ export function normLoop(l) {
41
+ const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : 0);
42
+ const count = n(l.turn) || n(l.iteration_count);
43
+ // the server's turn is authoritative: a real 0 (started, ran nothing) stays 0; only null/absent means unknown
44
+ const turn = count || (l.turn === 0 || LIVE_STATES.has(l.state) ? 0 : null);
45
+ const out = { ...l, turn };
46
+ if (l.objective_status !== undefined && l.state && l.objective_status !== l.state) {
47
+ out.objective_row_status = l.objective_status;
48
+ out.objective_status = l.state;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function turnLabel(l) {
54
+ if (l.turn === null) return '-';
55
+ return l.max_turns ? `${l.turn}/${l.max_turns}` : String(l.turn);
56
+ }
57
+
58
+ const SETTLE_MAX_MS = 90000;
59
+ const SETTLE_POLL_MS = 4000;
60
+
61
+ /**
62
+ * After loop.finished: wait (bounded) for the loop's receipts to be WITNESSED by WARD, asking the snapshot
63
+ * (which re-checks WARD and records what it finds), printing each receipt as it flips. Returns the finished
64
+ * event with the final witnessed count. A snapshot that cannot be read ends the wait and says so; it never
65
+ * reads as "0 witnessed".
66
+ */
67
+ async function settleReceipts({ client, id, finished, write, sleep, ctx, maxMs, signal, seen = new Set() }) {
68
+ const ep = resolveEndpoint('loops.get', { id: encodeURIComponent(id) });
69
+ let witnessed = finished.witnessed;
70
+ write(loopLine('receipts', `${witnessed}/${finished.receipts} witnessed, waiting for WARD to sign the covering tip`, 'busy'));
71
+ let waited = 0;
72
+ while (witnessed < finished.receipts && waited < maxMs && !(signal && signal.aborted)) {
73
+ await sleep(SETTLE_POLL_MS);
74
+ waited += SETTLE_POLL_MS;
75
+ let snap;
76
+ try { snap = await client.get(ep.path, { service: ep.service }); } catch (err) {
77
+ write(loopLine('receipts', `could not re-check WARD (${oneLineErr(err)}); \`quox loop status ${id}\` will`, 'warn'));
78
+ break;
79
+ }
80
+ const items = (snap && snap.receipts && snap.receipts.items) || [];
81
+ for (const r of items) {
82
+ if (r.status === 'WITNESSED' && !seen.has(r.envelope_id)) {
83
+ seen.add(r.envelope_id);
84
+ write(loopLine('receipt', `${String(r.envelope_id)} WITNESSED`, 'ok'));
85
+ }
86
+ }
87
+ witnessed = snap && snap.receipts ? snap.receipts.witnessed : witnessed;
88
+ }
89
+ if (witnessed < finished.receipts) write(loopLine('receipts', `${finished.receipts - witnessed} still pending after ${Math.round(waited / 1000)}s; they are witnessed when the next tip signs (quox loop status ${id})`, 'warn'));
90
+ return { ...finished, witnessed };
91
+ }
92
+
93
+ /**
94
+ * Follow one loop to its end. Returns the process exit code. Exported for tests.
95
+ * @param {object} p
96
+ * @param {object} p.client cli client (stream)
97
+ * @param {string} p.id
98
+ * @param {object} p.snapshot loops.get result (title, max_turns, started_at)
99
+ * @param {{spool?: boolean, quiet?: boolean, since?: number, json?: boolean, noSettle?: boolean, settleMs?: number}} p.opts
100
+ * @param {(line: string) => void} p.write
101
+ * @param {(ms: number) => Promise<void>} [p.sleep]
102
+ * @param {AbortSignal} [p.signal]
103
+ */
104
+ export async function watchLoop({ client, id, snapshot, opts = {}, write, sleep = sleepReal, signal }) {
105
+ const ep = resolveEndpoint('loops.events', { id: encodeURIComponent(id) });
106
+ let lastSeq = Number(opts.since) || 0;
107
+ let startedMs = toMs(snapshot && snapshot.started_at);
108
+ const ctx = { title: snapshot && snapshot.title, maxTurns: snapshot && snapshot.max_turns, startedMs, spool: !!opts.spool };
109
+ const showQuiet = new Set(['loop.started', 'loop.observe', 'loop.approval', 'loop.finished']);
110
+ let failures = 0;
111
+ let backoff = BACKOFF_BASE_MS;
112
+ const witnessedShown = new Set(); // receipts already printed from loop.ward events, so the settle phase does not repeat them
113
+
114
+ while (!signal || !signal.aborted) {
115
+ let gotEvent = false;
116
+ let ended = null;
117
+ try {
118
+ for await (const ev of client.stream(ep.path, { service: ep.service, query: { since: lastSeq }, signal })) {
119
+ // `event: end` frame (an old loop that never logged a finish): no type, a reason
120
+ if (ev && !ev.type && ev.reason) { ended = ev; break; }
121
+ if (!ev || typeof ev.type !== 'string' || !ev.type.startsWith('loop.')) continue;
122
+ gotEvent = true;
123
+ if (typeof ev.event_seq === 'number') {
124
+ if (ev.event_seq <= lastSeq) continue; // replay overlap after a reconnect
125
+ lastSeq = ev.event_seq;
126
+ }
127
+ if (ev.type === 'loop.ward' && ev.status === 'WITNESSED') witnessedShown.add(ev.envelope_id);
128
+ if (!ctx.startedMs && ev.type === 'loop.started') ctx.startedMs = Date.parse(ev.at);
129
+ if (opts.json) write(JSON.stringify(ev));
130
+ else if (ev.type !== 'loop.finished' && !(opts.quiet && !showQuiet.has(ev.type))) for (const l of renderEvent(ev, ctx)) write(l);
131
+ if (ev.type === 'loop.finished') {
132
+ // the loop is over; its receipts are WITNESSED when WARD's next signed tip covers them (about a minute),
133
+ // so say so and follow them in rather than printing a final count that is about to be stale
134
+ const last = (!opts.json && !opts.noSettle && ev.receipts > ev.witnessed)
135
+ ? await settleReceipts({ client, id, finished: ev, write, sleep, ctx, maxMs: opts.settleMs ?? SETTLE_MAX_MS, signal, seen: witnessedShown })
136
+ : ev;
137
+ if (!opts.json) for (const l of renderEvent(last, ctx).slice(-1)) write(l);
138
+ if (!opts.json && !opts.quiet) write(` quox loop status ${id} # receipts and proof`);
139
+ return exitCodeFor(ev.state);
140
+ }
141
+ }
142
+ } catch (err) {
143
+ // a refusal is not a dropped connection: 401/403/404 and friends end the watch now with the server's words
144
+ if (err instanceof QuoxAuthError || (err instanceof QuoxApiError && err.status >= 400 && err.status < 500 && err.status !== 429)) throw err;
145
+ if (signal && signal.aborted) return 130;
146
+ failures += 1;
147
+ if (failures > MAX_SILENT_RETRIES) {
148
+ write(loopLine('connection lost', `could not reach the loop stream after ${MAX_SILENT_RETRIES} tries; resume with: quox loop watch ${id} --since ${lastSeq}`, 'fail'));
149
+ return 4;
150
+ }
151
+ if (!opts.quiet && !opts.json) write(loopLine('reconnecting', `stream dropped (${oneLineErr(err)}); resuming after event ${lastSeq}`, 'warn'));
152
+ await sleep(backoff);
153
+ backoff = Math.min(BACKOFF_CAP_MS, backoff * 2);
154
+ continue;
155
+ }
156
+ if (ended) {
157
+ write(loopLine(ctx.title || 'loop', `ended without a recorded finish (${ended.status || 'unknown'}): ${ended.reason}`, 'warn'));
158
+ return ended.status === 'completed' ? 0 : 1;
159
+ }
160
+ // stream closed by the server (restart, proxy idle limit) before loop.finished: resume, quietly counting
161
+ if (gotEvent) { failures = 0; backoff = BACKOFF_BASE_MS; } else failures += 1;
162
+ if (failures > MAX_SILENT_RETRIES) {
163
+ write(loopLine('connection lost', `the loop stream keeps closing with nothing new; resume with: quox loop watch ${id} --since ${lastSeq}`, 'fail'));
164
+ return 4;
165
+ }
166
+ await sleep(gotEvent ? 250 : backoff);
167
+ if (!gotEvent) backoff = Math.min(BACKOFF_CAP_MS, backoff * 2);
168
+ }
169
+ return 130;
170
+ }
171
+
172
+ function oneLineErr(err) {
173
+ return String((err && err.message) || err).replace(/\s+/g, ' ').slice(0, 80);
174
+ }
175
+
176
+ export function statusLines(s, { diagnostics = false } = {}) {
177
+ const lines = [];
178
+ const turns = `turn ${s.turn === null || s.turn === undefined ? '-' : s.turn}${s.max_turns ? `/${s.max_turns}` : ''}`;
179
+ lines.push(loopLine(s.title || s.objective_id, `${s.state}, ${turns}`, s.state === 'running' ? 'busy' : (s.state === 'completed' ? 'ok' : (s.state === 'completed_with_errors' || s.state === 'halted' || s.state === 'interrupted' ? 'warn' : (s.finished ? 'fail' : 'busy')))));
180
+ lines.push(` id ${s.objective_id}`);
181
+ lines.push(` host ${s.host || '-'} backend ${s.backend || '-'} scope ${(s.scope || []).join(',') || '-'}`);
182
+ if (s.until) lines.push(` until ${s.until}${s.every_seconds ? ` (a turn every ${s.every_seconds}s)` : ''}`);
183
+ else if (s.every_seconds) lines.push(` every ${s.every_seconds}s`);
184
+ if (s.workspace) lines.push(` workspace ${s.workspace}`);
185
+ const r = s.receipts || { total: 0, witnessed: 0 };
186
+ lines.push(` receipts ${r.witnessed}/${r.total} witnessed${s.ward_check_error ? ` (WARD could not be asked just now: ${s.ward_check_error})` : ''}`);
187
+ if (s.observe) lines.push(` observe ${JSON.stringify(s.observe)}`);
188
+ if (s.reason && s.finished) lines.push(` reason ${s.reason}`);
189
+ if (diagnostics && s.diagnostics) lines.push(` diagnostics ${s.diagnostics}`);
190
+ if ((s.last_turns || []).length) {
191
+ lines.push(' last turns');
192
+ for (const t of s.last_turns) {
193
+ const tools = (t.tools || []).map((x) => `${x.tool}${x.denied ? ' denied' : (x.ok ? '' : ' FAILED')}`).join(', ');
194
+ lines.push(` ${t.turn} ${t.summary}${tools ? ` [${tools}]` : ''} ${t.envelope_id ? `${String(t.envelope_id)} ${t.ward}` : ''}`.trimEnd());
195
+ }
196
+ }
197
+ if (s.started_at) {
198
+ const st = toMs(s.started_at);
199
+ const en = toMs(s.completed_at) || Date.now();
200
+ if (st) lines.push(` elapsed ${elapsedLabel(en - st)}`);
201
+ }
202
+ return lines;
203
+ }
204
+
205
+ export function registerLoopCommands(program, ctx) {
206
+ const { client, auth } = ctx;
207
+
208
+ const loop = program
209
+ .command('loop')
210
+ .description('Observe QLoop loops as they run (watch, status, list)');
211
+
212
+ loop
213
+ .command('watch')
214
+ .description('Follow a loop live: one QuoxGlyph line per event, reconnecting on drop. Exits 0 completed, 1 failed, 2 halted, 3 interrupted, 4 connection lost')
215
+ .argument('<id>', 'loop (objective) ID')
216
+ .option('--spool', 'also stream the loop\'s output text under each turn line')
217
+ .option('--quiet', 'only the headline lines: started, observe points (milestones, errors, approvals, stalls), the final line')
218
+ .option('--since <n>', 'resume after this event_seq (printed by a previous watch; default: from the start)')
219
+ .option('--no-wait-receipts', 'print the final line as soon as the loop ends instead of waiting (up to 90s) for its receipts to be witnessed')
220
+ .action(async (id, options) => {
221
+ auth.requireAuth();
222
+ if (options.spool && options.quiet) throw new QuoxUserError('--spool and --quiet cancel each other out; pick one.');
223
+ const since = options.since !== undefined ? Number(options.since) : 0;
224
+ if (!Number.isInteger(since) || since < 0) throw new QuoxUserError('--since needs a whole number event_seq (0 or more).');
225
+ const snap = resolveEndpoint('loops.get', { id: encodeURIComponent(id) });
226
+ const snapshot = await client.get(snap.path, { service: snap.service });
227
+ const ac = new AbortController();
228
+ const onSigint = () => ac.abort();
229
+ process.once('SIGINT', onSigint);
230
+ let code;
231
+ try {
232
+ code = await watchLoop({
233
+ client, id, snapshot,
234
+ opts: { spool: options.spool, quiet: options.quiet, since, json: getOutputFormat() === 'json', noSettle: options.waitReceipts === false },
235
+ write: (l) => console.log(l),
236
+ signal: ac.signal,
237
+ });
238
+ } finally {
239
+ process.removeListener('SIGINT', onSigint);
240
+ }
241
+ if (code === 130) console.log(` stopped watching; the loop keeps running. Resume: quox loop watch ${id}`);
242
+ if (code) process.exitCode = code;
243
+ });
244
+
245
+ loop
246
+ .command('status')
247
+ .description('Snapshot of a loop: state, turn n/max, host, backend, scope, last turns, receipts')
248
+ .argument('<id>', 'loop (objective) ID')
249
+ .option('--diagnostics', 'also show the raw diagnostics behind the finish reason (hidden by default)')
250
+ .action(async (id, options = {}) => {
251
+ auth.requireAuth();
252
+ const ep = resolveEndpoint('loops.get', { id: encodeURIComponent(id) });
253
+ const data = await client.get(ep.path, { service: ep.service });
254
+ if (getOutputFormat() === 'json') {
255
+ const out = options.diagnostics || !data || typeof data !== 'object' ? data : { ...data, diagnostics: undefined };
256
+ console.log(JSON.stringify(out, null, 2)); return;
257
+ }
258
+ for (const l of statusLines(data, { diagnostics: !!options.diagnostics })) console.log(l);
259
+ });
260
+
261
+ loop
262
+ .command('list')
263
+ .description('Running and recent loops in this org')
264
+ .option('--limit <n>', 'max results', '20')
265
+ .action(async (options) => {
266
+ auth.requireAuth();
267
+ const ep = resolveEndpoint('loops.list');
268
+ const data = await client.get(ep.path, { service: ep.service, query: { limit: options.limit } });
269
+ const loops = (data.loops || []).map(normLoop);
270
+ formatOutput({
271
+ columns: ['objective_id', 'title', 'state', 'turn', 'backend', 'created_at'],
272
+ headers: ['ID', 'Title', 'State', 'Turn', 'Backend', 'Created'],
273
+ rows: loops.map((l) => ({ ...l, turn: turnLabel(l) })),
274
+ }, { format: program.opts().output, raw: loops, emptyMessage: 'No loops in this org yet.' });
275
+ });
276
+
277
+ // SECURITY containment (2026-10-26): which orgs may run loops in-server (core-local) while a security review is open.
278
+ // Instance-admin only (the server enforces it; a non-admin gets a 403). Stored server-side in instance_config.
279
+ const coreLocal = loop
280
+ .command('core-local')
281
+ .description('Instance admin: which workspaces may run loops inside the server (limited while a security review is open)');
282
+
283
+ coreLocal
284
+ .command('list')
285
+ .description('Show the workspaces allowed to run loops inside the server')
286
+ .action(async () => {
287
+ auth.requireAuth();
288
+ const ep = resolveEndpoint('admin.instance.core_local_orgs.list');
289
+ const r = await client.get(ep.path, { service: ep.service });
290
+ if (getOutputFormat() === 'json') { console.log(JSON.stringify(r, null, 2)); return; }
291
+ if (!(r.orgs || []).length) { console.log('No workspace may run loops inside the server (loops need a dev server).'); return; }
292
+ for (const o of r.orgs) console.log(o);
293
+ });
294
+
295
+ coreLocal
296
+ .command('add')
297
+ .description('Allow a workspace to run loops inside the server (instance admin)')
298
+ .argument('<orgId>', 'workspace (org) id')
299
+ .action(async (orgId) => {
300
+ auth.requireAuth();
301
+ const ep = resolveEndpoint('admin.instance.core_local_orgs.add');
302
+ const r = await client.post(ep.path, { service: ep.service, body: { org_id: orgId } });
303
+ if (getOutputFormat() === 'json') { console.log(JSON.stringify(r, null, 2)); return; }
304
+ console.log(`Added ${orgId}. ${(r.orgs || []).length} workspace(s) may now run loops inside the server.`);
305
+ });
306
+
307
+ coreLocal
308
+ .command('remove')
309
+ .description('Stop a workspace running loops inside the server (instance admin)')
310
+ .argument('<orgId>', 'workspace (org) id')
311
+ .action(async (orgId) => {
312
+ auth.requireAuth();
313
+ const ep = resolveEndpoint('admin.instance.core_local_orgs.remove', { orgId: encodeURIComponent(orgId) });
314
+ const r = await client.delete(ep.path, { service: ep.service });
315
+ if (getOutputFormat() === 'json') { console.log(JSON.stringify(r, null, 2)); return; }
316
+ console.log(`Removed ${orgId}. ${(r.orgs || []).length} workspace(s) may still run loops inside the server.`);
317
+ });
318
+ }
@@ -105,6 +105,7 @@ export function registerLucidCommands(program, ctx) {
105
105
  if (o.markdown) { const { renderSitrepMarkdown } = await import('../lucid/markdown.js'); console.log(renderSitrepMarkdown(data)); return; }
106
106
  const { renderSitrep } = await import('../lucid/sitrep.js');
107
107
  const tty = Boolean(process.stdout.isTTY);
108
+ if (tty) process.stderr.write('Tip: for a shorter form use --spoken (sentences), --agent (compact) or --markdown (one chat message).\n');
108
109
  process.stdout.write(renderSitrep(data, {
109
110
  width: process.stdout.columns || 100,
110
111
  color: tty && !process.env.NO_COLOR && process.env.QUOX_COLOR !== '0',
@@ -48,6 +48,8 @@ function parseCsvFile(path) {
48
48
  const VALID_ZONES = ['focus', 'fyi', 'auto', 'screened'];
49
49
  const VALID_KINDS = ['file', 'block', 'draft'];
50
50
  const VALID_DECISIONS = ['approve', 'deny'];
51
+ // quox#1518: past tense comes from a map, never verb + 'd' ("denyd").
52
+ const DECISION_PAST_TENSE = { approve: 'approved', deny: 'denied', reject: 'rejected' };
51
53
 
52
54
  // Small CLI-local copy of src/config/emailProviders.js's EMAIL_PROVIDERS.
53
55
  // The cli/ package can't import from src/, so the host/port defaults for
@@ -67,6 +69,21 @@ function printJson(data) {
67
69
  console.log(JSON.stringify(data, null, 2));
68
70
  }
69
71
 
72
+ // quox#1504: a failure is printed as a failure (stderr, non-zero exit), never
73
+ // dressed up as an empty success.
74
+ function printFailure(message) {
75
+ console.error(message);
76
+ process.exitCode = 1;
77
+ }
78
+
79
+ function pinHint(accountId, error) {
80
+ return /^TLS: /.test(error || '')
81
+ ? `If this server's certificate is one you trust, an org admin can pin it: quox mail account pin ${accountId} --sha256 <fingerprint>`
82
+ : null;
83
+ }
84
+
85
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
86
+
70
87
  export function registerMailCommands(program, ctx) {
71
88
  const { client, auth } = ctx;
72
89
 
@@ -85,9 +102,9 @@ export function registerMailCommands(program, ctx) {
85
102
  const data = await client.get(ep.path, { service: ep.service });
86
103
  if (isJsonOutput(program)) return printJson(data);
87
104
  formatOutput({
88
- columns: ['id', 'emailAddress', 'displayName', 'pollEnabled', 'createdAt'],
89
- headers: ['ID', 'Email', 'Name', 'Polling', 'Connected'],
90
- rows: data.accounts || [],
105
+ columns: ['id', 'emailAddress', 'displayName', 'pollEnabled', 'tlsPin', 'lastError'],
106
+ headers: ['ID', 'Email', 'Name', 'Polling', 'TLS pin', 'Last error'],
107
+ rows: (data.accounts || []).map((a) => ({ ...a, tlsPin: a.tlsPinSha256 ? 'sha256 pinned' : '', lastError: a.lastError || '' })),
91
108
  }, { format: program.opts().output });
92
109
  });
93
110
 
@@ -288,31 +305,123 @@ export function registerMailCommands(program, ctx) {
288
305
 
289
306
  mail
290
307
  .command('sync')
291
- .description('Sync a mail account now (fetch new messages, run triage)')
308
+ .description('Sync a mail account now (fetch new messages, run triage). Waits for the result and shows the real cause if the mailbox cannot be reached (TLS, login, network).')
292
309
  .argument('<accountId>', 'account ID')
293
- .action(async (accountId) => {
310
+ .option('--wait <seconds>', 'how long to wait for a background sync to finish (0 = do not wait)', '120')
311
+ .action(async (accountId, options) => {
294
312
  auth.requireAuth();
295
313
  const ep = resolveEndpoint('inboxq.sync');
296
- // A first full-mailbox pull runs the whole IMAP fetch + classify in one
297
- // synchronous request and routinely exceeds the default 30s client timeout
298
- // (the sync still completes server-side, but the CLI showed a false
299
- // "timed out"). Give sync a generous 4-minute ceiling (nginx allows 300s).
314
+ // A first full-mailbox pull can be slow; the collector usually runs it
315
+ // detached and returns {started:true} at once. Keep a generous ceiling
316
+ // for the inline shape (nginx allows 300s).
300
317
  const data = await client.post(ep.path, { service: ep.service, body: { accountId }, timeout: 240000 });
318
+
319
+ // Detached run: poll sync-status until it finishes, then report the
320
+ // OUTCOME. quox#1504: this used to print "No folders reported" while the
321
+ // background run failed TLS and nobody saw why.
322
+ if (data && (data.started || data.status === 'already_syncing')) {
323
+ const sep = resolveEndpoint('inboxq.syncStatus');
324
+ const deadline = Date.now() + Math.max(0, Number(options.wait) || 0) * 1000;
325
+ let st = await client.get(sep.path, { service: sep.service, query: { accountId } });
326
+ while (st && st.syncing && Date.now() < deadline) {
327
+ await sleep(2000);
328
+ st = await client.get(sep.path, { service: sep.service, query: { accountId } });
329
+ }
330
+ if (isJsonOutput(program)) return printJson({ ...data, status: st });
331
+ if (st && st.lastError) {
332
+ printFailure(`Sync of ${accountId} failed: ${st.lastError}`);
333
+ for (const f of st.folderErrors || []) printFailure(` ${f.folder}: ${f.error} (failures in a row: ${f.consecutiveFailures}${f.retired ? ', retired' : ''})`);
334
+ const hint = pinHint(accountId, st.lastError);
335
+ if (hint) console.error(hint);
336
+ return;
337
+ }
338
+ if (st && st.syncing) {
339
+ printSuccess(`Sync of ${accountId} is still running (${st.stored ?? 0} messages stored so far). Check: quox mail sync-status ${accountId}`);
340
+ return;
341
+ }
342
+ printSuccess(`Synced ${accountId}. ${st?.stored ?? 0} messages stored.`);
343
+ return;
344
+ }
345
+
301
346
  if (isJsonOutput(program)) return printJson(data);
302
347
  const summary = data.summary || {};
303
348
  const folders = summary.folders || [];
349
+ const failed = folders.filter((f) => f.failed);
304
350
  if (folders.length === 0) {
305
- printSuccess(`Synced ${accountId}. No folders reported.`);
351
+ if (data.lastError) { printFailure(`Sync of ${accountId} failed: ${data.lastError}`); return; }
352
+ printSuccess(`Synced ${accountId}. No folders were eligible this pass (all in backoff or retired); see quox mail sync-status ${accountId}.`);
306
353
  return;
307
354
  }
308
355
  formatOutput({
309
- columns: ['folder', 'new', 'failed'],
310
- headers: ['Folder', 'New', 'Failed'],
311
- rows: folders,
356
+ columns: ['folder', 'new', 'error'],
357
+ headers: ['Folder', 'New', 'Error'],
358
+ rows: folders.map((f) => ({ ...f, new: f.new ?? '', error: f.error || f.skipped || '' })),
312
359
  }, { format: program.opts().output });
360
+ if (failed.length) {
361
+ printFailure(`Sync of ${accountId}: ${failed.length} of ${folders.length} folder(s) failed: ${failed[0].error}`);
362
+ const hint = pinHint(accountId, failed[0].error);
363
+ if (hint) console.error(hint);
364
+ return;
365
+ }
313
366
  printSuccess(`Synced ${accountId}. LLM calls: ${summary.llmCalls ?? 0}`);
314
367
  });
315
368
 
369
+ mail
370
+ .command('sync-status')
371
+ .description('Show whether an account is syncing, how many messages are stored, and the last connect/TLS/login error if any')
372
+ .argument('<accountId>', 'account ID')
373
+ .action(async (accountId) => {
374
+ auth.requireAuth();
375
+ const sep = resolveEndpoint('inboxq.syncStatus');
376
+ const st = await client.get(sep.path, { service: sep.service, query: { accountId } });
377
+ if (isJsonOutput(program)) return printJson(st);
378
+ formatDetail({
379
+ syncing: Boolean(st.syncing),
380
+ stored: st.stored ?? 0,
381
+ folders: st.folders ? `${st.folders.backfilled}/${st.folders.total} fully imported` : '',
382
+ tlsPin: st.tlsPinSha256 || 'none (default certificate verification)',
383
+ lastError: st.lastError || 'none',
384
+ lastErrorAt: st.lastErrorAt || '',
385
+ }, { format: program.opts().output });
386
+ if ((st.folderErrors || []).length) {
387
+ formatOutput({
388
+ columns: ['folder', 'error', 'consecutiveFailures', 'retired', 'nextAttemptAt'],
389
+ headers: ['Folder', 'Error', 'Failures', 'Retired', 'Next attempt'],
390
+ rows: st.folderErrors,
391
+ }, { format: program.opts().output });
392
+ }
393
+ const hint = pinHint(accountId, st.lastError);
394
+ if (hint) console.error(hint);
395
+ });
396
+
397
+ // ── account (per-account settings) ───────────────────────────────────
398
+
399
+ const account = mail
400
+ .command('account')
401
+ .description('Per-account settings for a connected mailbox');
402
+
403
+ account
404
+ .command('pin')
405
+ .description('Pin the TLS certificate (SHA-256 fingerprint) an account trusts, for a mail server with a self-signed or private-CA certificate. Only that exact certificate is accepted; anything else still fails. Org admin only. Get the fingerprint with: openssl s_client -connect <host>:<port> </dev/null | openssl x509 -noout -fingerprint -sha256')
406
+ .argument('<accountId>', 'account ID')
407
+ .option('--sha256 <fingerprint>', 'SHA-256 certificate fingerprint (64 hex characters, colons optional; the openssl output line is accepted)')
408
+ .option('--clear', 'remove the pin (back to default certificate verification)')
409
+ .action(async (accountId, options) => {
410
+ auth.requireAuth();
411
+ if (Boolean(options.sha256) === Boolean(options.clear)) {
412
+ printFailure('Give exactly one of --sha256 <fingerprint> or --clear.');
413
+ return;
414
+ }
415
+ const ep = resolveEndpoint('inboxq.accounts.tlsPin', { accountId });
416
+ const body = options.clear ? { clear: true } : { sha256: options.sha256 };
417
+ const data = await client.put(ep.path, { service: ep.service, body });
418
+ if (isJsonOutput(program)) return printJson(data);
419
+ const pin = data?.account?.tlsPinSha256;
420
+ printSuccess(pin
421
+ ? `Pinned ${accountId} to TLS certificate sha256 ${pin}. Run quox mail sync ${accountId} to retry now.`
422
+ : `Removed the TLS pin from ${accountId}; it uses default certificate verification again.`);
423
+ });
424
+
316
425
  // ── messages ─────────────────────────────────────────────────────────
317
426
 
318
427
  mail
@@ -408,20 +517,52 @@ export function registerMailCommands(program, ctx) {
408
517
 
409
518
  watch
410
519
  .command('add')
411
- .description('Add a watch')
520
+ .description('Add a watch (awaited-mail alert). Give --match, or at least one of --from/--subject/--text/--zone.')
412
521
  .requiredOption('--label <label>', 'watch label')
413
- .requiredOption('--match <json>', 'match rule as a JSON object')
414
- .option('--account <id>', 'restrict the watch to one account')
522
+ .option('--match <json>', 'match rule as a JSON object (advanced; alternative to the --from/--subject/--text/--zone flags)')
523
+ .option('--from <addr>', 'match: sender address/name contains this')
524
+ .option('--subject <text>', 'match: subject contains this')
525
+ .option('--text <keyword>', 'match: message text contains this')
526
+ .option('--zone <zone>', 'match: only mail triaged into this zone (focus|fyi|auto|screened)')
527
+ .option('--notify <csv>', 'alert channels, comma list of hitl,lucid,telegram,discord,call,sms', 'hitl')
528
+ .option('--account <id>', 'restrict the watch to one account (omit to watch all your mailboxes)')
415
529
  .action(async (options) => {
416
530
  auth.requireAuth();
417
- const match = parseJSON(options.match, '--match');
418
- const body = { label: options.label, match };
531
+ let match;
532
+ if (options.match) {
533
+ match = parseJSON(options.match, '--match');
534
+ } else {
535
+ match = {};
536
+ if (options.from) match.from = options.from;
537
+ if (options.subject) match.subject = options.subject;
538
+ if (options.text) match.text = options.text;
539
+ if (options.zone) match.zone = options.zone;
540
+ }
541
+ if (!match || typeof match !== 'object' || Object.keys(match).length === 0) {
542
+ throw new Error('Give --match, or at least one of --from, --subject, --text, --zone, or the watch would match nothing in particular.');
543
+ }
544
+ const notify = String(options.notify).split(',').map((s) => s.trim()).filter(Boolean);
545
+ const body = { label: options.label, match, notify };
419
546
  if (options.account) body.accountId = options.account;
420
547
  const ep = resolveEndpoint('inboxq.watches.create');
421
548
  const data = await client.post(ep.path, { service: ep.service, body });
422
549
  if (isJsonOutput(program)) return printJson(data);
423
550
  const created = data.watch || data;
424
- printSuccess(`Added watch "${options.label}" (${created.id})`);
551
+ printSuccess(`Added watch "${options.label}" (${created.id}); alerts via ${notify.join(', ')}.`);
552
+ const ext = notify.filter((c) => ['telegram', 'discord', 'call', 'sms'].includes(c));
553
+ if (ext.length) console.log(`Note: ${ext.join(', ')} need a destination configured, or they report unconfigured; hitl and lucid work out of the box.`);
554
+ });
555
+
556
+ watch
557
+ .command('rm')
558
+ .description('Delete a watch')
559
+ .argument('<watchId>', 'watch ID')
560
+ .action(async (watchId) => {
561
+ auth.requireAuth();
562
+ const ep = resolveEndpoint('inboxq.watches.delete', { watchId });
563
+ const data = await client.delete(ep.path, { service: ep.service });
564
+ if (isJsonOutput(program)) return printJson(data);
565
+ printSuccess(`Watch ${watchId} deleted.`);
425
566
  });
426
567
 
427
568
  watch
@@ -493,7 +634,7 @@ export function registerMailCommands(program, ctx) {
493
634
  const ep = resolveEndpoint('inboxq.proposals.resolve', { proposalId });
494
635
  const data = await client.post(ep.path, { service: ep.service, body: { decision: options.decision } });
495
636
  if (isJsonOutput(program)) return printJson(data);
496
- printSuccess(`Proposal ${proposalId} ${options.decision}d.`);
637
+ printSuccess(`Proposal ${proposalId} ${DECISION_PAST_TENSE[options.decision] || options.decision}.`);
497
638
  });
498
639
 
499
640
  // ── moves (AQ-9E U2: reversible moves) ─────────────────────────────────