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,117 @@
1
+ // APPROVAL-BURST (owner 2026-10-28; 2026-10-27 09:53 VIGIL filed nine wire_alerts approvals in a minute as nine cards).
2
+ // 2+ pending approvals from the same agent for the same tool, arriving within 5 minutes of each other, are ONE card.
3
+ // A destructive, risky, privileged or step-up item is NEVER part of a batch: it keeps its own card and its own confirm.
4
+ // Pure helpers; app.js owns the keys and the calls.
5
+ import { describeInboxItem } from './events.js';
6
+ import { isDestructive } from './destructive.js';
7
+ import { heldReason } from './bulkRisk.js';
8
+
9
+ /** Tools that restart, reboot, run commands or change a running system: one keypress must never do that on several hosts. */
10
+ export const DISRUPTIVE_TOOL = /(^|_|\.)(restart|reboot|shutdown|poweroff|stop|kill|exec|shell|run_command|ssh|deploy|rollback|migrate|upgrade)(_|\.|$)/i;
11
+ export const isDisruptiveTool = (tool) => DISRUPTIVE_TOOL.test(String(tool ?? '').trim());
12
+ const flagged = (o) => Boolean(o && o.disruptive === true);
13
+ /** True when the server marks the item disruptive, else when its tool name matches the list. */
14
+ export function isDisruptive(it) {
15
+ const p = it?.payload || {}; const c = p.approval_context || {};
16
+ return flagged(it) || flagged(p) || flagged(c) || flagged(c.method) || flagged(p.what) || isDisruptiveTool(describeInboxItem(it).tool);
17
+ }
18
+
19
+ export const BATCH_WINDOW_MS = 5 * 60 * 1000;
20
+ export const BATCH_MAX_LINES = 8;
21
+
22
+ const ms = (v) => { const t = Date.parse(String(v ?? '').includes('T') || !v ? v : `${String(v).replace(' ', 'T')}Z`); return Number.isFinite(t) ? t : null; };
23
+ 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; };
24
+ const key = (agent, tool) => `${String(agent).trim().toLowerCase()}\u0000${String(tool).trim().toLowerCase()}`;
25
+
26
+ /** Chain by time: the members of `list` ({ t }) whose gaps to a neighbour inside the chain are <= window, starting from `from`. */
27
+ function chainFrom(list, from, window) {
28
+ const sorted = [...list].sort((a, b) => a.t - b.t);
29
+ const at = sorted.indexOf(from); if (at < 0) return [from];
30
+ let lo = at; let hi = at;
31
+ while (lo > 0 && sorted[lo].t - sorted[lo - 1].t <= window) lo--;
32
+ while (hi < sorted.length - 1 && sorted[hi + 1].t - sorted[hi].t <= window) hi++;
33
+ return sorted.slice(lo, hi + 1);
34
+ }
35
+
36
+ /** What the owner sees on a card of the batch: the item's target, else what it is, else a short id. */
37
+ export const batchLabel = (c) => clip(c.target || c.what || String(c.inboxItemId || c.callId || 'item').slice(0, 8), 120);
38
+
39
+ // ---- cards (the live approval queue) ---------------------------------------------------------
40
+ const cardTime = (c) => ms(c.askedAt) ?? c.shownAt ?? 0;
41
+ const cardAgent = (c) => c.askedBy || null;
42
+ const cardHeld = (c) => Boolean(c.destructive) || Boolean(c.privileged) || Boolean(c.undescribable) || Boolean(c.plan) || Boolean(c.held) || Boolean(c.disruptive) || isDisruptiveTool(c.tool) || /high|critical/i.test(String(c.risk || ''));
43
+ const cardBatchable = (c) => Boolean(c && c.inboxItemId && c.tool && cardAgent(c) && !cardHeld(c));
44
+
45
+ /**
46
+ * The batch the head card belongs to, or null (a lone card). `cards` is the queue in the order shown.
47
+ * -> { head, agent, tool, cards, heldDeletes, heldOther }. heldDeletes / heldOther count the same agent's destructive
48
+ * and other risky cards inside the same window: they are NOT in `cards` and keep their own card.
49
+ */
50
+ export function batchFor(cards, head) {
51
+ if (!cardBatchable(head)) return null;
52
+ const k = key(cardAgent(head), head.tool);
53
+ const mates = cards.filter(c => cardBatchable(c) && key(cardAgent(c), c.tool) === k).map(c => ({ c, t: cardTime(c) }));
54
+ const me = mates.find(m => m.c === head) || mates.find(m => m.c.callId === head.callId);
55
+ if (!me) return null;
56
+ const chain = chainFrom(mates, me, BATCH_WINDOW_MS);
57
+ if (chain.length < 2) return null;
58
+ const members = chain.map(m => m.c);
59
+ const lo = chain[0].t - BATCH_WINDOW_MS; const hi = chain[chain.length - 1].t + BATCH_WINDOW_MS;
60
+ const held = cards.filter(c => c !== head && !members.includes(c) && cardAgent(c) && String(cardAgent(c)).trim().toLowerCase() === String(cardAgent(head)).trim().toLowerCase() && cardHeld(c) && cardTime(c) >= lo && cardTime(c) <= hi);
61
+ return { head, agent: cardAgent(head), tool: head.tool, cards: members, heldDeletes: held.filter(c => c.destructive).length, heldOther: held.filter(c => !c.destructive).length };
62
+ }
63
+
64
+ /** The card's text: title, the item lines (at most 8, then "+K more"), the held-back note and the footer. */
65
+ export function batchModel(b) {
66
+ const n = b.cards.length;
67
+ const lines = b.cards.slice(0, BATCH_MAX_LINES).map(batchLabel);
68
+ const more = n - lines.length;
69
+ const notes = [];
70
+ if (b.heldDeletes) notes.push(`+${b.heldDeletes} delete${b.heldDeletes === 1 ? '' : 's'} held for you one by one`);
71
+ if (b.heldOther) notes.push(`+${b.heldOther} held for you one by one (risky)`);
72
+ return { n, title: `⚠ ${b.agent} wants to run ${b.tool} on ${n} items`, lines, more: more > 0 ? `+${more} more · p to see all` : null, notes, keys: `y approve all ${n} · n deny all ${n} · p pick one by one · Tab later` };
73
+ }
74
+
75
+ // ---- raw inbox items (the arrival bar) -------------------------------------------------------
76
+ const isApproval = (it) => Boolean(it?.payload?.approval_context) && it?.payload?.type !== 'decision';
77
+ const itemInfo = (it) => { const d = describeInboxItem(it); return { agent: d.by, tool: d.tool }; };
78
+ const itemBatchable = (it) => { if (!isApproval(it)) return false; const i = itemInfo(it); return Boolean(i.agent && i.tool) && !isDestructive(it) && !isDisruptive(it) && heldReason(it) === null && !/high|critical/i.test(String(it.payload?.approval_context?.risk || '')); };
79
+
80
+ /**
81
+ * Raw approval items grouped into bursts: [{ agent, tool, items }] for each run of 2+ from one agent and tool inside the window.
82
+ * Held, destructive and non-approval items are never in a burst.
83
+ */
84
+ export function bursts(items) {
85
+ const by = new Map();
86
+ for (const it of items || []) {
87
+ if (!itemBatchable(it)) continue;
88
+ const i = itemInfo(it); const k = key(i.agent, i.tool);
89
+ (by.get(k) || by.set(k, []).get(k)).push({ it, t: ms(it.created_at ?? it.createdAt) ?? 0, agent: i.agent, tool: i.tool });
90
+ }
91
+ const out = [];
92
+ for (const list of by.values()) {
93
+ const seen = new Set();
94
+ for (const m of [...list].sort((a, b) => a.t - b.t)) {
95
+ if (seen.has(m)) continue;
96
+ const chain = chainFrom(list, m, BATCH_WINDOW_MS); chain.forEach(x => seen.add(x));
97
+ if (chain.length >= 2) out.push({ agent: m.agent, tool: m.tool, items: chain.map(x => x.it) });
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /** The one arrival line for a burst. */
104
+ export const burstLine = (b) => `📨 ${clip(b.agent, 30)}: ${b.items.length} ${clip(b.tool, 40)} waiting · /approvals`;
105
+
106
+ /** Arrival lines for fresh items: a burst collapses into one line, everything else keeps its own (`lineOf(item)`). */
107
+ export function collapseArrivals(items, numOf, lineOf = null) {
108
+ const bs = bursts(items); const inBurst = new Map();
109
+ bs.forEach(b => b.items.forEach(it => inBurst.set(it, b)));
110
+ const out = []; const done = new Set();
111
+ for (const it of items || []) {
112
+ const b = inBurst.get(it);
113
+ if (!b) { out.push(lineOf ? lineOf(it, numOf(it)) : `📨 ${clip(it.title || it.kind || 'untitled', 70)} · /inbox${numOf(it) ? ` ${numOf(it)}` : ''}`); continue; }
114
+ if (done.has(b)) continue; done.add(b); out.push(burstLine(b));
115
+ }
116
+ return out;
117
+ }
@@ -3,7 +3,11 @@
3
3
  // (host count and names), WHO (the delegation chain and the human it acts for) and WHY (reason, the user's own words,
4
4
  // when). Pure, so the card, the /approvals row and /approvals N detail cannot drift apart.
5
5
 
6
+ import { startsBackgroundWork } from './backgroundWork.js';
7
+
6
8
  export const CMD_CARD_MAX = 400;
9
+ /** Server text can arrive HTML-escaped ("2&gt;/dev/null"); decode for display. Never renders HTML. */
10
+ const dec = (t) => (typeof t === 'string' ? t.replace(/&(amp|lt|gt|quot|apos|#39|#x27);/gi, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", '#x27': "'" })[e.toLowerCase()]) : t);
7
11
  const str = (v) => (typeof v === 'string' ? v : v == null ? '' : (() => { try { return JSON.stringify(v); } catch { return String(v); } })());
8
12
 
9
13
  /** Text cut at `max` with a visible, honest tail. `n` is the /approvals number when known. */
@@ -16,11 +20,11 @@ export function clipVisible(text, max, n = null) {
16
20
  /** The exact command or tool input, verbatim: method.command, else the reach request, else args. */
17
21
  export function commandOf(it) {
18
22
  const p = it?.payload || {}; const c = p.approval_context || {}; const m = c.method || {};
19
- if (typeof m.command === 'string' && m.command) return { text: m.command, kind: 'command' };
23
+ if (typeof m.command === 'string' && m.command) return { text: dec(m.command), kind: 'command' };
20
24
  const rq = (p.reach?.requests || []).find(r => r && r.kind === 'command' && typeof r.id === 'string' && r.id);
21
- if (rq) return { text: rq.id, kind: 'command' };
25
+ if (rq) return { text: dec(rq.id), kind: 'command' };
22
26
  const a = m.args ?? c.args ?? p.args ?? null;
23
- if (a != null && a !== '') return { text: str(a), kind: 'input' };
27
+ if (a != null && a !== '') return { text: dec(str(a)), kind: 'input' };
24
28
  return null;
25
29
  }
26
30
 
@@ -70,24 +74,76 @@ export function whoText(it) {
70
74
  /** WHY: the reason text (marked inferred when the caller did not state it). */
71
75
  export function whyText(it) {
72
76
  const c = it?.payload?.approval_context || {}; const r = c.reason;
73
- if (r && typeof r === 'object') return r.text ? `${r.text}${r.source === 'inferred' ? ' (inferred, not stated by the caller)' : ''}` : null;
74
- return r || it?.payload?.reason || null;
77
+ if (r && typeof r === 'object') return r.text ? `${dec(r.text)}${r.source === 'inferred' ? ' (inferred, not stated by the caller)' : ''}` : null;
78
+ return dec(r || it?.payload?.reason || null);
75
79
  }
76
80
 
77
81
  /** The user's own words that started this, when the server kept them. */
78
- export const excerptOf = (it) => it?.payload?.approval_context?.on_behalf_of?.request_excerpt || null;
82
+ export const excerptOf = (it) => dec(it?.payload?.approval_context?.on_behalf_of?.request_excerpt) || null;
83
+
84
+ /** The user's full ask when the server kept it (request_full), else the 300-char excerpt. */
85
+ const fullAskOf = (it) => dec(it?.payload?.approval_context?.on_behalf_of?.request_full) || excerptOf(it);
86
+
87
+ export const ASKED_BUDGET = 300;
88
+ const VERBS = [
89
+ [/delet|remov|destroy|drop|purge|wipe/, /\b(delet|remov|destroy|drop|purge|wipe|clean\s?up|tear\s?down|get rid)/i],
90
+ [/creat|build|add|new|make|install|register/, /\b(creat|build|add|make|set\s?up|install|register|new)/i],
91
+ [/run|exec|ssh|shell|start|trigger|invoke/, /\b(run|exec|ssh|start|trigger|invoke|launch|restart)/i],
92
+ [/updat|edit|modif|patch|chang|set|config/, /\b(updat|edit|modif|patch|chang|set|config|rename)/i],
93
+ [/restart|reboot|stop|kill/, /\b(restart|reboot|stop|kill|shut)/i],
94
+ [/send|post|publish|email|notify/, /\b(send|post|publish|email|notify)/i]
95
+ ];
96
+ const STOP = new Set(['tool', 'the', 'and', 'for', 'get', 'list', 'with', 'true', 'false', 'null']);
97
+
98
+ /** The sentences (and ", then"/"and finally" clauses) of an ask, in order. */
99
+ function clausesOf(ask) {
100
+ return ask.split(/(?<=[.!?])\s+|\n+/).flatMap(sn => sn.length > 160 ? sn.split(/,\s+(?=(?:and\s+)?(?:then|finally|after that|lastly)\b)/i) : [sn]).map(x => x.trim()).filter(Boolean);
101
+ }
102
+
103
+ /**
104
+ * Long ask: opening sentence (clipped) plus the clause that best matches the tool's verb and object and any id/name in its
105
+ * input, quoted in full. Null when the ask fits the budget. { text, none }: none = nothing in the ask matches the action.
106
+ */
107
+ export function askedFor(it, budget = ASKED_BUDGET) {
108
+ const ask = fullAskOf(it);
109
+ if (!ask || ask.length <= budget) return null;
110
+ const p = it?.payload || {}; const tool = String(p.toolName || p.approval_context?.method?.tool || '').toLowerCase();
111
+ const input = commandOf(it)?.text || '';
112
+ const parts = tool.split(/[^a-z0-9]+/).filter(w => w.length > 2);
113
+ const verbRe = (VERBS.find(([t]) => parts.some(w => t.test(w))) || [])[1] || null;
114
+ const objs = parts.filter(w => !STOP.has(w) && !VERBS.some(([t]) => t.test(w)));
115
+ const ids = [...new Set((input.match(/[A-Za-z0-9][A-Za-z0-9_.:-]{3,}/g) || []).filter(t => /[\d_-]/.test(t) && !STOP.has(t.toLowerCase())))];
116
+ const cl = clausesOf(ask);
117
+ const clip = (t, n) => (t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t);
118
+ let best = -1; let bi = -1;
119
+ cl.forEach((c, i) => {
120
+ if (!verbRe || !verbRe.test(c)) return; // the action's own verb must be in the clause
121
+ const lc = c.toLowerCase();
122
+ const sc = 2 + objs.filter(o => lc.includes(o)).length + 3 * ids.filter(id => lc.includes(id.toLowerCase())).length;
123
+ if (sc > best) { best = sc; bi = i; }
124
+ });
125
+ const open = `"${clip(cl[0] || ask, 120)}"`;
126
+ if (bi < 0) return { text: open, none: true };
127
+ if (bi === 0) return { text: `"${clip(cl[0], 240)}" (the part that asks for this)`, none: false };
128
+ return { text: `${open} … "${clip(cl[bi], 200)}" (the part that asks for this)`, none: false };
129
+ }
79
130
 
80
131
  /** One bag of the facts, for the card. */
81
132
  export function approvalFacts(it, { n = null } = {}) {
82
133
  const cmd = commandOf(it);
134
+ const af = askedFor(it);
83
135
  const when = it?.payload?.approval_context?.created_at || it?.created_at || it?.createdAt || null;
84
136
  return {
85
- command: cmd ? { ...cmd, shown: clipVisible(cmd.text, CMD_CARD_MAX, n) } : null,
137
+ // Only a command the server classed read-only may be clipped; anything else (write, or unclassified) is shown whole, so no part of it is hidden from the approver.
138
+ command: cmd ? { ...cmd, shown: accessOf(it) === 'read-only' ? clipVisible(cmd.text, CMD_CARD_MAX, n) : cmd.text } : null,
139
+ background: Boolean(cmd && cmd.kind === 'command' && startsBackgroundWork(cmd.text)),
86
140
  where: whereText(it, { n }),
87
141
  hostList: hostsOf(it).list,
88
142
  who: whoText(it),
89
143
  why: whyText(it),
90
- asked: excerptOf(it),
144
+ asked: af ? af.text : excerptOf(it),
145
+ askedNone: Boolean(af && af.none),
146
+ askedRich: Boolean(af),
91
147
  when: when ? String(when).replace('T', ' ').slice(0, 16) + ' UTC' : null,
92
148
  access: accessOf(it)
93
149
  };
@@ -0,0 +1,48 @@
1
+ // TUI-APPROVE: "You approved WHO to do WHAT on WHERE", built only from fields the card already shows.
2
+ // A field that is unknown is left out, never printed as "unknown".
3
+
4
+ const PLAIN = { // tool -> [infinitive, gerund]
5
+ ssh_exec: ['run a shell command', 'running a shell command'],
6
+ host_exec: ['run a shell command', 'running a shell command'],
7
+ host_restart: ['restart a service', 'restarting a service']
8
+ };
9
+
10
+ /** "VULCAN-SEC via FORGE (CLI) acting for Adam" -> "VULCAN-SEC". */
11
+ export function shortWho(raw) {
12
+ let w = String(raw || '').replace(/\s+acting for .*$/i, '').trim();
13
+ if (!w || /^unknown requester/i.test(w)) return '';
14
+ const sess = w.match(/\(via\s+session\s+([^,)]+)/i); // "FORGE (via session VULCAN-SEC, CLI)": the session is who asked
15
+ if (sess && sess[1].trim()) return sess[1].trim();
16
+ w = w.split('→').pop().split(/\s+via\s+/i)[0].replace(/\s*\(.*$/, '').trim();
17
+ return w;
18
+ }
19
+
20
+ const clean = (s) => String(s || '').replace(/\s+/g, ' ').replace(/[.\s]+$/, '').trim();
21
+
22
+ /** "4 hosts: a, b, c (+1 more, /approvals 3 detail for all)" -> "4 hosts: a, b, c (+1 more)": the hint is for the card, not the sentence. */
23
+ const tidyWhere = (w) => String(w || '').replace(/\s*\(\+(\d+) more[^)]*\)/, ' (+$1 more)');
24
+
25
+ function whatOf(a) {
26
+ const plain = PLAIN[a.tool];
27
+ if (plain) return plain;
28
+ const w = clean(a.what && a.what !== a.tool ? a.what : '');
29
+ if (w) return [w, w];
30
+ const t = clean(String(a.tool || '').replace(/_/g, ' '));
31
+ return t ? [`use ${t}`, `using ${t}`] : ['', ''];
32
+ }
33
+
34
+ /** The one- or two-line confirmation for an answered card. */
35
+ export function approvalSentence(a, approved) {
36
+ const who = shortWho(a.who);
37
+ const [inf, ger] = whatOf(a);
38
+ const where = tidyWhere(clean(a.where || a.target));
39
+ const said = (t) => where && new RegExp(`\\bon ${where.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'i').test(t); // WHAT already ends "on <where>"
40
+ const multi = /^\d+ hosts?:/i.test(where || '');
41
+ const at = where && !(multi && (/\bon \d+ hosts?:/i.test(inf) || /\bon \d+ hosts?:/i.test(ger))) && !(said(inf) || said(ger) || new RegExp(`\\b${where.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(inf)) ? ` on ${where}` : '';
42
+ if (approved) {
43
+ const tail = a.foreign && !a.approvalId ? ' It runs now; its result isn\'t tracked here.' : '';
44
+ const head = who ? `${who}${inf ? ` to ${inf}` : ''}` : (ger || 'the request');
45
+ return `\u2714 You approved ${head}${at}.${tail || (a.foreign ? ' It runs now; the result follows here.' : '')}`;
46
+ }
47
+ return `\u2716 You denied ${who ? `${who} ` : ''}${ger || 'the request'}${at}.`;
48
+ }
@@ -1,8 +1,11 @@
1
1
  // QUOXON-APPROVALS: pure helpers for /approvals rows, filters, detail and /approve|/deny argument parsing.
2
2
  import { describeInboxItem, approvalSourceLabel, isPrivileged } from './events.js';
3
+ import { startsBackgroundWork, BACKGROUND_NOTE } from './backgroundWork.js';
3
4
  import { commandOf, hostsOf, whereText, whoText, whyText, excerptOf, accessOf, clipVisible } from './approvalFacts.js';
4
5
 
5
6
  // quox ask: a decision request is an inbox item of kind question with payload.type 'decision'.
7
+ /** The ONE rule for "a yes needs the step-up PIN": approval_context.risk high or a high_risk flag. `quox approvals approve` and the walkthrough both use it. */
8
+ export const needsPin = (it) => it?.payload?.approval_context?.risk === 'high' || it?.payload?.high_risk === true;
6
9
  export const isDecision = (it) => it?.payload?.type === 'decision' && Array.isArray(it.payload.options);
7
10
  /** Who stands behind a recommendation: an explicit recommended_by, else the asking session or agent, else Quox. Never invented when there is no recommendation. */
8
11
  export const recommenderOf = (p) => String(p.recommended_by || p.session || p.agent || 'Quox');
@@ -110,12 +113,66 @@ export function filterPredicate(f, { userId = null, conversationId = null, now =
110
113
  return (it) => matchesAgent(it, f);
111
114
  }
112
115
 
116
+ /** What the item says it will do, in its own words: the server's what.summary or the post text, whichever is not just the generic "tool target". Null when there is nothing more specific. */
117
+ export function distinctWhat(it) {
118
+ const p = it?.payload || {}; const w = p.what && typeof p.what === 'object' ? p.what : {};
119
+ const generic = [w.action, w.target].filter(Boolean).join(' ').toLowerCase().replace(/\s+/g, ' ').trim();
120
+ const one = (t) => String(t ?? '').replace(/\s+/g, ' ').trim();
121
+ for (const c of [w.summary, p.action_params?.text, p.action_params?.body, p.action_params?.message]) {
122
+ const t = one(c); if (t && t.toLowerCase() !== generic) return t;
123
+ }
124
+ return null;
125
+ }
126
+ /** The content being approved, in full: summary, post text, images, body_md. */
127
+ export function contentRows(it) {
128
+ const p = it?.payload || {}; const w = p.what && typeof p.what === 'object' ? p.what : {}; const ap = p.action_params || {};
129
+ const imgs = [].concat(ap.image_paths || ap.images || []).filter(Boolean).map(String);
130
+ const text = ap.text ?? ap.body ?? ap.message ?? null;
131
+ const body = typeof p.body_md === 'string' ? p.body_md : null;
132
+ return [
133
+ ['summary', w.summary ? String(w.summary) : null],
134
+ ['content', text != null && String(text).trim() ? String(text).trim() : null],
135
+ ['images', imgs.length ? imgs.join(', ') : null],
136
+ ['details', body && body.trim() && body.trim() !== String(text ?? '').trim() ? body.trim() : null]
137
+ ].filter(r => r[1]);
138
+ }
139
+
140
+ export const INFERRED_WHY = '(guessed from the command, not stated by the caller)';
141
+ /** The exact command or tool input in full (never clipped), broken inside a token that cannot fit, one block under the label. */
142
+ function commandRows(label, text, cols) {
143
+ const lead = ' ' + label.padEnd(8); const pad = ' '.repeat(lead.length);
144
+ const room = cols ? Math.max(20, cols - lead.length - 1) : 0;
145
+ const out = [];
146
+ for (const raw of String(text).split('\n')) {
147
+ if (!room) { out.push(raw); continue; }
148
+ let l = raw; if (!l.length) { out.push(''); continue; }
149
+ while (l.length > room) { let cut = l.lastIndexOf(' ', room); if (cut < room / 2) cut = room; out.push(l.slice(0, cut)); l = l.slice(cut).replace(/^ /, ''); }
150
+ out.push(l);
151
+ }
152
+ return out.map((l, k) => (k ? pad : lead) + l);
153
+ }
154
+
155
+ /** A plain approval (not a quox ask) shaped as a y/n card so the walkthrough can show it. The original id stays, so /approve N routes by the same list. */
156
+ export function approvalAsWalkItem(it) {
157
+ if (isDecision(it)) return it;
158
+ const p = it?.payload || {}; const c = p.approval_context || {};
159
+ const dw = distinctWhat(it); const d = describeInboxItem(it);
160
+ const body = contentRows(it).filter(r => r[0] !== 'summary').map(r => `${r[0]}: ${r[1]}`).join('\n');
161
+ const inferred = c.reason && typeof c.reason === 'object' && c.reason.source === 'inferred' && c.reason.text;
162
+ const whyHead = inferred ? `${INFERRED_WHY}\n${decodeEntities(c.reason.text)}` : (whyText(it) || c.why || p.reason || '');
163
+ const why = [whyHead, body].filter(Boolean).join('\n');
164
+ return { ...it, __approval: true, title: dw || d.title || it.title || it.id,
165
+ payload: { ...p, type: 'decision', session: requesterNames(it)[0] || approvalSourceLabel(it), why, high_risk: c.risk === 'high' || p.high_risk === true,
166
+ options: [{ key: 'y', label: 'Approve' }, { key: 'n', label: 'Deny' }] } };
167
+ }
168
+
113
169
  /** One line: [N] age who what (risk). Clipped to `cols`. */
114
170
  export function approvalRow(it, n, { cols = 100, now = Date.now() } = {}) {
115
171
  if (isDecision(it)) return decisionRow(it, n, { cols, now });
116
172
  const d = describeInboxItem(it);
117
173
  const who = requesterNames(it)[0] || approvalSourceLabel(it);
118
174
  let what = [d.tool, d.target && d.target !== d.tool ? d.target : null].filter(Boolean).join(' ') || d.title || it.id;
175
+ const dw = distinctWhat(it); if (dw && commandOf(it)?.kind !== 'command') what = dw; // two posts to the same account must not read the same
119
176
  const risk = ctxOf(it).risk ? ` (${ctxOf(it).risk})` : '';
120
177
  const priv = isPrivileged({ tool: d.tool, risk: ctxOf(it).risk, flag: it?.payload?.privileged === true || ctxOf(it).privileged === true, title: it.title }) ? ' [privileged]' : '';
121
178
  const cmd = commandOf(it);
@@ -124,8 +181,9 @@ export function approvalRow(it, n, { cols = 100, now = Date.now() } = {}) {
124
181
  const on = count > 1 ? ` on ${count} ${/ssh|exec|host|shell/i.test(String(d.tool || it?.payload?.toolName || '')) ? 'hosts' : 'targets'} (${list.slice(0, 2).join(', ')}, +${count - 2} more)` : (count === 1 ? ` on ${list[0]}` : '');
125
182
  const one = cmd.text.replace(/\s*\n\s*/g, ' ; ');
126
183
  const head = ` [${n}] ${ageText(d.at, now).padStart(3)} ${String(who).slice(0, 18).padEnd(18)} ${d.tool || 'run'} `;
127
- const room = Math.max(24, cols - head.length - on.length - risk.length - priv.length - 2);
128
- return `${head}\`${clipVisible(one, room, n)}\`${on}${risk}${priv}`;
184
+ const bg = startsBackgroundWork(cmd.text) ? ' · runs in background' : '';
185
+ const room = Math.max(24, cols - head.length - on.length - risk.length - priv.length - bg.length - 2);
186
+ return `${head}\`${clipVisible(one, room, n)}\`${on}${risk}${priv}${bg}`;
129
187
  }
130
188
  const line = ` [${n}] ${ageText(d.at, now).padStart(3)} ${String(who).slice(0, 18).padEnd(18)} ${what}${risk}${priv}`;
131
189
  return line.length > cols ? line.slice(0, Math.max(10, cols - 1)) + '…' : line;
@@ -150,6 +208,7 @@ export function approvalDetail(it, n, { now = Date.now() } = {}) {
150
208
  ['who', whoText(it)],
151
209
  ['why', why ? String(why) : null],
152
210
  ['request', excerptOf(it) ? `"${excerptOf(it)}"` : null],
211
+ ...contentRows(it).filter(r => !(r[0] === 'summary' && String(r[1]) === String(why ?? ''))),
153
212
  ['class', accessOf(it)],
154
213
  ['from', requesterNames(it).join(', ') || approvalSourceLabel(it)],
155
214
  ['asked', d.at ? `${ageText(d.at, now)} ago (${String(d.at)})` : null],
@@ -235,7 +294,7 @@ export function walkNoCatch(it) {
235
294
  }
236
295
  /** A title without the asker's own name in front of it ("SEER: Publish?" asked by SEER -> "Publish?"), whitespace and line breaks folded. */
237
296
  export function cleanTitle(it) {
238
- const p = it.payload || {}; const t = String(it.title || it.id || '').replace(/\s+/g, ' ').trim();
297
+ const p = it.payload || {}; const t = decodeEntities(String(it.title || it.id || '')).replace(/\s+/g, ' ').trim();
239
298
  for (const name of [p.session, p.agent].filter(Boolean).map(String)) {
240
299
  if (t.toLowerCase().startsWith(name.toLowerCase() + ':')) { const rest = t.slice(name.length + 1).trim(); if (rest) return rest; }
241
300
  }
@@ -252,12 +311,64 @@ export function hang(label, text, cols) {
252
311
  if (cur || !out.length) out.push(cur);
253
312
  return out.map((l, k) => (k ? pad : lead) + l);
254
313
  }
314
+ /** Word-wrap `text` to `width`, breaking a token longer than the width inside it, so no line is ever wider than `width`. */
315
+ function wrapWords(text, width) {
316
+ const w = Math.max(1, width); const out = []; let cur = '';
317
+ for (let tok of String(text).split(' ')) {
318
+ while (tok.length > w) { // a token that can never fit: break it, never let it outdent or overflow
319
+ if (cur) { out.push(cur); cur = ''; }
320
+ out.push(tok.slice(0, w)); tok = tok.slice(w);
321
+ }
322
+ if (cur && cur.length + 1 + tok.length > w) { out.push(cur); cur = tok; } else cur = cur ? cur + ' ' + tok : tok;
323
+ }
324
+ if (cur || !out.length) out.push(cur);
325
+ return out;
326
+ }
327
+ /** A "Files (3): - a - b - c" that arrived with its line breaks flattened becomes a header line plus one "- item" line each. */
328
+ function unflattenList(text) {
329
+ const t = String(text ?? '');
330
+ if (t.includes('\n')) return t;
331
+ const m = /^(.*?\(\d+\):)\s+- (.+)$/.exec(t.trim());
332
+ return m ? [m[1], ...m[2].split(/\s+- /).map(x => '- ' + x)].join('\n') : t;
333
+ }
334
+ /** Like hang, but keeps the text's own line breaks: each line (a "- " list item too) starts a fresh line under the label, wrapped items indented past the dash. */
335
+ export function hangBlock(label, text, cols) {
336
+ const segs = unflattenList(text).split('\n').map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean);
337
+ if (segs.length <= 1) return hang(label, segs[0] || '', cols);
338
+ const lead = ' ' + label.padEnd(8); const pad = ' '.repeat(lead.length);
339
+ const room = Math.max(20, (cols || 0) - lead.length - 1); const out = [];
340
+ for (const seg of segs) {
341
+ if (!cols) { out.push(seg); continue; }
342
+ const item = /^[-*] /.test(seg);
343
+ const lines = item ? wrapWords(seg.slice(2), room - 2).map((l, k) => (k ? ' ' : seg.slice(0, 2)) + l) : wrapWords(seg, room);
344
+ out.push(...lines);
345
+ }
346
+ return out.map((l, k) => (k ? pad : lead) + l);
347
+ }
348
+ /** Slot and server text can arrive HTML-escaped ("-&gt;"); decode for display. Never renders HTML. */
349
+ export function decodeEntities(t) {
350
+ return String(t ?? '').replace(/&(amp|lt|gt|quot|apos|#39|#x27);/gi, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", '#x27': "'" })[e.toLowerCase()]);
351
+ }
255
352
  /** Owner 2026-10-17: a test or guard loosening needs a plain justification. True when the title, kind or tags say the ask would make a check easier to pass. */
256
353
  export const LOOSEN_RE = /loosen|weaken|relax|widen|(?:delete|remove|skip|disable|replace|rewrite|change|edit|update)\w*\s+(?:\w+\s+){0,3}?(?:tests?|guards?|checks?|assertions?|proof cases?)\b|test.?pin|goal ?post/i;
257
354
  export const isLoosening = (it) => { const p = it?.payload || {}; return p.loosening === true || LOOSEN_RE.test([it?.title, p.kind, p.category, ...(Array.isArray(p.tags) ? p.tags : [])].filter(Boolean).join(' ')); };
258
355
  export const NO_REASON = 'No reason given: ask the session, or say no.';
259
356
  export const NO_JUSTIFICATION = 'No plain justification given: ask the session, or say no.';
260
357
  const justificationOf = (p) => String(p.justification || p.plain_justification || '').replace(/\s+/g, ' ').trim();
358
+ /** ADVICE for a test-edit (loosening) card: only facts the card carries (payload.diff_stats), never the asker's own recommendation, which has its own star line. */
359
+ export const NO_INDEPENDENT_VIEW = 'No independent view: judge the JUSTIFY text and the diff (/diff or the files listed).';
360
+ /** The `quox ask loosening` verb titles its card "Approve test edits: <paths>", which isLoosening does not match. */
361
+ const TEST_EDIT_RE = /approve\s+(?:loosening|test)\b/i;
362
+ function looseAdvice(p) {
363
+ const d = p.diff_stats || p.diff; if (!d || typeof d !== 'object') return NO_INDEPENDENT_VIEW;
364
+ const rm = Number(d.assertions_removed), ad = Number(d.assertions_added), fc = Number(d.files_changed);
365
+ const bits = [];
366
+ if (Number.isFinite(rm) && Number.isFinite(ad)) bits.push(rm === 0 ? `nothing removed, ${ad} added` : `${rm} assertion${rm === 1 ? '' : 's'} removed, ${ad} added`);
367
+ else if (Number.isFinite(rm)) bits.push(`${rm} assertion${rm === 1 ? '' : 's'} removed`);
368
+ if (d.new_test) bits.unshift('a new test');
369
+ if (Number.isFinite(fc)) bits.push(`${fc} file${fc === 1 ? '' : 's'} changed`);
370
+ return bits.length ? `From the diff: ${bits.join(', ')}. Judge the JUSTIFY text against that.` : NO_INDEPENDENT_VIEW;
371
+ }
261
372
  export function walkCard(it, i, total, { cols = 0 } = {}) {
262
373
  const p = it.payload; const who = p.session || p.agent || 'A session';
263
374
  const loose = isLoosening(it); const just = justificationOf(p); // JUSTIFY shows whenever the asker gave one, loosening title or not
@@ -265,12 +376,14 @@ export function walkCard(it, i, total, { cols = 0 } = {}) {
265
376
  const yn = p.options.some(o => o.key === 'y') && p.options.some(o => o.key === 'n');
266
377
  const rec = bareLoose ? null : (p.options.find(o => o.key === p.recommend) || null);
267
378
  const dflt = bareLoose ? (p.options.find(o => o.key === 'n') || null) : (p.options.find(o => o.key === p.default) || null);
268
- const why = String(p.why || '').replace(/\s+/g, ' ').trim();
379
+ const why = decodeEntities(String(p.why || '')).split('\n').map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n');
269
380
  const what = yn
270
- ? `Yes lets ${who} go ahead with this; no stops it.${p.high_risk ? ' High risk: a yes needs your PIN.' : ''}`
381
+ ? `Yes lets ${who} go ahead with this; no stops it.${it.__approval && commandOf(it)?.kind === 'command' && startsBackgroundWork(commandOf(it).text) ? ` This ${BACKGROUND_NOTE}.` : ''}${p.high_risk ? ' High risk: a yes needs your PIN.' : ''}`
271
382
  : `${who} needs you to choose how to settle this.${p.high_risk ? ' High risk: a yes needs your PIN.' : ''}`;
272
383
  const advice = bareLoose
273
384
  ? `${yn ? 'I would say no' : 'Do not approve this yet'}: this makes a check easier to pass and nothing explains why that is safe.${dflt ? ` Safe default: ${optWord(dflt)} (${dflt.key}).` : ''}`
385
+ : rec && (loose || TEST_EDIT_RE.test(String(it.title || '')))
386
+ ? looseAdvice(p)
274
387
  : rec
275
388
  ? `I would ${yn ? (rec.key === 'y' ? 'say yes' : 'say no') : `go with ${optWord(rec)} (${rec.key})`}. The asker recommended it.`
276
389
  : `${who} gave no recommendation, and I will not guess one.${dflt ? ` If nobody answers it would take ${optWord(dflt)} (${dflt.key}).` : ''}`;
@@ -280,11 +393,15 @@ export function walkCard(it, i, total, { cols = 0 } = {}) {
280
393
  { key: keyClash('s') ? 'skip' : 's', label: 'Skip for now', defer: true },
281
394
  { key: keyClash('?') ? 'ask' : '?', label: 'Ask about it', ask: true },
282
395
  ];
396
+ const cmdFact = it.__approval ? commandOf(it) : null; // the same facts the single approval card shows (approvalFacts), here in full
397
+ const whereFact = it.__approval ? whereText(it, { all: true }) : null;
283
398
  const lines = [
284
- `Decision ${i} of ${total} · ${who} asks`,
399
+ `${p.__approval ? 'Approval' : 'Decision'} ${i} of ${total} · ${who} asks`,
285
400
  ...hang('', cleanTitle(it), cols).map(l => l.slice(8)),
286
401
  ...hang('WHAT', what, cols),
287
- ...(why ? hang('WHY', why, cols) : []),
402
+ ...(cmdFact ? commandRows(cmdFact.kind === 'input' ? 'INPUT' : 'COMMAND', cmdFact.text, cols) : []),
403
+ ...(whereFact ? hang('WHERE', whereFact, cols) : []),
404
+ ...(why ? hangBlock('WHY', why, cols) : []),
288
405
  ...(loose || just ? hang('JUSTIFY', just || NO_JUSTIFICATION, cols) : []),
289
406
  ...(!why && !just && !loose ? hang('WHY', NO_REASON, cols) : []),
290
407
  ...hang('ADVICE', advice, cols),
@@ -0,0 +1,94 @@
1
+ // APPROVALS-AUTO (owner 2026-11-06: "just make approvals pop up, i am tired of opening them"): pure rules for opening a
2
+ // newly arrived approval or decision BY ITSELF as the /approvals walkthrough card. It only ever OPENS a card; it never
3
+ // answers one. Keystrokes are never stolen: a draft in the box, typing in the last TYPING_PAUSE_MS, a streaming turn or
4
+ // another ask on screen holds it, and it opens at the next idle point. A walkthrough already open simply gains the card.
5
+ import { matchesAgent } from './approvalsView.js';
6
+
7
+ export const TYPING_PAUSE_MS = 2000;
8
+ /** How long after a "N more arrived" notice a bare y/n still belongs to the card, not the chat. */
9
+ export const STRAY_WINDOW_MS = 30000;
10
+
11
+ /** The /approvals auto setting: ON unless the profile explicitly says false. */
12
+ export const autoOn = (settings) => !(settings && settings.approvalsAuto === false);
13
+
14
+ const keyOf = (orgId, id) => `${orgId || ''}|${id}`;
15
+
16
+ /** @returns {{ enabled: boolean, seen: Set<string>, seeded: Set<string>, queue: object[] }} */
17
+ export function createAutoOpen({ enabled = true } = {}) {
18
+ return { enabled, seen: new Set(), seeded: new Set(), queue: [] };
19
+ }
20
+
21
+ /**
22
+ * One poll of open items for one org (orgId null = the window's own org). The first read of each org only seeds what was
23
+ * already waiting (nothing pops at launch). New ids queue once; ids in skipIds (shown inline as this conversation's card,
24
+ * or answered here) never queue; a queued item the server no longer lists leaves the queue. While switched off, arrivals
25
+ * are marked seen so turning it on never replays them.
26
+ * Cross-org items carry __orgId and __orgName. seed overrides the first-read rule (false: queue even on a first read).
27
+ */
28
+ export function noteArrivals(st, items, { skipIds = null, orgId = null, orgName = null, seed = null } = {}) {
29
+ const list = (Array.isArray(items) ? items : []).filter((x) => x && x.id);
30
+ const org = orgId || '';
31
+ const first = seed === null ? !st.seeded.has(org) : Boolean(seed); // seed:false = an org first read after launch: what it has is new
32
+ st.seeded.add(org);
33
+ const live = new Set(list.map((x) => keyOf(orgId, x.id)));
34
+ st.queue = st.queue.filter((x) => (x.__orgId || '') !== org || live.has(keyOf(orgId, x.id)));
35
+ const added = [];
36
+ for (const it of list) {
37
+ const k = keyOf(orgId, it.id);
38
+ if (st.seen.has(k)) continue;
39
+ st.seen.add(k);
40
+ if (first || !st.enabled || (skipIds && skipIds.has(it.id))) continue;
41
+ const item = orgId ? { ...it, __orgId: orgId, __orgName: orgName || orgId } : it;
42
+ st.queue.push(item); added.push(item);
43
+ }
44
+ return { added };
45
+ }
46
+
47
+ /**
48
+ * Whether to open now: 'open' | 'join' (a walk is on screen: append, its own order shows it) | 'wait-draft' |
49
+ * 'wait-typing' | 'wait-turn' | 'wait-ask' | null (nothing queued, or switched off).
50
+ */
51
+ export function openDecision(st, { draft = '', lastKeyAt = 0, now = Date.now(), streaming = false, walkOpen = false, askOpen = false } = {}) {
52
+ if (!st.enabled || !st.queue.length) return null;
53
+ if (walkOpen) return 'join';
54
+ if (String(draft || '') !== '') return 'wait-draft';
55
+ if (now - (lastKeyAt || 0) < TYPING_PAUSE_MS) return 'wait-typing';
56
+ if (streaming) return 'wait-turn';
57
+ if (askOpen) return 'wait-ask';
58
+ return 'open';
59
+ }
60
+
61
+ /** Hands the queued items over once. */
62
+ export function takeQueue(st) { const q = st.queue; st.queue = []; return q; }
63
+
64
+ /**
65
+ * A submitted line that is a bare yes/no while a card is queued (or just after a "more arrived" notice) belongs to that
66
+ * card: the caller opens the card and keeps the key for it. Never chat.
67
+ */
68
+ export function strayAnswer(text, { queued = 0, walkOpen = false, noticeAt = 0, now = Date.now() } = {}) {
69
+ if (walkOpen) return false;
70
+ if (!/^(y|n|yes|no)$/i.test(String(text ?? '').trim())) return false;
71
+ return queued > 0 || (noticeAt > 0 && now - noticeAt < STRAY_WINDOW_MS);
72
+ }
73
+
74
+ export const APPROVALS_OPTIONS = 'help, walkthrough, N, mine, agents, old, <agent name>, auto on|off';
75
+
76
+ /** /approvals arguments -> { kind: list|help|walkthrough|detail|filter|auto|unknown }. An unknown word says so, never lists. */
77
+ export function parseApprovalsArg(arg, { items = [] } = {}) {
78
+ const t = String(arg ?? '').trim();
79
+ if (!t) return { kind: 'list' };
80
+ if (/^help$/i.test(t)) return { kind: 'help' };
81
+ if (/^walkthrough$/i.test(t)) return { kind: 'walkthrough' };
82
+ if (/^\d+$/.test(t)) return { kind: 'detail', n: Number(t) };
83
+ const m = /^auto(?:\s+(\S+))?$/i.exec(t);
84
+ if (m) {
85
+ if (!m[1]) return { kind: 'auto', set: null };
86
+ if (/^on$/i.test(m[1])) return { kind: 'auto', set: true };
87
+ if (/^off$/i.test(m[1])) return { kind: 'auto', set: false };
88
+ return { kind: 'unknown', word: t };
89
+ }
90
+ const k = t.toLowerCase();
91
+ if (k === 'mine' || k === 'agents' || k === 'old') return { kind: 'filter', f: k };
92
+ if ((items || []).some((it) => matchesAgent(it, t))) return { kind: 'filter', f: t };
93
+ return { kind: 'unknown', word: t };
94
+ }