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,160 @@
1
+ /**
2
+ * ONE moment engine for both dragon systems. A "moment" is a stream fact worth a dragon: an approval is waiting, a turn or the
3
+ * stream failed, a long turn finished, a turn finished. The classification (and its dedupe) lives here once; the table says what
4
+ * each moment calls: `mood` is the Q dragon's cinematic clip (qmotion playDragon), `companion` is whether an attached companion
5
+ * dragon shows itself. The controller (dragonCompanion.js) runs the engine on every stream fact and owns the routing: while a
6
+ * companion owns the moments the Q dragon stays quiet, so a moment is never two dragons.
7
+ */
8
+ import { dragonForAgent } from '../dragon/select.js';
9
+
10
+ export const MOMENTS = Object.freeze({
11
+ approval: Object.freeze({ mood: 'waiting', companion: true }),
12
+ failure: Object.freeze({ mood: 'failure', companion: true }),
13
+ long: Object.freeze({ mood: 'success', companion: true }),
14
+ success: Object.freeze({ mood: 'success', companion: false }),
15
+ });
16
+ /** A turn at least this long counts as a "long task" when it finishes. */
17
+ export const LONG_TASK_MS = 30000;
18
+
19
+ export function createMomentEngine({ now = Date.now } = {}) {
20
+ const seen = new Set();
21
+ let turnStart = null;
22
+ const moment = (kind) => ({ kind, ...MOMENTS[kind] });
23
+ return {
24
+ turnStarted() { turnStart = now(); },
25
+ reset() { seen.clear(); turnStart = null; },
26
+ /** A reducer event (the companion's vocabulary) -> { kind, mood, companion } | null. */
27
+ observe(ev) {
28
+ if (!ev) return null;
29
+ if (ev.type === 'approval.requested') {
30
+ // The same approval arrives twice (the stream event, then the inbox poll) under different ids: one moment for it.
31
+ const keys = [ev.call_id, ev.inbox_item_id, ev.approval_id].filter(Boolean).map(String);
32
+ if (keys.some(k => seen.has(k))) return null;
33
+ keys.forEach(k => seen.add(k));
34
+ return moment('approval');
35
+ }
36
+ if (ev.type === 'error') return moment('failure');
37
+ if (ev.type === 'turn.finished') {
38
+ const started = turnStart; turnStart = null;
39
+ if (ev.status === 'failed' || ev.status === 'unconfirmed') return moment('failure');
40
+ if (ev.status !== 'completed') return null;
41
+ return moment(started != null && now() - started >= LONG_TASK_MS ? 'long' : 'success');
42
+ }
43
+ return null;
44
+ },
45
+ };
46
+ }
47
+
48
+ // ---- domain moments: a fact from the estate summons the matching dragon (companion mode `on` only) ------------------------
49
+ /** A dragon summoned by a domain moment cannot be summoned again for this long. */
50
+ export const SUMMON_COOLDOWN_MS = 60000;
51
+
52
+ /**
53
+ * An objective or loop roster row -> { dragon, caption } when it has finished or failed, else null. The caption states only
54
+ * what the row says. The roster row carries no agent, so the dragon is Seer (the plan dragon); an agent field would pick its own.
55
+ */
56
+ export function objectiveMoment(row) {
57
+ if (!row || (row.kind !== 'objective' && row.kind !== 'loop') || (row.state !== 'done' && row.state !== 'failed')) return null;
58
+ const noun = row.kind === 'loop' ? 'Loop' : 'Objective', name = row.name || row.id;
59
+ const caption = row.state === 'done' ? `${noun} finished: ${name}.` : `${noun} failed: ${name}${row.activity ? ` (${row.activity})` : ''}.`;
60
+ return { dragon: 'seer', caption };
61
+ }
62
+
63
+ /**
64
+ * Watches roster rows and reports each objective/loop that was seen unfinished and is now done or failed, once. The first
65
+ * observation is a baseline (rows already finished at startup never fire); a reported key is retired for good.
66
+ */
67
+ export function createRosterWatch() {
68
+ const active = new Set(), retired = new Set();
69
+ let primed = false;
70
+ return {
71
+ observe(rows = []) {
72
+ const out = [];
73
+ for (const r of rows) {
74
+ if (!r || (r.kind !== 'objective' && r.kind !== 'loop') || retired.has(r.key)) continue;
75
+ if (r.state === 'done' || r.state === 'failed') {
76
+ if (active.delete(r.key) && primed) { const m = objectiveMoment(r); if (m) out.push(m); }
77
+ retired.add(r.key);
78
+ } else active.add(r.key);
79
+ }
80
+ primed = true;
81
+ return out;
82
+ },
83
+ };
84
+ }
85
+
86
+ // ---- sender moments: a named session or agent speaks to the owner, so its own dragon appears (companion mode `on` only) ---------
87
+ /** The same sender cannot call its dragon again for this long once it has shown (a chatty session is one dragon, not a parade). */
88
+ export const SENDER_COOLDOWN_MS = 5 * 60 * 1000;
89
+ /** Only a message this recent is a moment: a backlog replayed on (re)connect is history, not news. */
90
+ export const SLOT_FRESH_MS = 2 * 60 * 1000;
91
+
92
+ const head = (s) => String(s).split(/[\s\-_.:/]+/)[0];
93
+ /** One name -> a dragon id: the whole name, its first word, or that word without trailing digits (quoxperticus-126). */
94
+ function dragonForName(name) {
95
+ const s = String(name ?? '').trim();
96
+ if (!s) return null;
97
+ return dragonForAgent(s) || dragonForAgent(head(s)) || dragonForAgent(head(s).replace(/\d+$/, ''));
98
+ }
99
+ /**
100
+ * A slot message sender -> its agent's dragon, or null. `from` is "slot (display)" or a bare name. The speaker (the display name
101
+ * in brackets) wins, then the bare name, then the slot it holds. Only approved dragons (select.js AGENT_DRAGON) can match.
102
+ */
103
+ export function dragonForSender(m) {
104
+ if (!m) return null;
105
+ const from = String(m.from ?? '').trim();
106
+ const inner = (from.match(/\(([^)]*)\)\s*$/) || [])[1];
107
+ const bare = from.replace(/\s*\(.*$/, '');
108
+ for (const n of [inner, bare, m.from_slot]) { const d = dragonForName(n); if (d) return d; }
109
+ return null;
110
+ }
111
+ const senderKey = (m) => String(m?.from_slot || m?.from || '').replace(/\s*\(.*$/, '').trim().toLowerCase();
112
+
113
+ /** A fresh slot message from a dragon session -> { dragon, sender, caption } | null. The caption says who and what kind, never the content. */
114
+ export function slotMessageMoment(m, { now = Date.now(), broadcast = false, run = false } = {}) {
115
+ const dragon = dragonForSender(m);
116
+ if (!dragon) return null;
117
+ const t = Date.parse(m.created_at ?? m.createdAt ?? '');
118
+ if (!Number.isFinite(t) || now - t > SLOT_FRESH_MS) return null;
119
+ const who = String(m.from).trim();
120
+ const caption = run ? `${who} asks you to run a command.` : broadcast ? `Broadcast from ${who}.` : `Message from ${who}.`;
121
+ return { dragon, sender: senderKey(m), caption };
122
+ }
123
+
124
+ /** Another session's approval card newly waiting, asked by an agent with a dragon -> { dragon, sender, key, caption } | null. */
125
+ export function waitingCardMoment(prev, next) {
126
+ const before = new Set((prev?.waiting || []).map(c => c.inboxItemId));
127
+ for (const c of next?.waiting || []) {
128
+ if (!c || before.has(c.inboxItemId)) continue;
129
+ const who = c.origin || c.askedBy;
130
+ const dragon = dragonForName(who) || dragonForName(c.askedBy);
131
+ if (!dragon) continue;
132
+ return { dragon, sender: String(who).toLowerCase(), key: c.inboxItemId, caption: `${who} wants your approval${c.tool ? ` (${c.tool})` : ''}.` };
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Dedupe + per-sender cooldown for sender moments. observe() remembers the message id at once (a re-delivery is never a second
139
+ * moment); shown() starts the sender's cooldown, so a moment refused because the owner was typing does not silence that sender.
140
+ */
141
+ export function createSenderWatch({ now = Date.now, cooldownMs = SENDER_COOLDOWN_MS } = {}) {
142
+ const seen = new Set(), last = new Map();
143
+ const remember = (k) => { seen.add(k); if (seen.size > 500) seen.delete(seen.values().next().value); };
144
+ const cool = (sender) => { const t = last.get(sender); return t != null && now() - t < cooldownMs; };
145
+ return {
146
+ observe(m, opts = {}) {
147
+ const id = m?.event_id || m?.id || (m?.created_at ? `${m.created_at}|${m.seq ?? ''}|${m.from ?? ''}` : null);
148
+ if (id != null) { if (seen.has(String(id))) return null; remember(String(id)); }
149
+ const mo = slotMessageMoment(m, { ...opts, now: now() });
150
+ return mo && !cool(mo.sender) ? mo : null;
151
+ },
152
+ card(prev, next) {
153
+ const mo = waitingCardMoment(prev, next);
154
+ if (!mo || seen.has(`card:${mo.key}`)) return null;
155
+ remember(`card:${mo.key}`);
156
+ return cool(mo.sender) ? null : mo;
157
+ },
158
+ shown(sender) { last.set(sender, now()); },
159
+ };
160
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The reads behind the dragon jobs (lib/dragon/jobs.js). Each is the same call a `quox` verb already makes, read-only,
3
+ * bounded, retries off. The CLI client throws on a non-2xx, so a throw is the failure and a returned body is checked for the
4
+ * error shapes some routes use with a 200 (success:false). Nothing here turns an error into a count.
5
+ */
6
+ import { resolveEndpoint } from '../api/endpoints.js';
7
+ import { healthOf } from './objects/fleet.js';
8
+
9
+ const TIMEOUT = 5000;
10
+ const STATE = { 1: 'up', 0: 'down', 2: 'pending', 3: 'maintenance' };
11
+ const kumaState = (m) => (m && typeof m.state === 'string' ? m.state : m && Object.hasOwn(STATE, m.status) ? STATE[m.status] : 'unknown'); // same mapping as `quox kuma`
12
+ const whyOf = (e) => (e?.status ? `HTTP ${e.status}` : /timed out/i.test(e?.message || '') ? 'timed out' : e?.message || 'failed');
13
+
14
+ export function createDragonReads({ client }) {
15
+ const get = (name, params, query) => { const ep = resolveEndpoint(name, params); return client.get(ep.path, { service: ep.service, query, retries: 0, timeout: TIMEOUT }); };
16
+ return {
17
+ /** WARD inclusion receipt of one envelope (POST /ward/verify-receipt, the route /proof uses). -> { ok, result } | { ok:false, error } */
18
+ async verifyReceipt(envelopeId) {
19
+ try {
20
+ const d = await client.post('/ward/verify-receipt', { service: 'collector', body: { source_id: envelopeId, source_kind: 'AEE' }, retries: 0, timeout: TIMEOUT });
21
+ return { ok: true, result: d?.result || d };
22
+ } catch (e) { return { ok: false, error: whyOf(e) }; }
23
+ },
24
+ /** `quox kuma` live read -> { up, down, total, avgPing, downNames, ageSeconds } */
25
+ async routes() {
26
+ const d = await get('mon.kuma.live.get');
27
+ if (d?.success === false) throw new Error(`kuma returned an error body: ${d.error || 'no detail'}`);
28
+ if (!Array.isArray(d?.monitors)) throw new Error('kuma answered with no monitors list');
29
+ const ms = d.monitors;
30
+ const up = ms.filter(m => kumaState(m) === 'up'), down = ms.filter(m => kumaState(m) === 'down');
31
+ const pings = up.filter(m => m.ping != null).map(m => Number(m.ping)).filter(Number.isFinite); // a null ping is no measurement, not 0 ms
32
+ return { up: up.length, down: down.length, total: ms.length, avgPing: pings.length ? pings.reduce((a, b) => a + b, 0) / pings.length : null, downNames: down.map(m => m.name || `monitor ${m.id}`), ageSeconds: Number.isFinite(d.ageSeconds) ? d.ageSeconds : null };
33
+ },
34
+ /** `quox plan focus` -> { id, done, total } of the first focus stream; null when nothing is in focus or there is no roadmap. */
35
+ async plan() {
36
+ const rms = (await get('quoxplan.roadmaps.list'))?.roadmaps;
37
+ if (!Array.isArray(rms)) throw new Error('quoxplan answered with no roadmaps list');
38
+ if (!rms.length) return null;
39
+ const streams = (await get('quoxplan.roadmap.streams.list', { roadmapId: rms[0].id }, { lens: 'focus' }))?.streams;
40
+ if (!Array.isArray(streams)) throw new Error('quoxplan answered with no streams list');
41
+ if (!streams.length) return null;
42
+ // The first few focus streams, in the server's order: the first one that has phases gives "x of y". A stream with none (a meta
43
+ // stream) or whose phases could not be read is only named, never given a made-up count.
44
+ const top = streams.slice(0, 3);
45
+ const details = await Promise.allSettled(top.map(t => get('quoxplan.stream.get', { id: t.id })));
46
+ const rows = top.map((t, i) => {
47
+ const phases = details[i].status === 'fulfilled' && Array.isArray(details[i].value?.phases) ? details[i].value.phases : null;
48
+ return phases ? { id: t.id, done: phases.filter(p => p.done_at || /^(done|complete)/i.test(String(p.status || ''))).length, total: phases.length } : { id: t.id, done: null, total: null };
49
+ });
50
+ return rows.find(r => r.total > 0) || rows[0];
51
+ },
52
+ /** `quox fleet hosts` rows bucketed by health. */
53
+ async fleet() {
54
+ const d = await get('v1.fleet.hosts.list');
55
+ const hosts = Array.isArray(d) ? d : d?.hosts;
56
+ if (!Array.isArray(hosts)) throw new Error('unexpected fleet response');
57
+ const c = { total: hosts.length, healthy: 0, degraded: 0, unhealthy: 0, offline: 0, unknown: 0 };
58
+ for (const h of hosts) c[healthOf(h)]++;
59
+ return c;
60
+ },
61
+ /** The org's open security findings (engagement store). */
62
+ async findings() {
63
+ const d = await get('engagement.findings.open', {});
64
+ if (!Array.isArray(d?.findings)) throw new Error('unexpected findings response');
65
+ return { open: d.findings.length, severe: d.findings.filter(f => /^(critical|high)$/i.test(String(f.severity || ''))).length };
66
+ },
67
+ };
68
+ }
@@ -32,10 +32,10 @@ export function DragonTalk({ cols, label, talking, since, still = false }) {
32
32
  }
33
33
 
34
34
  /** /dragon-fly: one pass of the dragon in flight across the live region (qdragonFly.js). `still`: a single frame, no motion. */
35
- export function FlyLive({ cols, rows, startedAt, still = false }) {
35
+ export function FlyLive({ cols, rows, startedAt, still = false, chromeH = null }) {
36
36
  useFrame();
37
37
  const c = flyCols(cols);
38
- const cells = flyFrame(Date.now() - startedAt, c, { still, mono: mono(), theme: isLight() ? 'light' : 'dark', termRows: rows });
38
+ const cells = flyFrame(Date.now() - startedAt, c, { still, mono: mono(), theme: isLight() ? 'light' : 'dark', termRows: rows, chromeH });
39
39
  return h(Box, { flexDirection: 'column', marginTop: 1 },
40
40
  ...cells.map((row, k) => h(Text, { key: k, wrap: 'truncate' }, ...cellNodes(row, `f${k}-`))));
41
41
  }
@@ -10,6 +10,7 @@ export const ECHO_CAP = 2000;
10
10
  export function echoLine(text, { cols = 100 } = {}) {
11
11
  let t = String(text ?? '').trim();
12
12
  if (PIN_CMD.test(t)) t = t.split(/\s+/)[0]; // a stray argument may be a PIN: never shown
13
+ if (/^\/verify-email(\s|$)/i.test(t) && /(^|\D)\d{6}(\D|$)/.test(t)) t = '/verify-email'; // a 6-digit run may be a PIN typed by mistake: never shown
13
14
  const full = redactSecrets(t).text;
14
15
  return { shown: clip(full.replace(/\s*\n\s*/g, ' '), ECHO_CAP), full };
15
16
  }
@@ -11,8 +11,13 @@
11
11
  * turn : null | { id, status: running|interrupting, startedAt, stage, activity }
12
12
  */
13
13
 
14
+ import { eventLine } from '../core/announce.js';
15
+ import { isDestructive, isDestructiveTool, destructiveObject } from './destructive.js';
16
+ import { heldReason } from './bulkRisk.js';
17
+ import { approvalSentence } from './approvalSentence.js';
14
18
  import { dividerStep } from './dividerAocl.js';
15
19
  import { approvalFacts } from './approvalFacts.js';
20
+ import { planLines } from './planSteps.js';
16
21
  import { isConsentError, consentErrorText } from './consent.js';
17
22
  import { splitSettled } from './markdownSettle.js'; // not markdown.js: that loads React + Ink (QUOXON-LATENCY)
18
23
  import { nextActivity, actionActivity } from './activity.js';
@@ -78,6 +83,15 @@ export function textGate(st, ev) {
78
83
 
79
84
  const tms = (v) => { const t = typeof v === 'number' ? v : Date.parse(v); return Number.isFinite(t) ? t : null; };
80
85
 
86
+ /** A machine source key (`devsuite_pack_install:<host>:<pack>:<ver>`) is never shown as a requester: it names a human-readable origin instead. */
87
+ const SOURCE_KEY = /^[A-Za-z][\w.-]*(:[^:\s]+){2,}$/;
88
+ const SOURCE_NAMES = [[/^devsuite_/i, 'DevSuite (skill-pack sync)']];
89
+ export function humanRequester(v) {
90
+ const t = String(v ?? '').trim(); if (!t || !SOURCE_KEY.test(t)) return t || null;
91
+ const hit = SOURCE_NAMES.find(([re]) => re.test(t)); if (hit) return hit[1];
92
+ const head = t.split(':')[0].replace(/[_-]+/g, ' ').trim();
93
+ return head ? head.charAt(0).toUpperCase() + head.slice(1) : 'plugin or workflow';
94
+ }
81
95
  const short = (v, n = 120) => { const t = typeof v === 'string' ? v : (v == null ? '' : JSON.stringify(v)); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
82
96
  const argText = (a) => a && typeof a === 'object' && !Array.isArray(a) && (a.argv || a.command) ? argText(a.argv ?? a.command) : Array.isArray(a) ? a.join(' ') : (a && typeof a === 'object' ? Object.entries(a).filter(([, v]) => v != null && typeof v !== 'object').slice(0, 4).map(([k, v]) => `${k}=${v}`).join(' ') : (a == null ? '' : String(a)));
83
97
 
@@ -95,7 +109,7 @@ export function describeInboxItem(it) {
95
109
  const argv = m.argv ?? m.args ?? c.argv ?? c.args ?? p.argv ?? p.args ?? m.command ?? c.command ?? p.command ?? null;
96
110
  const cmd = short([m.leaf || c.leaf || p.leaf, argText(argv)].filter(Boolean).join(' ').trim() || null);
97
111
  const target = w.target || m.target || c.target || p.target || cmd || w.target_none || c.target_org || c.org_name || p.org_name || c.org_id || p.org_id || null;
98
- const by = ob.agent || ob.agent_id || ob.session_name || ob.session || c.requested_by?.agent_label || c.source_label || p.source || it?.source || null;
112
+ const by = humanRequester(ob.agent || ob.agent_id || ob.session_name || ob.session || c.requested_by?.agent_label || c.source_label || p.source || it?.source || null);
99
113
  const what = w.summary ? String(w.summary) : (it?.title ? short(String(it.title), 160) : null);
100
114
  return { tool, target: target ? short(target, 200) : null, title: what, by: by ? String(by) : null, at: it?.created_at ?? it?.createdAt ?? null };
101
115
  }
@@ -125,7 +139,11 @@ export function cardFromInboxItem(it, { since = 0, userId = null, conversationId
125
139
  return {
126
140
  callId: `inbox:${it.id}`, inboxItemId: it.id, approvalId,
127
141
  tool, target: d.target, what: d.title, askedBy: d.by, askedAt: d.at, scope: null, risk: c.risk || null,
128
- url: it.dashboard_url || null, shownAt: now, undescribable: !d.tool && !d.target,
142
+ url: it.dashboard_url || null, shownAt: now, undescribable: !d.tool && !d.target && !it._plan,
143
+ plan: it._plan ? { lines: planLines(it._plan.steps), loaded: it._plan.loaded !== false, orgId: it._plan.orgId || null, orgName: it._plan.orgName || null } : null, // the plan's steps + the item's own org
144
+ destructive: isDestructive(it) ? destructiveObject(it) : null, // typed-name confirm before a y approves it
145
+ disruptive: [it, it.payload, c, m, it.payload?.what].some(o => o && o.disruptive === true), // server-marked: never batched
146
+ held: heldReason(it), // a risky ask is never part of a batch card
129
147
  facts: approvalFacts(it), // APPROVAL-WWWH: the exact command, hosts, chain, reason, time and read-only class
130
148
  // only an item that names THIS conversation is ours; anything else came from another session and must not take the keyboard
131
149
  foreign: !here,
@@ -137,7 +155,7 @@ export function cardFromInboxItem(it, { since = 0, userId = null, conversationId
137
155
  /** Where an inbox approval came from: its source label, else the payload source, else its type. */
138
156
  export function approvalSourceLabel(it) {
139
157
  const c = it?.payload?.approval_context || {};
140
- return String(c.source_label || it?.payload?.source || it?.source || (c.requested_by && c.requested_by.agent_label) || it?.payload?.type || 'plugin or workflow');
158
+ return humanRequester(c.source_label || it?.payload?.source || it?.source || (c.requested_by && c.requested_by.agent_label) || it?.payload?.type || 'plugin or workflow');
141
159
  }
142
160
 
143
161
  /**
@@ -161,6 +179,18 @@ export function groupApprovals(items, { conversationId = null, cardIds = [] } =
161
179
  return Object.entries(g).filter(([, v]) => v.length).map(([group, v]) => ({ group, items: v }));
162
180
  }
163
181
 
182
+ /**
183
+ * QLOOP-LIVE: which approval card the keyboard goes to first. Cards about a loop THIS window is watching lead, then the rest of
184
+ * this conversation, then other sessions' items (stable inside each rank). The loop id appears in the card's target / description.
185
+ */
186
+ export function orderApprovals(cards, mineIds = []) {
187
+ const ids = (mineIds || []).filter(Boolean).map(String);
188
+ const rank = (c) => (ids.length && ids.some((id) => [c.target, c.what, c.facts?.where].some((t) => t != null && String(t).includes(id))) ? 0 : c.foreign ? 2 : 1);
189
+ return cards.map((c, i) => ({ c, i, r: rank(c) })).sort((a, b) => a.r - b.r || a.i - b.i).map((x) => x.c);
190
+ }
191
+ /** Cards raised by other sessions (the "other sessions: N" tail). */
192
+ export const otherSessionsCount = (cards) => (cards || []).filter((c) => c.foreign).length;
193
+
164
194
  /** Privileged/elevated grants (hat switches and anything the server marks privileged or elevated) need an explicit confirm. */
165
195
  export function isPrivileged({ tool = '', risk = null, flag = false, title = '' } = {}) {
166
196
  if (flag === true) return true;
@@ -345,6 +375,8 @@ function reduceInner(s, a) {
345
375
  return { ...s, history: [...s.history, { id: nid(), kind: 'bang', text: a.text }] };
346
376
  case 'slotmsg': // QUOXON-MESH: a slot chat line (slotChat.js slotLine), incoming or sent
347
377
  return { ...s, history: [...s.history, { id: nid(), kind: 'slotmsg', line: a.line }] };
378
+ case 'loopcard': // QLOOP-LIVE: a live loop card; the card object is mutated in place by loopCards.js
379
+ return { ...s, history: [...s.history, { id: nid(), kind: 'loopcard', card: a.card }] };
348
380
  case 'tip': // a "did you know" tip (tips.js); text + the command to try
349
381
  return { ...s, history: [...s.history, { id: nid(), kind: 'tip', text: a.text, cmd: a.cmd }] };
350
382
  case 'bcard': // QUOXON-BROADCAST: the sender's framed delivery card (slash/broadcast.js broadcastCard), pure data
@@ -360,7 +392,7 @@ function reduceInner(s, a) {
360
392
  case 'help':
361
393
  return { ...s, history: [...s.history, { id: nid(), kind: 'help', model: a.model }] };
362
394
  case 'object': // inline object view (objects/): pure data, rebuilt at render width
363
- return { ...s, history: [...s.history, { id: nid(), kind: 'object', obj: a.obj }] };
395
+ return { ...s, notify: scopeNotify(s.notify, a.obj), history: [...s.history, { id: nid(), kind: 'object', obj: a.obj }] };
364
396
  case 'error':
365
397
  return flush(addBlock(s, { kind: 'error', text: a.text }), false);
366
398
  case 'verbose':
@@ -418,11 +450,11 @@ function reduceInner(s, a) {
418
450
  const card = cardFromInboxItem(a.item, { since: s.turn?.startedAt ?? a.since ?? 0, userId: a.userId, conversationId: s.ctx.conversationId, now: a.now || 0 });
419
451
  if (card && card.approvalId) { // an event card raised without an inbox id learns it from the poll (else y answers a null id: 404)
420
452
  const j = s.approvals.findIndex(x => !x.inboxItemId && x.approvalId === card.approvalId);
421
- if (j >= 0) { const approvals = s.approvals.slice(); approvals[j] = { ...approvals[j], inboxItemId: card.inboxItemId, url: approvals[j].url || card.url, facts: approvals[j].facts || card.facts }; return { ...s, approvals, seenInbox: { ...s.seenInbox, [card.inboxItemId]: true } }; }
453
+ if (j >= 0) { const approvals = s.approvals.slice(); approvals[j] = { ...approvals[j], inboxItemId: card.inboxItemId, url: approvals[j].url || card.url, facts: approvals[j].facts || card.facts, destructive: approvals[j].destructive || card.destructive }; return { ...s, approvals, seenInbox: { ...s.seenInbox, [card.inboxItemId]: true } }; }
422
454
  }
423
455
  if (card && card.approvalId) { // an event stub that could not be described learns its description from the inbox item
424
456
  const j = s.approvals.findIndex(x => x.undescribable && (x.inboxItemId === card.inboxItemId || x.approvalId === card.approvalId));
425
- if (j >= 0 && !card.undescribable) { const approvals = s.approvals.slice(); approvals[j] = { ...approvals[j], tool: card.tool, target: card.target, what: card.what, askedBy: card.askedBy, askedAt: card.askedAt, facts: card.facts, undescribable: false }; return { ...s, approvals }; }
457
+ if (j >= 0 && !card.undescribable) { const approvals = s.approvals.slice(); approvals[j] = { ...approvals[j], tool: card.tool, target: card.target, what: card.what, askedBy: card.askedBy, askedAt: card.askedAt, facts: card.facts, destructive: approvals[j].destructive || card.destructive, undescribable: false }; return { ...s, approvals }; }
426
458
  }
427
459
  if (!card || s.seenInbox[card.inboxItemId] || s.approvals.some(x => x.inboxItemId === card.inboxItemId || (card.approvalId && x.approvalId === card.approvalId))) return s;
428
460
  if (card.foreign) { // another conversation's card: a compact notice, never the focused card
@@ -443,8 +475,12 @@ function reduceInner(s, a) {
443
475
  const rest = { ...s, inbox: (s.inbox || []).filter(x => x.id !== a.inboxItemId), seenInbox: a.inboxItemId ? { ...s.seenInbox, [a.inboxItemId]: true } : s.seenInbox, approvals: s.approvals.filter(x => x.callId !== a.callId && !(a.inboxItemId && x.inboxItemId === a.inboxItemId)), waiting: s.waiting.filter(x => x.callId !== a.callId && x.inboxItemId !== a.callId) };
444
476
  if (a.batch) return rest; // /approve N prints its own one-line summary
445
477
  if (a.quiet && a.action === 'approve') return rest;
446
- if (a.action !== 'approve') return note(rest, `denied: ${a.tool || 'approval'}${a.target ? ` on ${a.target}` : ''}`, 'warn');
447
- if (a.foreign) return note(rest, `✔ Approved ${a.tool || 'approval'}${a.target ? ` on ${a.target}` : ''}; the plugin or agent will act on it (its outcome is not tracked here)`, 'ok');
478
+ if (a.action !== 'approve') return note(rest, approvalSentence(a, false), 'warn');
479
+ if (a.foreign && a.approvalId) { // tracked: the poll adds one follow-up line with the outcome
480
+ const run = { callId: a.callId, approvalId: a.approvalId, tool: a.tool, target: a.target };
481
+ return note({ ...rest, running: [...rest.running.filter(x => x.callId !== a.callId), run] }, approvalSentence(a, true), 'ok');
482
+ }
483
+ if (a.foreign) return note(rest, approvalSentence(a, true), 'ok');
448
484
  if (a.tool === 'hat.switch') { // applied server-side by the inbox approve; nothing to wait for
449
485
  const to = String(a.target || '').split('\u2192').pop().replace(/\(privileged\)/, '').trim() || 'the new hat';
450
486
  return note(rest, `Hat switched to ${to}. Ask again and I'll carry on.`, 'ok');
@@ -458,7 +494,7 @@ function reduceInner(s, a) {
458
494
  const n = { ...s, running: s.running.filter(x => x.callId !== a.callId) };
459
495
  const what = run ? `${run.tool || 'approval'}${run.target ? ` on ${run.target}` : ''}` : 'approval';
460
496
  if (a.outcome === 'done') return note(n, `result: ${what}: ${a.text || 'completed'}`, 'ok');
461
- if (a.outcome === 'failed') return note(n, `failed: ${what}: ${a.text || 'error'}`, 'warn');
497
+ if (a.outcome === 'failed') return note(n, `${Number.isInteger(a.exit) && a.exit >= 1 ? `ran, exit ${a.exit}` : 'failed'}: ${what}: ${a.text || 'error'}`, 'warn');
462
498
  if (a.outcome === 'denied') return note(n, `denied: ${what}`, 'warn');
463
499
  // FW4: never silent and never a guess. Say what is unknown and where the authoritative answer is.
464
500
  const check = run && run.approvalId ? `quox approvals result ${run.approvalId} (or /approvals)` : '/approvals (no approval id was returned, so it cannot be tracked from here)';
@@ -478,6 +514,15 @@ function reduceInner(s, a) {
478
514
  }
479
515
 
480
516
  /** The first card is armed from the moment it is actually SHOWN, not from when it was received. */
517
+ /** An away/sitrep card that lists older items or a source it cannot see: the approvals bar must not say all-clear next to it. */
518
+ function scopeNotify(n, obj) {
519
+ const d = obj?.data;
520
+ if (!d || (obj.type !== 'away' && obj.type !== 'sitrep')) return n;
521
+ const outstanding = obj.type === 'away' ? (d.standing?.count || 0) : (d.needs || []).length;
522
+ const blind = Array.isArray(d.blind) ? d.blind.length : 0;
523
+ return { ...n, outstanding, blind };
524
+ }
525
+
481
526
  export function reduce(s, a) {
482
527
  let n = reduceInner(s, a);
483
528
  // TUI-DIVIDER: the bottom-rule AOCL strip, moved only by real turn events (dividerAocl.js).
@@ -485,6 +530,9 @@ export function reduce(s, a) {
485
530
  const dv = dividerStep(s.divider ?? null, a, a.now || 0);
486
531
  if (dv !== (n.divider ?? null)) n = { ...n, divider: dv };
487
532
  }
533
+ // A turn that ends WITHOUT a turn.finished event (stopped.local, turn.deferred, a /clear cancel) must not leave the
534
+ // strip open: endedAt stayed null, dividerLive stayed true, and the dot train + scrambled label looped on a dead turn.
535
+ if (n.divider && n.divider.endedAt == null && !n.turn) n = { ...n, divider: null };
488
536
  // The honest activity word: only real events move it (see activity.js).
489
537
  if (n.turn) {
490
538
  const act = a.type === 'event' ? nextActivity(s.turn?.act, a.ev, a.now || 0, { hasText: Boolean(n.turn.chars) })
@@ -558,6 +606,7 @@ function reduceEvent(s, ev, now) {
558
606
  let withTool = upsertTool(s, ev, {
559
607
  status, resultSummary: toolResultText(ev.result_summary, s.verbose), rawSummary: ev.result_summary, durationMs: ev.duration_ms,
560
608
  envelopeId: ev.envelope_id, receipt: ev.receipt || null,
609
+ ...(ev.card && typeof ev.card === 'object' && Array.isArray(ev.card.rows) ? { card: ev.card } : {}), // HITL-INBOX-BAR: a list answer's rows (toolCards.js)
561
610
  ...(Number.isFinite(ev.tools_used) ? { toolsUsed: ev.tools_used } : {})
562
611
  });
563
612
  if (first && withTool.turn) withTool = { ...withTool, turn: { ...withTool.turn, finishedTools: withTool.turn.finishedTools + 1 } };
@@ -572,7 +621,8 @@ function reduceEvent(s, ev, now) {
572
621
  target: ev.target, scope: ev.scope, risk: ev.risk, url: ev.dashboard_url, shownAt: now,
573
622
  undescribable: ev.describable === false || !ev.target,
574
623
  turnId: s.turn?.id ?? ev.turn_id ?? null, // the turn this card arrived in (a stream event is always this conversation)
575
- privileged: isPrivileged({ tool: ev.tool, risk: ev.risk, flag: ev.privileged === true || ev.elevated === true })
624
+ privileged: isPrivileged({ tool: ev.tool, risk: ev.risk, flag: ev.privileged === true || ev.elevated === true }),
625
+ destructive: ev.destructive === true || ev.irreversible === true || isDestructiveTool(ev.tool) ? (() => { const v = String(ev.object_name || ev.target || ev.inbox_item_id || ev.tool || 'item').slice(0, 80); return { label: v, name: v }; })() : null
576
626
  };
577
627
  if (s.approvals.some(x => x.callId === card.callId)) return s;
578
628
  const dup = s.approvals.findIndex(x => (card.inboxItemId && x.inboxItemId === card.inboxItemId) || (card.approvalId && x.approvalId === card.approvalId));
@@ -590,6 +640,13 @@ function reduceEvent(s, ev, now) {
590
640
  const n = { ...s, approvals: s.approvals.filter(x => x.callId !== ev.call_id) };
591
641
  return card || ev.decision ? note(n, `${ev.decision === 'approved' ? '✔ approved by you' : `approval ${ev.decision || 'resolved'}`}${card ? `: ${card.tool} on ${card.target}` : ''}`, ev.decision === 'approved' ? 'ok' : 'warn') : n;
592
642
  }
643
+ // QUOX-ANNOUNCE A2: a Quox effect of this turn (memory saved, approval held, delegation ...), one line, same renderer as the CLI verbs.
644
+ case 'quox_event': {
645
+ const text = eventLine(ev);
646
+ if (!text) return s;
647
+ if (ev.status === 'fail') return addBlock(s, { kind: 'error', text });
648
+ return addBlock(s, { kind: 'note', text, tone: ev.status === 'ok' ? 'ok' : ev.status === 'busy' ? 'accent' : 'warn' });
649
+ }
593
650
  case 'steer.accepted': {
594
651
  const i = s.steering.findIndex(x => x.text === ev.text);
595
652
  const steering = i >= 0 ? s.steering.filter((_, k) => k !== i) : s.steering.slice(1);