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,189 @@
1
+ /**
2
+ * QLOOP-LIVE: the loop cards in the chat stream. One card per loop, updated in place; observer points and the finish line
3
+ * append as ordinary chat lines beneath it, so the history reads as the loop's story.
4
+ *
5
+ * Glue only (the logic is in loopCard.js and loopStream.js): it owns the watchers, the redraw throttle (<= 8 per second),
6
+ * the keys of a focused card and the finish moment. The host (app.js) supplies say / addItem / bump / dragon, which keeps this
7
+ * testable without Ink.
8
+ */
9
+ import { loopPinned, initLoop, applySnapshot, applyProgress, reduceLoop, setConn, observerLine, finishLines, startLine, createThrottle, REDRAW_MIN_MS, REDRAW_UNFOCUSED_MS, TERMINAL } from './loopCard.js';
10
+ import { watchLoop } from './loopStream.js';
11
+ import { objectiveMoment } from './dragonMoments.js';
12
+
13
+ const TICK_MS = 250; // a focused card's spinner and clock
14
+ const TICK_UNFOCUSED_EVERY = 4; // nobody on the card: every 4th tick (1 s), so idle cards cost little
15
+ const REFRESH_MIN_MS = 2000; // the runner's own turn is read from the snapshot at most this often per card
16
+ const REFRESH_CAP_MS = 1500; // and the finish never waits longer than this for the last read
17
+
18
+ /**
19
+ * The roster sees a loop finish (its row turns done) up to a minute BEFORE the stream's loop.finished reaches the card (the server
20
+ * drains receipts first), and its dragon then lands on a card that is still running and overflows the screen. A loop with a live card
21
+ * summons its dragon from the card, once, at its finish: the roster's watch skips those rows.
22
+ */
23
+ export const rowsWithoutLoopCards = (rows, has) => (rows || []).filter((r) => !(r?.kind === 'loop' && has(r.id)));
24
+
25
+ export function createLoopCards({ api, say, addItem, bump, dragon = null, now = Date.now, setTick = (f, ms) => { const t = setInterval(f, ms); t.unref?.(); return () => clearInterval(t); }, throttleTimer, watch = watchLoop, sleep } = {}) {
26
+ const cards = new Map(); // id -> ctrl
27
+ let focusId = null, cancelTick = null;
28
+ const throttle = createThrottle(() => bump(), { minMs: () => (focusId ? REDRAW_MIN_MS : REDRAW_UNFOCUSED_MS), now, ...(throttleTimer ? { timer: throttleTimer } : {}) });
29
+
30
+ const running = () => [...cards.values()].some((c) => !c.state.done && !c.state.refused);
31
+ /**
32
+ * A runner loop's stream `turn` counts model messages; its own turn is the snapshot's `iteration`. Read it again when a turn moves
33
+ * (at most every REFRESH_MIN_MS; a request inside the window is made by the next tick). -> a promise that settles when the read is over.
34
+ */
35
+ function refresh(c, force = false) {
36
+ if (!api.loopSnapshot || c.refreshing) return c.refreshing || Promise.resolve();
37
+ if (!force && now() - (c.lastSnapAt ?? -Infinity) < REFRESH_MIN_MS) { c.refreshDue = true; return Promise.resolve(); }
38
+ c.lastSnapAt = now(); c.refreshDue = false;
39
+ c.refreshing = Promise.resolve().then(() => api.loopSnapshot(c.id)).then((snap) => { c.state = applyProgress(c.state, snap); throttle.poke(); }, () => {}).finally(() => { c.refreshing = null; });
40
+ return c.refreshing;
41
+ }
42
+ let tickN = 0;
43
+ function tick() { // elapsed time and the spinner move once per tick, never faster than the throttle allows
44
+ if (!running()) { cancelTick?.(); cancelTick = null; return; }
45
+ if (!focusId && ++tickN % TICK_UNFOCUSED_EVERY) return;
46
+ for (const c of cards.values()) if (!c.state.done && !c.state.refused) { c.frame = (c.frame + 1) % 4; if (c.refreshDue) refresh(c); }
47
+ throttle.poke();
48
+ }
49
+ const ensureTick = () => { if (!cancelTick && running()) cancelTick = setTick(tick, TICK_MS); };
50
+
51
+ function finish(c, ev) {
52
+ if (!c.finishedSaid) { c.finishedSaid = true; for (const l of finishLines(c.state)) say(l.text, l.tone); }
53
+ c.confirm = false; // a focused card keeps its focus: Enter then expands the final output and Esc lets the frozen card go to scrollback
54
+ // The moment engine fires the dragon. Same call and same gates as a finished roster row (mode on, not typing, cooldown).
55
+ const st = c.state;
56
+ const m = !c.quiet && dragon?.summon && ['completed', 'completed_with_errors', 'failed'].includes(st.state)
57
+ ? objectiveMoment({ kind: 'loop', state: st.state === 'failed' ? 'failed' : 'done', name: st.title || st.id, activity: st.reason || '' }) : null;
58
+ if (m) Promise.resolve(dragon.summon(st.agent && typeof st.agent === 'string' ? st.agent : m.dragon, m.caption)).then((shown) => { if (shown) bump(); }, () => {});
59
+ return ev;
60
+ }
61
+
62
+ function onEvent(c, ev) {
63
+ const before = c.state;
64
+ c.state = reduceLoop(before, ev);
65
+ if (c.state === before) return;
66
+ const line = c.quiet ? null : observerLine(ev, c.state); // replaying a loop that was already over: the card tells it, the chat does not re-read it
67
+ if (line) { c.state.observed = [...c.state.observed, line.text].slice(-50); say(line.text, line.tone); }
68
+ if (ev.type === 'loop.turn.started' || ev.type === 'loop.turn.completed' || ev.type === 'loop.observe') refresh(c);
69
+ if (ev.type === 'loop.finished') { // the last read of the runner's turn first: "completed, N turns" must be the loop's count
70
+ c.finishing = true;
71
+ let t; const cap = new Promise((res) => { t = setTimeout(res, REFRESH_CAP_MS); t.unref?.(); });
72
+ Promise.race([refresh(c, true), cap]).then(() => { clearTimeout(t); finish(c, ev); throttle.poke(); });
73
+ }
74
+ throttle.poke();
75
+ }
76
+
77
+ // The start line is only true once the loop has answered: a refused watch never says "started".
78
+ function announceStart(c) {
79
+ if (c.startSaid || !c.announce) return;
80
+ c.startSaid = true; const l = startLine(c.state); say(l.text, l.tone);
81
+ }
82
+
83
+ async function run(c) {
84
+ const ctl = c.abort = new AbortController();
85
+ try {
86
+ const out = await watch({
87
+ api, id: c.id, signal: ctl.signal, sleep,
88
+ onSnapshot: (snap) => { announceStart(c); c.state = applySnapshot(c.state, snap); if (c.state.done) c.quiet = true; throttle.poke(); }, // the finish line waits for the replay: it needs the receipts
89
+ onEvent: (ev) => { announceStart(c); onEvent(c, ev); },
90
+ onConn: ({ conn, attempt, note }) => { if (!c.state.done) { c.state = setConn(c.state, conn, { attempt, note }); throttle.poke(); } },
91
+ });
92
+ if (out?.lost && !c.state.done) {
93
+ c.state = setConn(c.state, 'lost', { attempt: 0, note: out.reason });
94
+ // Refused (403 / 404) before a single event: the loop is not this caller's (or does not exist). Say so; it never started for us.
95
+ if ((out.status === 403 || out.status === 404) && c.state.startedAt == null && !c.state.turn && !c.startSaid) c.state = { ...c.state, refused: "that loop belongs to another workspace (or doesn't exist)" };
96
+ }
97
+ if (c.state.done && !c.finishedSaid && !c.finishing) { c.finishedSaid = true; for (const l of finishLines(c.state)) say(l.text, l.tone); } // finished before we looked (or the replay ended without a finish event)
98
+ } catch (e) {
99
+ c.state = setConn(c.state, 'lost', { note: e?.message || 'failed' });
100
+ } finally { throttle.poke(); }
101
+ }
102
+
103
+ return {
104
+ /** Put a live card in the chat stream for this loop and start following it. -> the card, or the existing one. */
105
+ watch(id, { title = '', agent = null, announce = true } = {}) {
106
+ if (!id) return null;
107
+ if (cards.has(id)) return cards.get(id);
108
+ const c = { id, state: initLoop({ id, title, agent }), expanded: false, focused: false, confirm: false, frame: 0, abort: null, finishedSaid: false, startSaid: false, announce };
109
+ cards.set(id, c);
110
+ addItem({ id: `loopcard-${id}`, kind: 'loopcard', card: c });
111
+ ensureTick();
112
+ run(c).then(() => bump());
113
+ return c;
114
+ },
115
+ /**
116
+ * A loop the chat just made (its `objective` tool answered `Loop "<title>" created ...`): the tool result carries no id, so look
117
+ * the loop up in the org's list (newest first) and put a card in this conversation for the live, unwatched one that matches
118
+ * the title. One retry, because the list can lag the tool's answer. -> ids watched.
119
+ */
120
+ async discover({ title = '' } = {}) {
121
+ if (!api.loopList) return [];
122
+ const want = String(title || '').replace(/…$/, '').trim().toLowerCase();
123
+ for (let attempt = 0; attempt < 2; attempt++) {
124
+ let rows = [];
125
+ try { const d = await api.loopList(); rows = Array.isArray(d) ? d : (d?.loops || []); } catch { rows = []; }
126
+ const hit = rows.filter((r) => {
127
+ const id = r.objective_id || r.id; if (!id || cards.has(id) || TERMINAL.has(r.state)) return false;
128
+ const age = now() - (Date.parse(String(r.created_at || '').replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(r.created_at || '')) ? '' : 'Z')) || 0);
129
+ return age < 20 * 60 * 1000 && (!want || String(r.title || '').toLowerCase().startsWith(want));
130
+ });
131
+ if (hit.length) { const r = hit[0]; const id = r.objective_id || r.id; this.watch(id, { title: r.title || title }); return [id]; }
132
+ if (attempt === 0) await (sleep || ((ms) => new Promise((res) => setTimeout(res, ms))))(1500);
133
+ }
134
+ return [];
135
+ },
136
+ /** Read the runner's turn now (tests, and a card that was just focused). */
137
+ refresh: (id) => (cards.has(id) ? refresh(cards.get(id), true) : Promise.resolve()),
138
+ has: (id) => cards.has(id),
139
+ get: (id) => cards.get(id) || null,
140
+ all: () => [...cards.values()],
141
+ /** True while this history item must stay in the live region (commitLimit): its loop has not finished. */
142
+ held: (item) => Boolean(item?.kind === 'loopcard' && item.card && ((!item.card.state.done && !item.card.state.refused) || item.card.focused)), // a focused card stays live even when finished, so Enter / Esc work on it
143
+ anyRunning: running,
144
+ /** The cards drawn above the composer: running ones, and a finished one you are still on. */
145
+ pinned: () => [...cards.values()].filter(loopPinned),
146
+ /** The card the keys act on, or null. */
147
+ focused: () => (focusId && cards.get(focusId)) || null,
148
+ /** Focus the newest card that is still running (or the named one). -> the card or null. */
149
+ focus(id = null) {
150
+ const pick = id ? cards.get(id) : [...cards.values()].reverse().find((c) => !c.state.done) || [...cards.values()].pop();
151
+ if (!pick) return null;
152
+ const old = this.focused(); if (old) { old.focused = false; old.confirm = false; }
153
+ pick.focused = true; focusId = pick.id; throttle.poke(); bump(); return pick;
154
+ },
155
+ blur() { const c = this.focused(); if (c) { c.focused = false; c.confirm = false; } focusId = null; bump(); },
156
+ /**
157
+ * Keys while a card is focused. Enter toggles the expanded spool, s asks to halt (y confirms, anything else cancels),
158
+ * Esc collapses and then leaves. -> true when the key was consumed.
159
+ */
160
+ key(name, text) {
161
+ const c = this.focused(); if (!c) return false;
162
+ if (c.confirm) {
163
+ c.confirm = false;
164
+ if (text === 'y') this.halt(c.id).then(() => bump(), () => bump()); else say('Halt cancelled.');
165
+ bump(); return true;
166
+ }
167
+ if (name === 'enter') { c.expanded = !c.expanded; bump(); return true; }
168
+ if (name === 'escape') { if (c.expanded) c.expanded = false; else this.blur(); bump(); return true; }
169
+ if (text === 's' && !c.state.done) { c.confirm = true; bump(); return true; }
170
+ if (name === 'up' || name === 'down' || name === 'tab') { // cycle between cards when there are several
171
+ const ids = [...cards.keys()]; if (ids.length > 1) { const i = ids.indexOf(c.id); this.focus(ids[(i + (name === 'up' ? ids.length - 1 : 1)) % ids.length]); }
172
+ return true;
173
+ }
174
+ if (text) { this.blur(); return false; } // ordinary typing goes to the draft
175
+ return true;
176
+ },
177
+ /** The halt goes through the same governed route as the roster's s: after an explicit y from the human, never silently. */
178
+ async halt(id) {
179
+ const c = cards.get(id); if (!c) return { ok: false, message: `no loop card for ${id}` };
180
+ if (!api.stopRow) { say('Halt is not available on this server.', 'warn'); return { ok: false }; }
181
+ const r = await api.stopRow({ source: 'objectives', kind: 'loop', id, name: c.state.title || id });
182
+ say(r.message || (r.ok ? 'Halt sent.' : 'Halt refused.'), r.ok ? undefined : 'warn');
183
+ return r;
184
+ },
185
+ dispose() { for (const c of cards.values()) c.abort?.abort(); cards.clear(); cancelTick?.(); cancelTick = null; throttle.dispose(); },
186
+ };
187
+ }
188
+
189
+ export { TERMINAL };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * QLOOP-LIVE: the client for a running loop. Reads GET /api/v1/loops/:id (snapshot) and
3
+ * GET /api/v1/loops/:id/events (SSE; ?since=<event_seq> resumes; a heartbeat comment every 15 s; ends after loop.finished).
4
+ * Contract: ~/quoxon-work/qloop-live-design.md section 1. Every event carries a monotonic event_seq.
5
+ *
6
+ * Reconnect is the part that must not lie. A drop is counted, backed off (500 ms doubling to 10 s, reset by any event) and
7
+ * reported to the card as 'reconnecting'; after MAX_FAILS drops in a row with no event between, or at once on 401/403/404,
8
+ * the watcher stops and says 'lost' with the reason. It never reports a lost stream as a finished loop.
9
+ */
10
+ export const BACKOFF_START_MS = 500;
11
+ export const BACKOFF_CAP_MS = 10000;
12
+ export const MAX_FAILS = 8;
13
+ export const QUIET_START_MS = 2000, QUIET_CAP_MS = 60000, QUIET_AFTER = 10, QUIET_RETIRE_AFTER = 20; // (retire: the snapshot still says running after this many empty closes in a row -> stop watching) // empty-close reconnects while the snapshot says running
14
+ const FATAL = new Set([401, 403, 404]);
15
+ const TERMINAL_STATES = new Set(['completed', 'completed_with_errors', 'failed', 'interrupted', 'halted', 'cancelled', 'aborted', 'stopped']);
16
+ const isTerminal = (snap) => Boolean(snap && (snap.finished === true || TERMINAL_STATES.has(snap.state)));
17
+
18
+ const sleepFor = (ms, signal) => new Promise((resolve) => {
19
+ if (signal?.aborted) return resolve();
20
+ const t = setTimeout(done, ms); t.unref?.();
21
+ function done() { signal?.removeEventListener?.('abort', done); clearTimeout(t); resolve(); }
22
+ signal?.addEventListener?.('abort', done, { once: true });
23
+ });
24
+
25
+ /**
26
+ * Follow one loop until loop.finished, abort, or a fatal failure.
27
+ *
28
+ * api: { loopSnapshot(id) -> object, loopEvents(id, { since }, signal) -> async iterable of events }
29
+ * handlers: { onSnapshot(snap), onEvent(ev), onConn({ conn: 'live'|'reconnecting'|'lost', attempt, note }) }
30
+ * -> { finished: boolean, lost: boolean, lastSeq, reason? }
31
+ */
32
+ export async function watchLoop({ api, id, signal, since = 0, onSnapshot = () => {}, onEvent = () => {}, onConn = () => {}, sleep = sleepFor, backoffStart = BACKOFF_START_MS, backoffCap = BACKOFF_CAP_MS, maxFails = MAX_FAILS, quietStart = QUIET_START_MS, quietCap = QUIET_CAP_MS, quietAfter = QUIET_AFTER, quietRetireAfter = QUIET_RETIRE_AFTER, now = Date.now }) {
33
+ let quietRuns = 0, quietSince = 0, quietSaid = false;
34
+ let lastSeq = Number(since) || 0, fails = 0, delay = backoffStart, finished = false;
35
+ try { onSnapshot(await api.loopSnapshot(id)); }
36
+ catch (e) { // the snapshot is a head start, not a gate: the replayed events carry the same facts. A fatal status still stops.
37
+ if (FATAL.has(e?.status)) { onConn({ conn: 'lost', attempt: 0, note: describe(e) }); return { finished: false, lost: true, lastSeq, status: e?.status, reason: describe(e) }; }
38
+ }
39
+ while (!signal?.aborted && !finished) {
40
+ let sawEvent = false;
41
+ try {
42
+ for await (const ev of api.loopEvents(id, { since: lastSeq || undefined }, signal)) {
43
+ if (signal?.aborted) break;
44
+ if (!ev || typeof ev.type !== 'string') continue;
45
+ const seq = Number(ev.event_seq);
46
+ if (Number.isFinite(seq) && seq <= lastSeq) continue; // the boundary event of a resume
47
+ if (Number.isFinite(seq)) lastSeq = seq;
48
+ if (!sawEvent) { sawEvent = true; fails = 0; quietRuns = 0; quietSaid = false; delay = backoffStart; onConn({ conn: 'live', attempt: 0, note: null }); }
49
+ onEvent(ev);
50
+ if (ev.type === 'loop.finished') { finished = true; break; }
51
+ }
52
+ if (!finished && !signal?.aborted && !sawEvent) {
53
+ // A clean close with no events is not a failure: a loop's silent final turn, or a loop that already finished, ends this way.
54
+ // Ask the snapshot. Terminal: finish quietly with the final state. Still running: reconnect with ?since, failures reset.
55
+ // Only a snapshot read that itself fails counts (a real error).
56
+ let snap = null;
57
+ try { snap = await api.loopSnapshot(id); }
58
+ catch (e) {
59
+ if (FATAL.has(e?.status)) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, status: e?.status, reason: describe(e) }; }
60
+ fails += 1;
61
+ if (fails >= maxFails) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, status: e?.status, reason: describe(e) }; }
62
+ onConn({ conn: 'reconnecting', attempt: fails, note: describe(e) });
63
+ await sleep(delay, signal); delay = Math.min(delay * 2, backoffCap);
64
+ continue;
65
+ }
66
+ if (isTerminal(snap)) { onSnapshot(snap); return { finished: true, lost: false, lastSeq, final: snap }; }
67
+ fails = 0; // still running: quiet, not a failure, but counted: capped backoff, and one honest line after quietAfter
68
+ if (quietRuns === 0) quietSince = now();
69
+ quietRuns += 1;
70
+ if (quietRuns >= quietRetireAfter) { // retirement: the snapshot (just re-read) still says running but nothing streams. Stop, say so plainly, never spin.
71
+ const reason = `the loop says running but its stream is silent; /loop ${id} to check, or it may have ended`;
72
+ onConn({ conn: 'silent', attempt: quietRuns, note: reason });
73
+ return { finished: false, lost: false, silent: true, lastSeq, reason };
74
+ }
75
+ if (quietRuns >= quietAfter && !quietSaid) { quietSaid = true; onConn({ conn: 'quiet', attempt: quietRuns, note: fmtDur(now() - quietSince) }); }
76
+ await sleep(Math.min(quietStart * 2 ** (quietRuns - 1), quietCap), signal);
77
+ continue;
78
+ }
79
+ else if (!finished && sawEvent) fails = 0;
80
+ } catch (e) {
81
+ if (signal?.aborted) break;
82
+ if (FATAL.has(e?.status)) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, status: e?.status, reason: describe(e) }; }
83
+ fails += 1;
84
+ if (fails >= maxFails) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, status: e?.status, reason: describe(e) }; }
85
+ onConn({ conn: 'reconnecting', attempt: fails, note: describe(e) });
86
+ await sleep(delay, signal); delay = Math.min(delay * 2, backoffCap);
87
+ continue;
88
+ }
89
+ if (finished || signal?.aborted) break;
90
+ if (fails >= maxFails) { onConn({ conn: 'lost', attempt: fails, note: 'the stream keeps closing' }); return { finished: false, lost: true, lastSeq, reason: 'the stream keeps closing' }; }
91
+ onConn({ conn: 'reconnecting', attempt: Math.max(1, fails), note: null });
92
+ await sleep(delay, signal); delay = Math.min(delay * 2, backoffCap);
93
+ }
94
+ return { finished, lost: false, lastSeq };
95
+ }
96
+
97
+ const fmtDur = (ms) => { const m = Math.round(ms / 60000); return m >= 1 ? `${m} min` : `${Math.round(ms / 1000)}s`; };
98
+ const describe = (e) => `${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}`.trim();
@@ -0,0 +1,71 @@
1
+ /**
2
+ * TUI-MAIL (owner 2026-10-30): read triaged InboxQ mail in the terminal. Pure helpers, read only; app.js owns the calls.
3
+ * Same data as `quox mail list` (GET /api/v1/inboxq/messages) and the message read behind `quox notes from-email`.
4
+ */
5
+ import { ageOf } from './inboxView.js';
6
+ 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; };
7
+ const pad = (s, n) => { const t = clip(s, n); return t + ' '.repeat(Math.max(0, n - [...t].length)); };
8
+ export const MAIL_ZONES = ['focus', 'fyi', 'auto', 'screened'];
9
+ export const MAIL_USAGE = 'Usage: /mail | /mail focus|fyi|auto|screened (one zone) | /mail N (read one). Read only.';
10
+ export const BODY_MAX_LINES = 40;
11
+
12
+ const whenMs = (m) => { const v = Number(m?.dateTs); if (Number.isFinite(v) && v > 0) return v < 1e12 ? v * 1000 : v; const t = Date.parse(m?.createdAt || ''); return Number.isFinite(t) ? t : 0; };
13
+ const fromOf = (m) => m.fromName || m.fromAddr || 'unknown';
14
+ const newestFirst = (items) => [...(Array.isArray(items) ? items : [])].filter((m) => m && m.id).sort((a, b) => whenMs(b) - whenMs(a));
15
+ export { newestFirst as mailNewestFirst };
16
+
17
+ /** '' | list | N | #N | show N | <zone> -> { kind:'list', zone? } | { kind:'show', n } | { kind:'usage' } */
18
+ export function parseMail(arg) {
19
+ const a = String(arg || '').trim().toLowerCase();
20
+ if (!a || a === 'list' || a === 'latest') return { kind: 'list' };
21
+ if (MAIL_ZONES.includes(a)) return { kind: 'list', zone: a };
22
+ const m = /^(?:show\s+)?#?(\d+)$/.exec(a);
23
+ if (m && Number(m[1]) >= 1) return { kind: 'show', n: Number(m[1]) };
24
+ return { kind: 'usage' };
25
+ }
26
+
27
+ /** No mailbox connected: one line, the exact command, no secret asked for here. */
28
+ export const noMailboxLines = () => [{ text: 'No mailbox connected. Connect one with: quox mail connect --provider gmail --email you@example.com --password-env MAIL_PASSWORD (the password is read from that env var, not typed here).', tone: 'warn' }];
29
+
30
+ /** The newest messages as a table. -> [{ text, tone }] */
31
+ export function mailTable(items, now = Date.now(), cols = 100, max = 10) {
32
+ const rows = newestFirst(items).slice(0, max);
33
+ if (!rows.length) return [{ text: 'No triaged mail yet.', tone: 'dim' }];
34
+ const tw = Math.max(16, Math.min(60, cols - 44));
35
+ const out = [{ text: ` ${pad('#', 3)}${pad('when', 6)}${pad('from', 18)}${pad('subject', tw)} zone`, tone: 'dim' }];
36
+ rows.forEach((m, i) => out.push({ text: `${m.seen === false ? '●' : ' '} ${pad(i + 1, 3)}${pad(ageOf(whenMs(m) ? new Date(whenMs(m)).toISOString() : '', now), 6)}${pad(fromOf(m), 18)}${pad(m.subject || '(no subject)', tw)} ${m.zone || '?'}`, tone: m.seen === false ? 'accent' : 'dim' }));
37
+ out.push({ text: '/mail N read one · /mail focus|fyi|auto|screened filter · full list: quox mail list', tone: 'dim' });
38
+ return out;
39
+ }
40
+
41
+ const htmlToText = (h) => String(h)
42
+ .replace(/<(script|style)[\s\S]*?<\/\1>/gi, '').replace(/<br\s*\/?>/gi, '\n').replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n')
43
+ .replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/\n{3,}/g, '\n\n');
44
+
45
+ /** One message: from, to, date, subject, a plain-text body excerpt, the triage reason. -> [{ text, tone }] */
46
+ export function mailDetail(m, now = Date.now(), n = 0) {
47
+ const from = m.fromName && m.fromAddr ? `${m.fromName} <${m.fromAddr}>` : fromOf(m);
48
+ const t = whenMs(m);
49
+ const to = m.toAddr || m.toAddrs || m.to;
50
+ const out = [{ text: m.subject || '(no subject)', tone: 'accent' },
51
+ { text: `from ${from}`, tone: 'dim' }];
52
+ if (to) out.push({ text: `to ${Array.isArray(to) ? to.join(', ') : to}`, tone: 'dim' });
53
+ out.push({ text: `${t ? new Date(t).toISOString().slice(0, 16).replace('T', ' ') + ' UTC' : 'date unknown'} (${t ? ageOf(new Date(t).toISOString(), now) + ' ago' : '?'})`, tone: 'dim' });
54
+ const bits = [`triage: ${m.zone || 'untriaged'}`];
55
+ if (m.classifiedBy) bits.push(`by ${m.classifiedBy}`);
56
+ if (typeof m.confidence === 'number') bits.push(`${Math.round(m.confidence * 100)}% sure`);
57
+ if (Array.isArray(m.flags) && m.flags.length) bits.push(`flags: ${m.flags.join(', ')}`);
58
+ if (m.phishingRisk > 0) bits.push('phishing risk flagged');
59
+ out.push({ text: bits.join(' · '), tone: 'dim' });
60
+ let body = typeof m.bodyText === 'string' && m.bodyText.trim() ? m.bodyText : (typeof m.bodyHtml === 'string' && m.bodyHtml.trim() ? htmlToText(m.bodyHtml) : '');
61
+ if (body) {
62
+ const lines = body.trim().split('\n');
63
+ for (const l of lines.slice(0, BODY_MAX_LINES)) out.push({ text: l.replace(/\s+$/, ''), tone: 'text' });
64
+ if (lines.length > BODY_MAX_LINES) out.push({ text: `… ${lines.length - BODY_MAX_LINES} more lines. Full message: quox mail list -o json`, tone: 'dim' });
65
+ } else if (m.snippet) {
66
+ out.push({ text: m.snippet, tone: 'text' });
67
+ out.push({ text: '(preview only: the full body has not been fetched from the mailbox yet)', tone: 'dim' });
68
+ } else out.push({ text: '(no body stored)', tone: 'dim' });
69
+ if (n) out.push({ text: `/mail for the list · read only: nothing is sent, moved or deleted here`, tone: 'dim' });
70
+ return out;
71
+ }
@@ -12,7 +12,7 @@ import { loadRules, rulesContext } from './rules.js';
12
12
  import { createTranscript } from './transcript.js';
13
13
  import { createScreenRecorder, screenFramesEnabled } from './screenFrames.js';
14
14
  import { applySettings } from './slash/settings.js';
15
- import { headOf, RUNNING_ROOT, execRestart, restartPlan, checkRestart, startBuildWatch, settleMsFrom, restoreTerminal, RESTART_SLOT_ENV, RESTART_ATTACH_ENV, RESTART_NOTE_ENV, RESTART_MODE_ENV } from './slash/restart.js';
15
+ import { headOf, RUNNING_ROOT, execRestart, restartPlan, checkRestart, startBuildWatch, settleMsFrom, minGapMsFrom, restoreTerminal, RESTART_SLOT_ENV, RESTART_ATTACH_ENV, RESTART_NOTE_ENV, RESTART_MODE_ENV, RESTART_DECLINED_ENV } from './slash/restart.js';
16
16
  import { readLastSeen, writeLastSeen } from './away.js';
17
17
  import { fileHistory } from './promptHistory.js';
18
18
  import { takeLauncherHandoff, writeRestartHandoff, RESTART_EXIT_CODE } from './launcher.js';
@@ -21,6 +21,7 @@ export { parseArgs, USAGE, pickProfile, choosePick, chatCredentialProblem, noSer
21
21
  import { parseArgs, USAGE, noServerConfigured, mainPrint, pickProfile, chatCredentialProblem } from './headless.js';
22
22
 
23
23
  export { captureEarlyInput } from './earlyInput.js';
24
+ import { isNeverAnswered, healthyStamp } from './neverAnswered.js';
24
25
 
25
26
  /**
26
27
  * Clear the screen before the chat client? Never right after Getting started: its finale (the one-time
@@ -39,7 +40,7 @@ export async function main(argv = process.argv.slice(2)) {
39
40
  if (opts.errors.length) { early?.release(); process.stderr.write(`quox: ${opts.errors.join('; ')}\n${USAGE}\n`); process.exitCode = 1; return; }
40
41
  const cwd = process.cwd().replace(process.env.HOME || '\0', '~');
41
42
  const { resolveCliVersion } = await import('../core/version.js');
42
- let version = '0.3.1';
43
+ let version = '0.5.0';
43
44
  try { version = resolveCliVersion?.() || version; } catch { /* keep default */ }
44
45
  if (opts.print) return mainPrint(opts);
45
46
  let replay = null;
@@ -56,6 +57,15 @@ export async function main(argv = process.argv.slice(2)) {
56
57
  // and a fresh install replays the original request as the first real turn
57
58
  replay = r.replay || null;
58
59
  fromGettingStarted = true;
60
+ // QUOXON-Q: ONE offer for a short `q` command, asked once (answer kept in the CLI config)
61
+ if (process.stdin.isTTY && process.stdout.isTTY && !opts.demoOnboarding) {
62
+ try {
63
+ const { offerShortcut } = await import('./onboarding/shortcutOffer.js');
64
+ const { createInterface } = await import('node:readline/promises');
65
+ const config = (await import('../core/config.js')).loadConfig();
66
+ await offerShortcut({ config, ask: async (line) => { const rl = createInterface({ input: process.stdin, output: process.stdout }); try { return await rl.question(`\n${line} `); } finally { rl.close(); } } });
67
+ } catch { /* an offer must never block the first conversation */ }
68
+ }
59
69
  }
60
70
  let props;
61
71
  let restart = null; // set by /restart: run the installed quox here once the app has let go of the terminal
@@ -95,6 +105,9 @@ export async function main(argv = process.argv.slice(2)) {
95
105
  if (problem) return problem.message;
96
106
  if (pick.note) return pick.note;
97
107
  const hl = await api.health(8000);
108
+ if (hl.ok && !missing) { // ONBOARD-P2: remember that this profile's server has answered, so a later outage keeps the reconnect spinner
109
+ try { const stamp = healthyStamp(config.getProfile?.(pname), Date.now()); if (stamp) config.upsertProfile?.(pname, { lastHealthyAt: stamp }); } catch { /* history is best effort */ }
110
+ }
98
111
  return hl.ok ? null : hl.absent ? `No QuoxCORE running at ${hl.url}` : `QuoxCORE at ${hl.url} is not answering (${hl.status}); messages may be slow`;
99
112
  };
100
113
  // /restart hand-over: re-enter the predecessor window's org in this window only (the profile is untouched).
@@ -118,7 +131,7 @@ export async function main(argv = process.argv.slice(2)) {
118
131
  return { ok: true, api: a2, agentLogin: residentNames().includes(name), orgName: (c && !c.error && c.name) || c2.getAuth?.()?.org_name || null, note };
119
132
  };
120
133
  const loginCandidates = profiles.filter(p => !p.loggedIn && !residentNames().includes(p.name)).map(p => p.name);
121
- props = { api, switchProfile, loginCandidates, away: { read: readLastSeen, write: writeLastSeen }, modelChoice: opts.provider, resume: opts.resume ?? null, onRestart: (r) => { restart = r; }, onDemo: () => { restart = { demo: true }; }, restartCheck: checkRestart, buildWatch: (o) => startBuildWatch({ ...o, settleMs: settleMsFrom() }), startSha: headOf(RUNNING_ROOT), rules: loadRules(), profile: pname, agentLogin: (probe.listResidentProfiles?.() || []).some(r => r.name === pname), humanProfiles: profiles.filter(p => p.loggedIn && !(probe.listResidentProfiles?.() || []).some(r => r.name === p.name)).map(p => p.name), org: missing ? null : ((carried && !carried.error && carried.name) || config.getAuth?.()?.org_name || null), startup: startupCarry, healthRecheck: () => api.health(8000), blocked: problem && !signedOut ? problem.message : null, signedOut, autoplay: replay };
134
+ props = { api, switchProfile, loginCandidates, startupSwitchOffer: true, away: { read: readLastSeen, write: writeLastSeen }, modelChoice: opts.provider, resume: opts.resume ?? null, onRestart: (r) => { restart = r; }, onDemo: () => { restart = { demo: true }; }, restartCheck: checkRestart, buildWatch: (o) => startBuildWatch({ ...o, settleMs: settleMsFrom(), minGapMs: minGapMsFrom() }), startSha: headOf(RUNNING_ROOT), rules: loadRules(), profile: pname, agentLogin: (probe.listResidentProfiles?.() || []).some(r => r.name === pname), humanProfiles: profiles.filter(p => p.loggedIn && !(probe.listResidentProfiles?.() || []).some(r => r.name === p.name)).map(p => p.name), org: missing ? null : ((carried && !carried.error && carried.name) || config.getAuth?.()?.org_name || null), startup: startupCarry, neverAnswered: !missing && !signedOut && !problem && !pick.note && !opts.serviceKey && isNeverAnswered(config.getProfile?.(pname)), onConnect: (url) => { restart = { connect: url }; }, healthRecheck: () => api.health(8000), blocked: problem && !signedOut ? problem.message : null, signedOut, autoplay: replay };
122
135
  }
123
136
  // Interactive start: a clean screen under the intro (never when piped; NO_COLOR still clears),
124
137
  // except straight after Getting started, whose finale must stay readable.
@@ -144,13 +157,24 @@ export async function main(argv = process.argv.slice(2)) {
144
157
  const restartSlot = opts.demo ? null : (process.env[RESTART_SLOT_ENV] || null); delete process.env[RESTART_SLOT_ENV]; // one hand-over only
145
158
  const restartAttach = opts.demo ? null : (process.env[RESTART_ATTACH_ENV] || null); delete process.env[RESTART_ATTACH_ENV];
146
159
  const restartMode = opts.demo ? null : (process.env[RESTART_MODE_ENV] === 'plan' ? 'plan' : null); delete process.env[RESTART_MODE_ENV]; // PLAN MODE: window-scoped, carried once
160
+ const restartDeclined = opts.demo ? null : (process.env[RESTART_DECLINED_ENV] || null); delete process.env[RESTART_DECLINED_ENV]; // a slot this window said no to: not re-asked after the restart
147
161
  const restartNote = opts.demo ? null : (process.env[RESTART_NOTE_ENV] || null); delete process.env[RESTART_NOTE_ENV]; // e.g. a failed /update, said once in the restarted client
148
162
  if (restartNote && !props.introNote) props.introNote = restartNote;
149
- const app = render(React.createElement(App, { ...props, restartSlot, restartAttach, ...(restartMode ? { mode: restartMode } : {}), welcome, tipStartup: !fromGettingStarted && !opts.demo && Boolean(process.stdout.isTTY), whatsNewStartup: !opts.demo && Boolean(process.stdout.isTTY), version, cwd, verbose: opts.verbose, earlyInput: early, sessionName: opts.name, resumeConversation: opts.conversation || null, invite: Boolean(process.stdout.isTTY) && !opts.name, slotsAvailable, transcript, historyStore: opts.demo ? null : fileHistory() }), inkRenderOptions(process.stdout, { tap: screenRec?.tap }));
163
+ const app = render(React.createElement(App, { ...props, restartSlot, restartAttach, restartDeclined, ...(restartMode ? { mode: restartMode } : {}), welcome, tipStartup: !fromGettingStarted && !opts.demo && Boolean(process.stdout.isTTY), whatsNewStartup: !opts.demo && Boolean(process.stdout.isTTY), version, cwd, verbose: opts.verbose, earlyInput: early, sessionName: opts.name, resumeConversation: opts.conversation || null, invite: Boolean(process.stdout.isTTY) && !opts.name, slotsAvailable, transcript, historyStore: opts.demo ? null : fileHistory() }), inkRenderOptions(process.stdout, { tap: screenRec?.tap }));
150
164
  try { await app.waitUntilExit(); screenRec?.stop(); transcript?.end(restart ? 'restart' : 'exit'); }
151
165
  catch (e) { screenRec?.stop(); transcript?.end(`crash: ${e?.message || e}`); throw e; }
152
166
  finally { screenRec?.stop(); process.stdout.off('resize', onResize); }
153
167
  if (!restart) return;
168
+ if (restart.connect) { // ONBOARD-P2: "Connect to a different server?" said y; the connect flow owns the terminal (certificate card, sign-in)
169
+ restoreTerminal();
170
+ const { connectServer } = await import('./onboarding/connectServer.js');
171
+ const { profileNameForUrl } = await import('../core/profileTrust.js');
172
+ const { confirmInteractive } = await import('../core/confirm.js');
173
+ const profile = profileNameForUrl(restart.connect);
174
+ const c = await connectServer(restart.connect, { profile, trust: () => confirmInteractive(`Trust this server's certificate for profile "${profile}" only?`, { flag: '--trust-cert', purpose: 'trust it' }), loadConfigImpl: (await import('../core/config.js')).loadConfig });
175
+ if (c.action !== 'connected') { process.exitCode = 1; return; }
176
+ restart = { profile };
177
+ }
154
178
  if (restart.update) { // /update said y: the update owns the terminal (npm prompts, sudo), then the same restart either way
155
179
  const { runUpdate } = await import('../core/selfUpdate.js');
156
180
  const plan = restart.update;
@@ -165,7 +189,7 @@ export async function main(argv = process.argv.slice(2)) {
165
189
  }
166
190
  }
167
191
  // The app preflighted the installed quox before letting go, so a failure here is rare (it changed in between).
168
- const how = { argv: restart.demo ? ['--demo', '--autoplay'] : argv, conversationId: restart.demo ? null : restart.conversationId, profile: opts.demo ? null : (restart.profile || props.profile), name: restart.name || opts.name || null, slot: restart.demo ? null : (restart.slot || null), attach: restart.demo ? null : (restart.attach || null), org: restart.demo ? null : (restart.org || null), mode: restart.demo ? null : (restart.mode || null), bin: restart.bin || undefined };
192
+ const how = { argv: restart.demo ? ['--demo', '--autoplay'] : argv, conversationId: restart.demo ? null : restart.conversationId, profile: opts.demo ? null : (restart.profile || props.profile), name: restart.name || opts.name || null, slot: restart.demo ? null : (restart.slot || null), attach: restart.demo ? null : (restart.attach || null), org: restart.demo ? null : (restart.org || null), mode: restart.demo ? null : (restart.mode || null), declined: restart.demo ? null : (restart.declined || null), bin: restart.bin || undefined };
169
193
  // QUOXON-RESTART-LEAK: under the launcher this process EXITS and the launcher runs the successor, so exactly one
170
194
  // client stays alive per terminal. Without one (QUOX_NO_LAUNCHER=1) it falls back to waiting on a nested child.
171
195
  const launched = takeLauncherHandoff();
@@ -63,7 +63,7 @@ export function inlineText(tokens = []) {
63
63
  return tokens.map(t => {
64
64
  if (t.tokens) return inlineText(t.tokens);
65
65
  if (t.type === 'br') return ' ';
66
- return t.text ?? t.raw ?? '';
66
+ return typeof t.text === 'string' ? t.text : typeof t.raw === 'string' ? t.raw : ''; // never an object: it would print [object Object]
67
67
  }).join('').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
68
68
  }
69
69
 
@@ -90,6 +90,21 @@ export const choiceId = (ch) => (ch ? rowOf(ch)?.id || null : null);
90
90
  export const connectedChoices = (res, caps = null) =>
91
91
  CHOICES.filter(c => c.served !== false && availability(res, c, caps).state === 'connected');
92
92
 
93
+ /** Health states that mean a provider refuses turns right now. */
94
+ const REFUSING = new Set(['limited', 'no_credits']);
95
+ /**
96
+ * The active model's provider is limited / out of credits: the first CHOICES row the server reports ready whose
97
+ * provider health does not also refuse turns. -> { id, label, why } | null (no usable alternative = no offer).
98
+ */
99
+ export function limitedOffer(health, current, res, caps = null, mine = null) {
100
+ const bad = new Set((Array.isArray(health) ? health : []).filter(e => REFUSING.has(e?.state)).map(e => String(e.provider || '').toLowerCase()));
101
+ const active = String(current?.provider || mine || '').toLowerCase();
102
+ if (!active || !bad.has(active)) return null;
103
+ const alt = connectedChoices(res, caps).find(c => !bad.has(c.provider) && c.provider !== active);
104
+ if (!alt) return null;
105
+ return { id: alt.id, label: alt.name, why: alt.id === 'ollama' ? 'local Ollama, ready on this server' : 'ready on this server' };
106
+ }
107
+
93
108
  const same = (a, b) => (a?.provider || null) === (b?.provider || null) && (a?.via || null) === (b?.via || null);
94
109
 
95
110
  /** -> [[text, tone?]] lines for /model. `caps` = api.capabilities() (the server's word on each provider). */
@@ -17,6 +17,17 @@
17
17
  */
18
18
  import { graphemes, composerFrom } from './textBuffer.js';
19
19
 
20
+ /** The logical line (between newlines) holding buffer index `idx` -> { from, to }; a triple-click selects it. */
21
+ export function lineAt(text, idx) {
22
+ const t = String(text);
23
+ const i = Math.max(0, Math.min(idx, t.length));
24
+ const from = t.lastIndexOf('\n', i - 1) + 1;
25
+ const nl = t.indexOf('\n', i);
26
+ return { from, to: nl < 0 ? t.length : nl };
27
+ }
28
+
29
+ export const MOUSE_HINT = 'Click your draft to place the cursor, drag to select (copies), double-click a word, triple-click a line. Shift+drag uses your terminal\'s own selection in most terminals. /mouse off gives native selection back.';
30
+
20
31
  export const MOUSE_ON = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
21
32
  export const MOUSE_OFF = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
22
33
  export const DSR = '\x1b[6n';
@@ -26,7 +37,7 @@ export const DOUBLE_MS = 400;
26
37
  export const DSR_TIMEOUT_MS = 400;
27
38
 
28
39
  export function mouseOnNote() {
29
- return 'Mouse on. Click a ◆ sender to reply, click a card\'s y / n / c to answer, click or drag in your draft to place the cursor or select (Backspace deletes, typing replaces, double-click selects a word). While it is on your terminal sends clicks and the wheel to Quox: hold Shift to select or copy text (Option on macOS Terminal and iTerm2), and use Shift+wheel or the scrollbar to scroll back. /mouse off returns all of it.';
40
+ return 'Mouse on. Click a ◆ sender to reply, click a card\'s y / n / c to answer, click or drag in your draft to place the cursor or select (Backspace deletes, typing replaces, double-click selects a word, triple-click a line). While it is on your terminal sends clicks and the wheel to Quox: hold Shift to select or copy text (Option on macOS Terminal and iTerm2), and use Shift+wheel or the scrollbar to scroll back. /mouse off returns all of it.';
30
41
  }
31
42
 
32
43
  // ---- cell widths --------------------------------------------------------------------------------
@@ -0,0 +1,20 @@
1
+ /**
2
+ * ONBOARD-P2: has this profile's server ever answered from this machine?
3
+ * A profile that has not (no recorded health success, never signed in) and whose server is dead is a first-run
4
+ * problem (wrong address), not an outage, so the TUI offers to connect elsewhere instead of spinning "restarting".
5
+ */
6
+ const HOUR = 3600 * 1000;
7
+
8
+ export function isNeverAnswered(profile) {
9
+ if (!profile) return false;
10
+ if (profile.lastHealthyAt) return false;
11
+ if (profile.auth?.access_token) return false; // a sign-in worked once, so the server answered
12
+ return true;
13
+ }
14
+
15
+ /** The ISO time to record after a successful health probe, or null when a recent stamp already exists. */
16
+ export function healthyStamp(profile, now = Date.now()) {
17
+ const prev = Date.parse(profile?.lastHealthyAt || '');
18
+ if (Number.isFinite(prev) && now - prev < HOUR) return null;
19
+ return new Date(now).toISOString();
20
+ }