quoxcli 0.2.0 → 0.3.1

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 (327) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/bin/quox.js +99 -126
  4. package/lib/api/endpoints/admin.js +26 -0
  5. package/lib/api/endpoints/agent-schedules.js +26 -0
  6. package/lib/api/endpoints/agents.js +7 -0
  7. package/lib/api/endpoints/assistants.js +12 -0
  8. package/lib/api/endpoints/away.js +15 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/contactsq.js +2 -0
  11. package/lib/api/endpoints/conversations.js +14 -0
  12. package/lib/api/endpoints/executions.js +13 -0
  13. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  14. package/lib/api/endpoints/files.js +14 -0
  15. package/lib/api/endpoints/hat.js +30 -0
  16. package/lib/api/endpoints/inbox.js +20 -0
  17. package/lib/api/endpoints/index.js +14 -0
  18. package/lib/api/endpoints/memory.js +19 -0
  19. package/lib/api/endpoints/mind.js +13 -0
  20. package/lib/api/endpoints/mon.js +6 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/plugins.js +18 -0
  23. package/lib/api/endpoints/presence.js +33 -0
  24. package/lib/api/endpoints/quoxplan.js +7 -0
  25. package/lib/api/endpoints/quoxseo.js +6 -0
  26. package/lib/api/endpoints/router.js +17 -0
  27. package/lib/api/endpoints/safety.js +7 -0
  28. package/lib/api/endpoints/skills.js +20 -0
  29. package/lib/api/endpoints/stepup.js +20 -0
  30. package/lib/api/endpoints/teams.js +2 -2
  31. package/lib/api/endpoints/triggers.js +26 -0
  32. package/lib/api/endpoints/v1.js +86 -0
  33. package/lib/api/endpoints/ward.js +7 -0
  34. package/lib/api/endpoints/workflows.js +7 -0
  35. package/lib/api/endpoints/x.js +8 -0
  36. package/lib/commands/actions.js +61 -0
  37. package/lib/commands/admin.js +16 -0
  38. package/lib/commands/agent.js +163 -27
  39. package/lib/commands/agentAttach.js +94 -6
  40. package/lib/commands/approvals.js +73 -1
  41. package/lib/commands/aside.js +24 -0
  42. package/lib/commands/ask.js +144 -0
  43. package/lib/commands/assistant.js +59 -4
  44. package/lib/commands/auth.js +8 -2
  45. package/lib/commands/backup.js +8 -4
  46. package/lib/commands/brain.js +105 -14
  47. package/lib/commands/build.js +19 -5
  48. package/lib/commands/chat.js +57 -2
  49. package/lib/commands/claude.js +34 -8
  50. package/lib/commands/contacts.js +49 -0
  51. package/lib/commands/conversation.js +35 -4
  52. package/lib/commands/dashboard.js +43 -0
  53. package/lib/commands/department.js +28 -7
  54. package/lib/commands/devResident.js +15 -3
  55. package/lib/commands/devSupervisor.js +13 -1
  56. package/lib/commands/discord.js +75 -39
  57. package/lib/commands/done.js +98 -28
  58. package/lib/commands/engagement.js +17 -6
  59. package/lib/commands/fetchAllowlist.js +71 -0
  60. package/lib/commands/file.js +38 -1
  61. package/lib/commands/fleet.js +311 -4
  62. package/lib/commands/governance.js +12 -3
  63. package/lib/commands/gripe.js +44 -13
  64. package/lib/commands/hat.js +178 -9
  65. package/lib/commands/helpers.js +18 -1
  66. package/lib/commands/hyperion.js +58 -0
  67. package/lib/commands/inbox.js +74 -23
  68. package/lib/commands/licence.js +14 -155
  69. package/lib/commands/lucid.js +10 -4
  70. package/lib/commands/memory.js +85 -8
  71. package/lib/commands/mind.js +71 -0
  72. package/lib/commands/mon.js +69 -2
  73. package/lib/commands/monitoring.js +1 -1
  74. package/lib/commands/motion.js +2 -2
  75. package/lib/commands/nucleus.js +48 -17
  76. package/lib/commands/objective.js +11 -0
  77. package/lib/commands/offload.js +89 -0
  78. package/lib/commands/onboard.js +2 -1
  79. package/lib/commands/org.js +11 -1
  80. package/lib/commands/peek.js +28 -0
  81. package/lib/commands/plan.js +38 -0
  82. package/lib/commands/plugin.js +110 -0
  83. package/lib/commands/presence.js +89 -0
  84. package/lib/commands/provider.js +159 -0
  85. package/lib/commands/qlarity.js +19 -3
  86. package/lib/commands/quoxchat.js +13 -4
  87. package/lib/commands/quoxseo.js +38 -0
  88. package/lib/commands/reach.js +2 -1
  89. package/lib/commands/router.js +77 -0
  90. package/lib/commands/rows.js +358 -0
  91. package/lib/commands/run.js +64 -9
  92. package/lib/commands/server.js +292 -0
  93. package/lib/commands/setup.js +33 -7
  94. package/lib/commands/skills.js +48 -5
  95. package/lib/commands/slot.js +260 -7
  96. package/lib/commands/status.js +1 -1
  97. package/lib/commands/team.js +3 -1
  98. package/lib/commands/tips.js +23 -0
  99. package/lib/commands/tool.js +133 -45
  100. package/lib/commands/trace.js +12 -7
  101. package/lib/commands/trigger.js +95 -0
  102. package/lib/commands/update.js +46 -0
  103. package/lib/commands/vault.js +31 -8
  104. package/lib/commands/vm.js +237 -0
  105. package/lib/commands/voice.js +34 -3
  106. package/lib/commands/volt.js +45 -3
  107. package/lib/commands/walkthrough.js +32 -0
  108. package/lib/commands/watch.js +73 -21
  109. package/lib/commands/welcome.js +14 -0
  110. package/lib/commands/workflow.js +189 -32
  111. package/lib/commands/x.js +13 -4
  112. package/lib/core/actingAs.js +2 -2
  113. package/lib/core/activeSummary.js +186 -0
  114. package/lib/core/aside.js +54 -0
  115. package/lib/core/askDecision.js +60 -0
  116. package/lib/core/auth.js +164 -13
  117. package/lib/core/callerSource.js +1 -1
  118. package/lib/core/cliBuild.js +62 -0
  119. package/lib/core/client.js +144 -35
  120. package/lib/core/commandRegistrars.js +121 -0
  121. package/lib/core/config.js +142 -24
  122. package/lib/core/customHats.js +168 -0
  123. package/lib/core/doneEngineFetch.js +8 -0
  124. package/lib/core/errors.js +329 -20
  125. package/lib/core/global-flags.js +105 -5
  126. package/lib/core/hyperion.js +102 -0
  127. package/lib/core/inboxResolution.js +65 -0
  128. package/lib/core/lazyCommands.js +4 -1
  129. package/lib/core/offload.js +504 -0
  130. package/lib/core/org-override.js +1 -1
  131. package/lib/core/orgResolve.js +70 -0
  132. package/lib/core/output.js +4 -4
  133. package/lib/core/peek.js +126 -0
  134. package/lib/core/profileUsers.js +51 -0
  135. package/lib/core/promptCancel.js +18 -0
  136. package/lib/core/repoPath.js +11 -0
  137. package/lib/core/residentSeed.js +3 -1
  138. package/lib/core/rootOptions.js +39 -0
  139. package/lib/core/router.js +168 -0
  140. package/lib/core/selfUpdate.js +188 -0
  141. package/lib/core/sessionsOverview.js +64 -0
  142. package/lib/core/slotKeeper.js +248 -3
  143. package/lib/core/slotWake.js +120 -0
  144. package/lib/core/supervisorEngine.js +71 -2
  145. package/lib/core/version.js +1 -1
  146. package/lib/core/vm.js +361 -0
  147. package/lib/core/vmTui.js +86 -0
  148. package/lib/core/walkthrough.js +35 -0
  149. package/lib/hyperion/deck.js +29 -0
  150. package/lib/lucid/LucidCard.jsx +68 -0
  151. package/lib/lucid/LucidIntro.jsx +45 -58
  152. package/lib/lucid/cards.js +86 -0
  153. package/lib/lucid/markdown.js +81 -0
  154. package/lib/lucid/motion.js +56 -0
  155. package/lib/quoxon/activity.js +14 -2
  156. package/lib/quoxon/aocl.js +117 -0
  157. package/lib/quoxon/api.js +504 -25
  158. package/lib/quoxon/app.js +3000 -230
  159. package/lib/quoxon/approvalFacts.js +94 -0
  160. package/lib/quoxon/approvalsView.js +325 -0
  161. package/lib/quoxon/asideView.js +40 -0
  162. package/lib/quoxon/ask.js +121 -0
  163. package/lib/quoxon/away.js +52 -0
  164. package/lib/quoxon/bang.js +224 -0
  165. package/lib/quoxon/bulkRisk.js +17 -0
  166. package/lib/quoxon/capabilities.js +13 -6
  167. package/lib/quoxon/cells.js +53 -0
  168. package/lib/quoxon/commandShort.js +112 -0
  169. package/lib/quoxon/commands.js +363 -38
  170. package/lib/quoxon/connect.js +210 -23
  171. package/lib/quoxon/connectCard.js +19 -2
  172. package/lib/quoxon/consent.js +86 -0
  173. package/lib/quoxon/copy.js +4 -3
  174. package/lib/quoxon/countdownLine.js +11 -0
  175. package/lib/quoxon/demo.autoplay.js +29 -0
  176. package/lib/quoxon/demo.fixtures.js +1 -1
  177. package/lib/quoxon/demo.js +44 -3
  178. package/lib/quoxon/devObserver.js +20 -0
  179. package/lib/quoxon/dividerAocl.js +191 -0
  180. package/lib/quoxon/dividerRule.js +57 -0
  181. package/lib/quoxon/dragonModes.js +150 -0
  182. package/lib/quoxon/dragonView.js +41 -0
  183. package/lib/quoxon/earlyInput.js +40 -0
  184. package/lib/quoxon/echo.js +15 -0
  185. package/lib/quoxon/egg.js +2 -2
  186. package/lib/quoxon/events.js +244 -25
  187. package/lib/quoxon/feedback.js +50 -0
  188. package/lib/quoxon/gallery.js +21 -7
  189. package/lib/quoxon/handoffOffer.js +78 -0
  190. package/lib/quoxon/hatSwitch.js +37 -0
  191. package/lib/quoxon/headless.js +161 -0
  192. package/lib/quoxon/hostUrls.js +41 -0
  193. package/lib/quoxon/indicator.js +6 -2
  194. package/lib/quoxon/intent.js +12 -1
  195. package/lib/quoxon/keys.js +49 -6
  196. package/lib/quoxon/launcher.js +103 -0
  197. package/lib/quoxon/main.js +92 -160
  198. package/lib/quoxon/markdown.js +45 -21
  199. package/lib/quoxon/markdownSettle.js +19 -0
  200. package/lib/quoxon/matrixDomains.json +46 -0
  201. package/lib/quoxon/mentions.js +90 -0
  202. package/lib/quoxon/mind.js +103 -0
  203. package/lib/quoxon/mode.js +46 -0
  204. package/lib/quoxon/model.js +132 -26
  205. package/lib/quoxon/modelPrefs.js +345 -0
  206. package/lib/quoxon/motion/clock.js +3 -2
  207. package/lib/quoxon/motion/level.js +91 -0
  208. package/lib/quoxon/mouse.js +211 -0
  209. package/lib/quoxon/noai.js +57 -0
  210. package/lib/quoxon/objects/away.js +72 -0
  211. package/lib/quoxon/objects/fetch.js +17 -5
  212. package/lib/quoxon/objects/fleet.js +149 -0
  213. package/lib/quoxon/objects/health.js +186 -0
  214. package/lib/quoxon/objects/index.js +24 -3
  215. package/lib/quoxon/objects/mirror.js +137 -0
  216. package/lib/quoxon/objects/peek.js +17 -0
  217. package/lib/quoxon/objects/prove.js +130 -0
  218. package/lib/quoxon/objects/replay.js +125 -0
  219. package/lib/quoxon/objects/sitrep.js +58 -0
  220. package/lib/quoxon/objects/view.js +11 -0
  221. package/lib/quoxon/onboarding/copy.js +32 -4
  222. package/lib/quoxon/onboarding/credential.js +17 -4
  223. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  224. package/lib/quoxon/onboarding/failureCause.js +33 -0
  225. package/lib/quoxon/onboarding/finish.js +15 -0
  226. package/lib/quoxon/onboarding/index.js +34 -7
  227. package/lib/quoxon/onboarding/installRunner.js +64 -5
  228. package/lib/quoxon/onboarding/preflight.js +17 -3
  229. package/lib/quoxon/onboarding/probe.js +65 -0
  230. package/lib/quoxon/onboarding/view.js +76 -19
  231. package/lib/quoxon/orgLabel.js +10 -0
  232. package/lib/quoxon/orgSwitch.js +33 -0
  233. package/lib/quoxon/ownBroadcast.js +22 -0
  234. package/lib/quoxon/palette.js +43 -0
  235. package/lib/quoxon/print.js +9 -5
  236. package/lib/quoxon/profileSwitch.js +53 -0
  237. package/lib/quoxon/promptHistory.js +41 -0
  238. package/lib/quoxon/providers.js +48 -0
  239. package/lib/quoxon/qart.js +20 -51
  240. package/lib/quoxon/qdragon.js +410 -0
  241. package/lib/quoxon/qdragonFly.js +61 -0
  242. package/lib/quoxon/qdragonScenes.js +282 -0
  243. package/lib/quoxon/qeyes.js +213 -0
  244. package/lib/quoxon/qmotion.js +58 -37
  245. package/lib/quoxon/render.js +638 -119
  246. package/lib/quoxon/resend.js +72 -0
  247. package/lib/quoxon/roster.js +95 -9
  248. package/lib/quoxon/route.js +6 -5
  249. package/lib/quoxon/rows.js +711 -0
  250. package/lib/quoxon/rowsStore.js +80 -0
  251. package/lib/quoxon/screenContext.js +150 -0
  252. package/lib/quoxon/screenFrames.js +254 -0
  253. package/lib/quoxon/session.js +172 -2
  254. package/lib/quoxon/sessionDead.js +17 -0
  255. package/lib/quoxon/signin.js +125 -0
  256. package/lib/quoxon/slash/agents.js +112 -0
  257. package/lib/quoxon/slash/argSources.js +65 -0
  258. package/lib/quoxon/slash/args.js +65 -0
  259. package/lib/quoxon/slash/automate.js +89 -0
  260. package/lib/quoxon/slash/broadcast.js +140 -0
  261. package/lib/quoxon/slash/broadcastRun.js +108 -0
  262. package/lib/quoxon/slash/card.js +30 -0
  263. package/lib/quoxon/slash/challenge.js +114 -0
  264. package/lib/quoxon/slash/cliCommand.js +53 -0
  265. package/lib/quoxon/slash/cliLeaves.json +948 -0
  266. package/lib/quoxon/slash/compact.js +1 -1
  267. package/lib/quoxon/slash/confirm.js +30 -0
  268. package/lib/quoxon/slash/crystallize.js +69 -0
  269. package/lib/quoxon/slash/dashboard.js +136 -0
  270. package/lib/quoxon/slash/draft.js +54 -0
  271. package/lib/quoxon/slash/hatWalk.js +206 -0
  272. package/lib/quoxon/slash/inverses.js +77 -0
  273. package/lib/quoxon/slash/killer.js +24 -0
  274. package/lib/quoxon/slash/killerApi.js +16 -0
  275. package/lib/quoxon/slash/ledger.js +13 -8
  276. package/lib/quoxon/slash/listView.js +3 -1
  277. package/lib/quoxon/slash/modelCmd.js +261 -0
  278. package/lib/quoxon/slash/palette.js +21 -7
  279. package/lib/quoxon/slash/paletteController.js +117 -7
  280. package/lib/quoxon/slash/panels.js +21 -6
  281. package/lib/quoxon/slash/picker.js +20 -2
  282. package/lib/quoxon/slash/restart.js +71 -13
  283. package/lib/quoxon/slash/rowCmd.js +157 -0
  284. package/lib/quoxon/slash/rowWalk.js +175 -0
  285. package/lib/quoxon/slash/settings.js +16 -6
  286. package/lib/quoxon/slash/spawn.js +177 -0
  287. package/lib/quoxon/slash/stepUp.js +274 -0
  288. package/lib/quoxon/slash/stepUpCard.js +60 -0
  289. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  290. package/lib/quoxon/slash/undoApi.js +38 -0
  291. package/lib/quoxon/slash/undoCmd.js +49 -0
  292. package/lib/quoxon/slash/undoWish.js +15 -0
  293. package/lib/quoxon/slash/watch.js +164 -0
  294. package/lib/quoxon/slash/wish.js +23 -0
  295. package/lib/quoxon/slash/wishCmd.js +32 -0
  296. package/lib/quoxon/slotChat.js +300 -0
  297. package/lib/quoxon/slotFormat.js +195 -0
  298. package/lib/quoxon/slotRun.js +131 -0
  299. package/lib/quoxon/sound/mapping.js +2 -1
  300. package/lib/quoxon/stray.js +22 -0
  301. package/lib/quoxon/suggestCommand.js +59 -0
  302. package/lib/quoxon/term.js +46 -11
  303. package/lib/quoxon/textBuffer.js +176 -2
  304. package/lib/quoxon/theme.js +58 -25
  305. package/lib/quoxon/tips.js +433 -0
  306. package/lib/quoxon/toolError.js +41 -0
  307. package/lib/quoxon/transcript.js +42 -4
  308. package/lib/quoxon/web/qface.js +18 -10
  309. package/lib/quoxon/welcome.js +1 -1
  310. package/lib/quoxon/whatsnew.js +136 -0
  311. package/lib/quoxon/whatsnew.jsonl +89 -0
  312. package/lib/quoxon/windowOrg.js +113 -0
  313. package/lib/server/diag.js +129 -0
  314. package/lib/server/doctor.js +71 -0
  315. package/lib/server/install.js +149 -0
  316. package/lib/server/pluginEnable.js +161 -0
  317. package/lib/server/skew.js +56 -0
  318. package/lib/server/snapshot.js +131 -0
  319. package/lib/server/status.js +128 -0
  320. package/lib/server/sys.js +65 -0
  321. package/lib/server/uninstall.js +63 -0
  322. package/lib/server/update.js +234 -0
  323. package/lib/toolOutputShape.js +315 -0
  324. package/lib/tui/views/HyperionDeck.jsx +79 -0
  325. package/lib/tui/views/LucidDeck.jsx +209 -217
  326. package/package.json +4 -1
  327. package/lib/core/websiteSession.js +0 -148
@@ -11,9 +11,14 @@
11
11
  * turn : null | { id, status: running|interrupting, startedAt, stage, activity }
12
12
  */
13
13
 
14
- import { splitSettled } from './markdown.js';
14
+ import { dividerStep } from './dividerAocl.js';
15
+ import { approvalFacts } from './approvalFacts.js';
16
+ import { isConsentError, consentErrorText } from './consent.js';
17
+ import { splitSettled } from './markdownSettle.js'; // not markdown.js: that loads React + Ink (QUOXON-LATENCY)
15
18
  import { nextActivity, actionActivity } from './activity.js';
16
19
  import { normUsage, turnTokens, fmtTokens, fmtCost } from './indicator.js';
20
+ import { isLayerTrace, normaliseSummary, summaryFromTraces, verdictNote } from './aocl.js';
21
+ import { toolResultText } from './toolError.js';
17
22
 
18
23
  export const TOOL_STATUSES = ['ok', 'failed', 'denied', 'effect_unknown'];
19
24
  export const APPROVAL_ARM_MS = 300;
@@ -23,10 +28,14 @@ export const HELD_KEY_MS = 1500;
23
28
  export const initialState = (over = {}) => ({
24
29
  history: [],
25
30
  blocks: [],
31
+ asks: [], // ASK-SETTLE: open y/N/c ask cards (live, never scrollback) until answered/expired/closed
26
32
  turn: null,
27
33
  ctx: { agent: null, model: null, provider: null, endpoint: null, hat: null, conversationId: null },
28
34
  pending: [], // client-side queue (Tab)
29
35
  steering: [], // steer messages not yet accepted
36
+ lastTurnId: null, // the newest turn begun here: a card from an older turn is stale
37
+ lastAocl: null, // QUOXON-AOCL: the last turn's normalised aocl.summary (for /aocl)
38
+ waiting: [], // approvals raised by ANOTHER conversation/session: a notice only, never a focused card (answered via /approvals)
30
39
  approvals: [], // approval cards waiting for a human, first is shown; survive the turn that raised them
31
40
  running: [], // approved from this terminal, result arrives asynchronously (polled)
32
41
  inbox: [], // last /approvals listing
@@ -46,21 +55,51 @@ export const initialState = (over = {}) => ({
46
55
  * streams keep using `chunk`. Returns { append, st } where st is { v1, lastChunk }.
47
56
  */
48
57
  export function textGate(st, ev) {
49
- const g = st || { v1: false, lastChunk: null };
58
+ const g = st || { v1: false, lastChunk: null, acc: '' };
59
+ const acc = g.acc || '';
60
+ const add = (c) => (acc + c).slice(-200000);
50
61
  if (ev.type === 'turn.started') return { append: false, st: { ...g, v1: true } };
51
62
  if (ev.type === 'chunk') {
52
- if (g.v1) return { append: false, st: g };
53
- return { append: Boolean(ev.content), st: { ...g, lastChunk: ev.content ?? null } };
63
+ const c = ev.content;
64
+ if (!c) return { append: false, st: g };
65
+ // RESTORE-TUI: on a v1 stream a chunk is skipped only when it mirrors text already shown (the full text so far,
66
+ // or the delta it repeats). A chunk carrying NEW text is still answer text (it was dropped since dae720283).
67
+ if (g.v1 && acc.endsWith(c)) return { append: false, st: g };
68
+ return { append: true, st: { ...g, lastChunk: c, acc: add(c) } };
54
69
  }
55
70
  if (ev.type === 'text.delta') {
56
- const mirrored = !g.v1 && g.lastChunk != null && g.lastChunk === ev.content;
57
- return { append: Boolean(ev.content) && !mirrored, st: { v1: true, lastChunk: null } };
71
+ const c = ev.content;
72
+ const mirrored = g.lastChunk != null && g.lastChunk === c;
73
+ const append = Boolean(c) && !mirrored;
74
+ return { append, st: { v1: true, lastChunk: null, acc: append ? add(c) : acc } };
58
75
  }
59
76
  return { append: false, st: g };
60
77
  }
61
78
 
62
79
  const tms = (v) => { const t = typeof v === 'number' ? v : Date.parse(v); return Number.isFinite(t) ? t : null; };
63
80
 
81
+ 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
+ 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
+
84
+ /**
85
+ * Describe ANY inbox approval from what the item carries: the tool or leaf, the command or key args, the target
86
+ * (explicit target, else the org it acts on), and who asked and when. Never invents; empty fields are null.
87
+ */
88
+ export function describeInboxItem(it) {
89
+ const p = it?.payload || {};
90
+ const c = p.approval_context || {};
91
+ const m = c.method || {};
92
+ const ob = c.on_behalf_of || {};
93
+ const w = p.what && typeof p.what === 'object' ? p.what : {}; // APPROVAL-WHAT: the server's own action/target/summary wins
94
+ const tool = w.action || m.tool || c.tool || c.leaf || p.tool || p.leaf || null;
95
+ const argv = m.argv ?? m.args ?? c.argv ?? c.args ?? p.argv ?? p.args ?? m.command ?? c.command ?? p.command ?? null;
96
+ const cmd = short([m.leaf || c.leaf || p.leaf, argText(argv)].filter(Boolean).join(' ').trim() || null);
97
+ 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;
99
+ const what = w.summary ? String(w.summary) : (it?.title ? short(String(it.title), 160) : null);
100
+ return { tool, target: target ? short(target, 200) : null, title: what, by: by ? String(by) : null, at: it?.created_at ?? it?.createdAt ?? null };
101
+ }
102
+
64
103
  /**
65
104
  * A pending inbox approval found by polling -> approval card, or null when it does not qualify.
66
105
  * Qualifies when created after `since` (the turn start, else app start), or raised on behalf of this
@@ -68,19 +107,67 @@ const tms = (v) => { const t = typeof v === 'number' ? v : Date.parse(v); return
68
107
  */
69
108
  export function cardFromInboxItem(it, { since = 0, userId = null, conversationId = null, now = 0 } = {}) {
70
109
  if (!it || !it.id) return null;
110
+ if (it.payload?.type === 'decision') return null; // quox ask: answered with /answer N key from /approvals, never a y/n key card
71
111
  const c = it.payload?.approval_context || {};
72
112
  const m = c.method || {};
73
113
  const created = tms(it.created_at ?? it.createdAt);
74
114
  const mine = Boolean(userId && c.on_behalf_of?.user_id && String(c.on_behalf_of.user_id) === String(userId));
75
115
  const here = Boolean(conversationId && c.on_behalf_of?.conversation_id === conversationId);
76
116
  if (!(created != null && created >= since) && !mine && !here) return null;
117
+ // QUOXON-REVIEW: collector tool approvals mint the inbox item with aee_correlation_id = the approval id;
118
+ // without it the card had no id, polling was skipped and every terminal approve read "timeout".
119
+ const toolApproval = it.payload?.type === 'native_tool_approval';
120
+ const approvalId = it.payload?.approval_id || it.approval_id || it.approvalId
121
+ || (toolApproval ? (it.aee_correlation_id || it.aeeCorrelationId || null) : null) || null;
122
+ const ob = c.on_behalf_of || {};
123
+ const d = describeInboxItem(it);
124
+ const tool = d.tool || String(it.title || it.id);
77
125
  return {
78
- callId: `inbox:${it.id}`, inboxItemId: it.id, approvalId: it.payload?.approval_id || it.approval_id || it.approvalId || null,
79
- tool: m.tool || String(it.title || it.id), target: m.target || null, scope: null, risk: c.risk || null,
80
- url: it.dashboard_url || null, shownAt: now, undescribable: !m.tool || !m.target
126
+ callId: `inbox:${it.id}`, inboxItemId: it.id, approvalId,
127
+ 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,
129
+ facts: approvalFacts(it), // APPROVAL-WWWH: the exact command, hosts, chain, reason, time and read-only class
130
+ // only an item that names THIS conversation is ours; anything else came from another session and must not take the keyboard
131
+ foreign: !here,
132
+ origin: String(ob.agent || ob.agent_id || ob.session_name || ob.session || (ob.conversation_id ? `conversation ${String(ob.conversation_id).slice(0, 8)}` : approvalSourceLabel(it))),
133
+ privileged: isPrivileged({ tool, risk: c.risk, flag: c.privileged === true || c.elevated === true || it.payload?.privileged === true, title: it.title })
81
134
  };
82
135
  }
83
136
 
137
+ /** Where an inbox approval came from: its source label, else the payload source, else its type. */
138
+ export function approvalSourceLabel(it) {
139
+ 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');
141
+ }
142
+
143
+ /**
144
+ * QUOXON-FW7: every open inbox approval, grouped for /approvals. 'this conversation' (names this conversation,
145
+ * or already a card here), 'other sessions' (raised on behalf of another conversation or agent), and
146
+ * 'plugins and workflows' (everything else, labelled by source). Empty groups are dropped.
147
+ */
148
+ export function groupApprovals(items, { conversationId = null, cardIds = [] } = {}) {
149
+ const g = { 'decisions asked of you': [], 'this conversation': [], 'other sessions': [], 'plugins and workflows': [] };
150
+ for (const item of items || []) {
151
+ if (item?.payload?.type === 'decision') { g['decisions asked of you'].push({ item, label: String(item.payload.session || 'session') }); continue; } // quox ask
152
+ const ob = item?.payload?.approval_context?.on_behalf_of || {};
153
+ const label = approvalSourceLabel(item);
154
+ // A card that names a conversation belongs here only when it is THIS one (a card shown in this window for another
155
+ // conversation, e.g. a privileged hat.switch, is still "other sessions"). No conversation named: a card here counts.
156
+ const mine = ob.conversation_id ? Boolean(conversationId) && ob.conversation_id === conversationId : cardIds.includes(item.id);
157
+ if (mine) g['this conversation'].push({ item, label });
158
+ else if (ob.conversation_id || ob.agent || ob.agent_id || ob.session_name || ob.session) g['other sessions'].push({ item, label: String(ob.session_name || ob.session || ob.agent || ob.agent_id || `conversation ${String(ob.conversation_id).slice(0, 8)}`) });
159
+ else g['plugins and workflows'].push({ item, label });
160
+ }
161
+ return Object.entries(g).filter(([, v]) => v.length).map(([group, v]) => ({ group, items: v }));
162
+ }
163
+
164
+ /** Privileged/elevated grants (hat switches and anything the server marks privileged or elevated) need an explicit confirm. */
165
+ export function isPrivileged({ tool = '', risk = null, flag = false, title = '' } = {}) {
166
+ if (flag === true) return true;
167
+ if (String(tool) === 'hat.switch' || /^hat[ .]switch/i.test(String(title))) return true;
168
+ return /privileged|elevated|critical/i.test(String(risk || ''));
169
+ }
170
+
84
171
  let uid = 0;
85
172
  const nid = () => ++uid;
86
173
 
@@ -101,6 +188,11 @@ function flush(s, force = false) {
101
188
  return out.length || blocks.length !== s.blocks.length ? { ...s, history: [...s.history, ...out], blocks } : s;
102
189
  }
103
190
 
191
+ /** Fold the turn's VOLT tool count and list into its AOCL summary (footer count, /aocl list). */
192
+ function withTools(sum, turn) {
193
+ const t = turn?.toolsOffer;
194
+ return t && t.offered != null ? { ...sum, toolsOffered: t.offered, toolsTotal: t.total, toolsList: t.list } : sum;
195
+ }
104
196
  function note(s, text, tone = 'dim') {
105
197
  return { ...s, history: [...s.history, { id: nid(), kind: 'note', text, tone }] };
106
198
  }
@@ -133,6 +225,14 @@ function appendText(s00, content) {
133
225
  return addBlock(s, { kind: 'text', text: content });
134
226
  }
135
227
 
228
+ /** QUOXON-FW3: who a delegation targets. Server fields first; older servers only put agent_id=<id> in the summary. */
229
+ export function delegateInfo(ev) {
230
+ if (ev.tool !== 'delegate_to_agent') return {};
231
+ const id = ev.agent_id || (String(ev.summary || '').match(/\bagent_id=(\S+)/) || [])[1] || null;
232
+ if (!id) return {};
233
+ return { agentId: id, agentName: ev.agent_name || id, ...(ev.task_gist ? { taskGist: ev.task_gist } : {}) };
234
+ }
235
+
136
236
  function upsertTool(s, ev, patch) {
137
237
  const i = s.blocks.findIndex(b => b.kind === 'tool' && b.callId === ev.call_id);
138
238
  if (i >= 0) {
@@ -172,7 +272,8 @@ export function summarizeTurn(s, status, now) {
172
272
  // Real server usage when it arrived; otherwise the estimate from streamed text; nothing when neither exists.
173
273
  const tk = turnTokens(t);
174
274
  if (tk > 0) parts.push(`${fmtTokens(tk)} tokens`);
175
- const cost = fmtCost(t?.usage?.cost ?? normUsage(null, t?.doneCost).cost);
275
+ // A labelled estimate (cost_source, e.g. the claude CLI's own figure) is never shown as this turn's price; /cost explains it.
276
+ const cost = t?.usage?.costSource || t?.viaCli ? null : fmtCost(t?.usage?.cost ?? normUsage(null, t?.doneCost).cost);
176
277
  if (cost) parts.push(cost);
177
278
  if (status === 'interrupted' || status === 'failed') {
178
279
  if (t?.tools) parts.push(`${plural(finished, 'tool')} finished`);
@@ -215,15 +316,47 @@ function endTurn(s, status, now) {
215
316
  function reduceInner(s, a) {
216
317
  switch (a.type) {
217
318
  case 'user': // a submitted prompt
218
- return { ...s, history: [...s.history, { id: nid(), kind: 'user', text: a.text, queued: !!a.queued }] };
319
+ return { ...s, history: [...s.history, { id: nid(), kind: 'user', text: a.text, queued: !!a.queued, ...(a.echo ? { echo: true } : {}) }] };
219
320
  case 'turn.begin':
220
- return { ...s, turn: { id: a.turnId, status: 'running', startedAt: a.now, stage: null, activity: null, tools: 0, finishedTools: 0, approvals: 0, receipts: [], chars: 0, usage: null }, blocks: [], autoDrain: false };
321
+ return { ...s, lastTurnId: a.turnId, turn: { id: a.turnId, status: 'running', startedAt: a.now, stage: null, activity: null, tools: 0, finishedTools: 0, approvals: 0, receipts: [], chars: 0, usage: null }, blocks: [], autoDrain: false };
221
322
  case 'note':
222
323
  return note(s, a.text, a.tone);
324
+ // ASK-SETTLE (owner 2026-10-17): an open ask is a live card; settling it removes the card and leaves one line.
325
+ case 'ask.open': { // the same ask shown again (after a chat about it) replaces its card, never a second one
326
+ const asks = (s.asks || []).filter(x => x.text !== a.text);
327
+ return { ...s, asks: [...asks, { id: a.id, text: a.text, at: a.at ?? null }] };
328
+ }
329
+ case 'ask.settle': {
330
+ const asks = s.asks || [];
331
+ if (!asks.some(x => x.id === a.id)) return s;
332
+ if (a.silent) return { ...s, asks: asks.filter(x => x.id !== a.id) }; // a walkthrough card that ended normally: no leftover line
333
+ return note({ ...s, asks: asks.filter(x => x.id !== a.id) }, a.text, a.tone);
334
+ }
335
+ case 'slotmsg.settle': { // SLOT-RUN: an answered/expired run request collapses to one line in place
336
+ let hit = false;
337
+ const history = s.history.map(it => {
338
+ if (it.kind !== 'slotmsg' || !it.line?.run || it.line.run.askId !== a.askId || it.line.run.settled) return it;
339
+ hit = true;
340
+ return { ...it, line: { ...it.line, run: { ...it.line.run, settled: a.text, settledTone: a.tone } } };
341
+ });
342
+ return hit ? { ...s, history } : s;
343
+ }
344
+ case 'bang.out': // QUOXON-FW8: command output, shown as is (not note-painted); the transcript records only the action name
345
+ return { ...s, history: [...s.history, { id: nid(), kind: 'bang', text: a.text }] };
346
+ case 'slotmsg': // QUOXON-MESH: a slot chat line (slotChat.js slotLine), incoming or sent
347
+ return { ...s, history: [...s.history, { id: nid(), kind: 'slotmsg', line: a.line }] };
348
+ case 'tip': // a "did you know" tip (tips.js); text + the command to try
349
+ return { ...s, history: [...s.history, { id: nid(), kind: 'tip', text: a.text, cmd: a.cmd }] };
350
+ case 'bcard': // QUOXON-BROADCAST: the sender's framed delivery card (slash/broadcast.js broadcastCard), pure data
351
+ return { ...s, history: [...s.history, { id: nid(), kind: 'bcard', card: a.card }] };
352
+ case 'whatsnew': // the startup "What's new" box (whatsnew.js): [{ title, cmd }] + how many more
353
+ return { ...s, history: [...s.history, { id: nid(), kind: 'whatsnew', items: a.items, more: a.more, head: a.head, pad: a.pad }] };
223
354
  case 'notify':
224
355
  return { ...s, notify: { ...s.notify, ...a.patch } };
225
356
  case 'welcome':
226
357
  return { ...s, history: [...s.history, { id: nid(), kind: 'welcome', version: a.version }] };
358
+ case 'introShow': // /welcome: the first screen again (Q art + the greeting text), settled in history
359
+ return { ...s, history: [...s.history, { id: nid(), kind: 'intro', text: a.text, cols: a.cols }] };
227
360
  case 'help':
228
361
  return { ...s, history: [...s.history, { id: nid(), kind: 'help', model: a.model }] };
229
362
  case 'object': // inline object view (objects/): pure data, rebuilt at render width
@@ -232,6 +365,8 @@ function reduceInner(s, a) {
232
365
  return flush(addBlock(s, { kind: 'error', text: a.text }), false);
233
366
  case 'verbose':
234
367
  return { ...s, verbose: a.mode };
368
+ case 'aocl.table': // /aocl: the last turn's per-layer table, on demand
369
+ return { ...s, history: [...s.history, { id: nid(), kind: 'aocl', table: true, sum: s.lastAocl || null }] };
235
370
  case 'history.add': // restored turns (/resume): ordinary history items, plus their answers for /copy
236
371
  return { ...s, history: [...s.history, ...(a.items || []).map(i => ({ id: nid(), ...i }))], answers: [...(s.answers || []), ...(a.answers || [])].slice(-20) };
237
372
  case 'history.reset': // /clear: a fresh screen; nothing on the server is touched
@@ -273,20 +408,47 @@ function reduceInner(s, a) {
273
408
  }
274
409
  case 'interrupt.requested':
275
410
  return s.turn ? { ...s, turn: { ...s.turn, status: 'interrupting' } } : s;
411
+ case 'turn.deferred': // QUOXON-FW3: the send never reached the server; end the turn quietly (no failure summary, nothing ran)
412
+ return { ...s, turn: null, blocks: [], autoDrain: false };
276
413
  case 'stopped.local': { // SSE aborted by us because the server has no interrupt route
277
414
  let n = note(s, 'Stopped locally; work already sent may still finish. Verify before retrying.', 'warn');
278
415
  return endTurn(n, 'interrupted', a.now);
279
416
  }
280
417
  case 'approval.found': { // pending approval seen by polling (covers provider paths that emit no approval.requested)
281
418
  const card = cardFromInboxItem(a.item, { since: s.turn?.startedAt ?? a.since ?? 0, userId: a.userId, conversationId: s.ctx.conversationId, now: a.now || 0 });
419
+ 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
+ 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 } }; }
422
+ }
423
+ if (card && card.approvalId) { // an event stub that could not be described learns its description from the inbox item
424
+ 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 }; }
426
+ }
282
427
  if (!card || s.seenInbox[card.inboxItemId] || s.approvals.some(x => x.inboxItemId === card.inboxItemId || (card.approvalId && x.approvalId === card.approvalId))) return s;
283
- return { ...s, approvals: [...s.approvals, card], seenInbox: { ...s.seenInbox, [card.inboxItemId]: true }, turn: s.turn ? { ...s.turn, approvals: s.turn.approvals + 1 } : s.turn };
428
+ if (card.foreign) { // another conversation's card: a compact notice, never the focused card
429
+ if (s.waiting.some(x => x.inboxItemId === card.inboxItemId)) return s;
430
+ return { ...s, waiting: [...s.waiting, card] };
431
+ }
432
+ return { ...s, approvals: [...s.approvals, { ...card, turnId: s.turn?.id ?? null }], seenInbox: { ...s.seenInbox, [card.inboxItemId]: true }, turn: s.turn ? { ...s.turn, approvals: s.turn.approvals + 1 } : s.turn };
433
+ }
434
+ case 'approval.gone': { // the server says it no longer knows this approval (404): someone already handled it
435
+ const hit = x => x.callId === a.callId || (a.inboxItemId && x.inboxItemId === a.inboxItemId);
436
+ const had = s.approvals.some(hit) || s.waiting.some(hit);
437
+ const n = { ...s, inbox: (s.inbox || []).filter(x => x.id !== a.inboxItemId), approvals: s.approvals.filter(x => !hit(x)), waiting: s.waiting.filter(x => !hit(x)), seenInbox: a.inboxItemId ? { ...s.seenInbox, [a.inboxItemId]: true } : s.seenInbox };
438
+ return had ? note(n, 'approval already handled', 'accent') : n;
284
439
  }
285
440
  case 'approval.dismiss':
286
441
  return { ...s, approvals: s.approvals.filter(x => x.callId !== a.callId) };
287
442
  case 'approval.sent': { // the human answered from this terminal
288
- const rest = { ...s, approvals: s.approvals.filter(x => x.callId !== a.callId) };
443
+ 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
+ if (a.batch) return rest; // /approve N prints its own one-line summary
445
+ if (a.quiet && a.action === 'approve') return rest;
289
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');
448
+ if (a.tool === 'hat.switch') { // applied server-side by the inbox approve; nothing to wait for
449
+ const to = String(a.target || '').split('\u2192').pop().replace(/\(privileged\)/, '').trim() || 'the new hat';
450
+ return note(rest, `Hat switched to ${to}. Ask again and I'll carry on.`, 'ok');
451
+ }
290
452
  const run = { callId: a.callId, approvalId: a.approvalId || null, tool: a.tool, target: a.target };
291
453
  return note({ ...rest, running: [...rest.running.filter(x => x.callId !== a.callId), run] },
292
454
  `approved; running (result arrives asynchronously)${a.tool ? `: ${a.tool}${a.target ? ` on ${a.target}` : ''}` : ''}`, 'ok');
@@ -298,12 +460,15 @@ function reduceInner(s, a) {
298
460
  if (a.outcome === 'done') return note(n, `result: ${what}: ${a.text || 'completed'}`, 'ok');
299
461
  if (a.outcome === 'failed') return note(n, `failed: ${what}: ${a.text || 'error'}`, 'warn');
300
462
  if (a.outcome === 'denied') return note(n, `denied: ${what}`, 'warn');
301
- return note(n, `result not known yet: ${what}; check /approvals`, 'warn');
463
+ // FW4: never silent and never a guess. Say what is unknown and where the authoritative answer is.
464
+ 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)';
465
+ return note(n, `result not known yet: ${what}; approved, but the server has not said whether it ran. It may still be running or may have run. Check: ${check}`, 'warn');
302
466
  }
303
467
  case 'inbox.sync': { // open approval ids from a successful inbox refresh
304
468
  const ids = new Set(a.ids);
305
469
  const approvals = s.approvals.filter(x => !x.inboxItemId || ids.has(x.inboxItemId) || (a.now || 0) - x.shownAt < 5000);
306
- return approvals.length === s.approvals.length ? s : { ...s, approvals };
470
+ const waiting = s.waiting.filter(x => ids.has(x.inboxItemId));
471
+ return approvals.length === s.approvals.length && waiting.length === s.waiting.length ? s : { ...s, approvals, waiting };
307
472
  }
308
473
  case 'event':
309
474
  return reduceEvent(s, a.ev, a.now || 0);
@@ -315,6 +480,11 @@ function reduceInner(s, a) {
315
480
  /** The first card is armed from the moment it is actually SHOWN, not from when it was received. */
316
481
  export function reduce(s, a) {
317
482
  let n = reduceInner(s, a);
483
+ // TUI-DIVIDER: the bottom-rule AOCL strip, moved only by real turn events (dividerAocl.js).
484
+ if (a.type === 'turn.begin' || a.type === 'event') {
485
+ const dv = dividerStep(s.divider ?? null, a, a.now || 0);
486
+ if (dv !== (n.divider ?? null)) n = { ...n, divider: dv };
487
+ }
318
488
  // The honest activity word: only real events move it (see activity.js).
319
489
  if (n.turn) {
320
490
  const act = a.type === 'event' ? nextActivity(s.turn?.act, a.ev, a.now || 0, { hasText: Boolean(n.turn.chars) })
@@ -331,12 +501,15 @@ export function reduce(s, a) {
331
501
  function reduceEvent(s, ev, now) {
332
502
  switch (ev.type) {
333
503
  case 'connected': return s;
334
- // TODO(contract v2): background.status is not in events v1; wire here once the server defines it.
504
+ // background.status is not in events v1 yet; handled so the line works the moment the server sends it (RESTORE-TUI: 4fbd3178c dropped it).
505
+ case 'background.status': return { ...s, background: Math.max(0, Number(ev.pending) || 0) };
335
506
  case 'turn.started': {
336
507
  const ctx = {
337
508
  agent: ev.agent ?? s.ctx.agent, model: ev.model ?? s.ctx.model, provider: ev.provider ?? s.ctx.provider,
338
509
  // B3: the host a self-hosted provider ran at; cleared when the server names none.
339
510
  endpoint: ev.provider ? (ev.endpoint ?? null) : (s.ctx.endpoint ?? null),
511
+ // QUOXON-PARITY: which Claude path ran (subscription CLI vs API key); cleared like endpoint.
512
+ auth: ev.provider ? (ev.auth ?? null) : (s.ctx.auth ?? null),
340
513
  hat: ev.hat ?? s.ctx.hat, conversationId: ev.conversation_id ?? s.ctx.conversationId
341
514
  };
342
515
  // The model THIS turn actually used, as the server reported it (never the profile's default).
@@ -378,13 +551,14 @@ function reduceEvent(s, ev, now) {
378
551
  case 'replay.gap':
379
552
  return addBlock(s, { kind: 'note', text: `Events ${ev.from} to ${ev.to} of this turn were not kept on the server (size cap); the rest follows.`, tone: 'warn' });
380
553
  case 'tool.started':
381
- return upsertTool(s, ev, { status: 'running', summary: ev.summary || ev.tool, startedAt: now });
554
+ return upsertTool(s, ev, { status: 'running', summary: ev.summary || ev.tool, startedAt: now, ...delegateInfo(ev) });
382
555
  case 'tool.finished': {
383
556
  const status = TOOL_STATUSES.includes(ev.status) ? ev.status : 'unknown'; // never green for a status we do not know
384
557
  const first = !s.doneCalls[ev.call_id] && ['ok', 'failed', 'denied'].includes(status);
385
558
  let withTool = upsertTool(s, ev, {
386
- status, resultSummary: ev.result_summary, durationMs: ev.duration_ms,
387
- envelopeId: ev.envelope_id, receipt: ev.receipt || null
559
+ status, resultSummary: toolResultText(ev.result_summary, s.verbose), rawSummary: ev.result_summary, durationMs: ev.duration_ms,
560
+ envelopeId: ev.envelope_id, receipt: ev.receipt || null,
561
+ ...(Number.isFinite(ev.tools_used) ? { toolsUsed: ev.tools_used } : {})
388
562
  });
389
563
  if (first && withTool.turn) withTool = { ...withTool, turn: { ...withTool.turn, finishedTools: withTool.turn.finishedTools + 1 } };
390
564
  if (ev.receipt && ev.receipt.verified === true && withTool.turn && ev.receipt.ward_seq != null) {
@@ -396,7 +570,9 @@ function reduceEvent(s, ev, now) {
396
570
  const card = {
397
571
  callId: ev.call_id, inboxItemId: ev.inbox_item_id, approvalId: ev.approval_id, tool: ev.tool,
398
572
  target: ev.target, scope: ev.scope, risk: ev.risk, url: ev.dashboard_url, shownAt: now,
399
- undescribable: ev.describable === false || !ev.target
573
+ undescribable: ev.describable === false || !ev.target,
574
+ 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 })
400
576
  };
401
577
  if (s.approvals.some(x => x.callId === card.callId)) return s;
402
578
  const dup = s.approvals.findIndex(x => (card.inboxItemId && x.inboxItemId === card.inboxItemId) || (card.approvalId && x.approvalId === card.approvalId));
@@ -404,13 +580,15 @@ function reduceEvent(s, ev, now) {
404
580
  const approvals = s.approvals.slice(); approvals[dup] = { ...approvals[dup], callId: card.callId };
405
581
  return { ...s, approvals };
406
582
  }
583
+ const wi = card.inboxItemId ? s.waiting.findIndex(x => x.inboxItemId === card.inboxItemId) : -1;
584
+ if (wi >= 0) s = { ...s, waiting: s.waiting.filter((_, i) => i !== wi) }; // the stream says it is ours after all
407
585
  if (card.inboxItemId && s.seenInbox[card.inboxItemId]) return s; // already shown and answered
408
586
  return { ...s, seenInbox: card.inboxItemId ? { ...s.seenInbox, [card.inboxItemId]: true } : s.seenInbox, approvals: [...s.approvals, card], turn: s.turn ? { ...s.turn, approvals: s.turn.approvals + 1 } : s.turn };
409
587
  }
410
588
  case 'approval.resolved': {
411
589
  const card = s.approvals.find(x => x.callId === ev.call_id);
412
590
  const n = { ...s, approvals: s.approvals.filter(x => x.callId !== ev.call_id) };
413
- return card || ev.decision ? note(n, `approval ${ev.decision || 'resolved'}${card ? `: ${card.tool} on ${card.target}` : ''}`, ev.decision === 'approved' ? 'ok' : 'warn') : n;
591
+ 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;
414
592
  }
415
593
  case 'steer.accepted': {
416
594
  const i = s.steering.findIndex(x => x.text === ev.text);
@@ -418,10 +596,27 @@ function reduceEvent(s, ev, now) {
418
596
  return addBlock({ ...s, steering }, { kind: 'note', text: `steered: ${ev.text}`, tone: 'accent' });
419
597
  }
420
598
  case 'trace':
599
+ // QUOXON-AOCL: per-layer AOCL rows fold into ONE line per turn (aocl.summary); /aocl shows the table.
600
+ if (ev.protocol === 'volt' && /tools offered/i.test(ev.label || '')) { // kept for the footer count and /aocl, shown only in verbose
601
+ const m = String(ev.label).match(/(\d+)\s+of\s+(\d+)/);
602
+ const list = String(ev.detail || '');
603
+ const toolsOffered = m ? Number(m[1]) : (list ? list.split(/,\s*/).filter(Boolean).length : null);
604
+ if (s.turn) s = { ...s, turn: { ...s.turn, toolsOffer: { offered: toolsOffered, total: m ? Number(m[2]) : null, list } } };
605
+ }
606
+ if (isLayerTrace(ev)) return s.turn ? { ...s, turn: { ...s.turn, aoclTraces: [...(s.turn.aoclTraces || []), { label: ev.label, detail: ev.detail }] } } : s;
421
607
  if (s.verbose === 'off') return s;
422
608
  return addBlock(s, { kind: 'trace', protocol: ev.protocol, label: ev.label, ref: ev.ref, detail: ev.detail });
609
+ case 'aocl.summary': {
610
+ const sum = withTools(normaliseSummary(ev), s.turn);
611
+ const n = { ...s, lastAocl: sum, turn: s.turn ? { ...s.turn, aoclSummary: true } : s.turn };
612
+ return addBlock(n, { kind: 'aocl', sum }); // the one-line footer shows at every verbosity
613
+ }
614
+ case 'aocl.verdict': {
615
+ const v = verdictNote(ev);
616
+ return v.tone === 'error' ? addBlock(s, { kind: 'error', text: v.text }) : addBlock(s, { kind: 'note', text: v.text, tone: v.tone });
617
+ }
423
618
  case 'error': {
424
- const n = addBlock(s, { kind: 'error', text: String(ev.error || 'stream error') });
619
+ const n = addBlock(s, { kind: 'error', text: isConsentError(ev) ? consentErrorText(ev) : String(ev.error || 'stream error') });
425
620
  return n;
426
621
  }
427
622
  case 'turn.finished': case 'done': {
@@ -435,8 +630,16 @@ function reduceEvent(s, ev, now) {
435
630
  // `done` only carries text/session; turn.finished (sent just after it) ends the turn. A legacy
436
631
  // server never sends turn.finished: the app synthesises one when the stream closes.
437
632
  // usage: `done` may carry cost_usd; turn.finished carries `usage` (input/output tokens, maybe cost_usd).
438
- if (ev.type === 'done') return { ...n, turn: { ...n.turn, doneCost: ev.cost_usd } };
439
- n = { ...n, turn: { ...n.turn, usage: normUsage(ev.usage, n.turn.doneCost) } };
633
+ // cli_session_id on `done` = the claude CLI path: its cost is that CLI's own estimate even from a server that
634
+ // does not label it yet (cost_source 'claude-cli', R3 D5).
635
+ if (ev.type === 'done') return { ...n, turn: { ...n.turn, doneCost: ev.cost_usd, viaCli: Boolean(ev.cli_session_id) } };
636
+ // An older server sends per-layer AOCL traces and no aocl.summary: fold them into the one line here.
637
+ if (!n.turn.aoclSummary && n.turn.aoclTraces?.length) {
638
+ const sum = withTools(summaryFromTraces(n.turn.aoclTraces), n.turn);
639
+ n = { ...n, lastAocl: sum };
640
+ n = addBlock(n, { kind: 'aocl', sum });
641
+ }
642
+ n = { ...n, turn: { ...n.turn, usage: normUsage(n.turn.viaCli && ev.usage && !ev.usage.cost_source ? { ...ev.usage, cost_source: 'claude-cli' } : ev.usage, n.turn.doneCost) } };
440
643
  return endTurn(n, ev.status || 'completed', now);
441
644
  }
442
645
  default:
@@ -456,3 +659,19 @@ export function approvalKeyAllowed({ now, shownAt, lastKeyAt, heldKey = null, ch
456
659
  if (heldKey != null && ch === heldKey && (lastKeyAt == null || now - lastKeyAt < HELD_KEY_MS)) return false;
457
660
  return true;
458
661
  }
662
+
663
+ /**
664
+ * What an approval is for, without repeating who asked: titles often start "<who> wants to: ..." (the source label
665
+ * already says who), and a generic "Approval required" title falls back to the item's summary or description.
666
+ */
667
+ export function approvalWhat(it) {
668
+ const w = it?.payload?.what;
669
+ if (w && typeof w === 'object' && typeof w.summary === 'string' && w.summary.trim()) { // APPROVAL-WHAT: the server says what will happen
670
+ const s = w.summary.trim(); const tg = typeof w.target === 'string' ? w.target.trim() : '';
671
+ return tg && !s.includes(tg) ? `${s} (target: ${tg})` : (!tg && w.target_none ? `${s} (${String(w.target_none).trim()})` : s);
672
+ }
673
+ let t = String(it?.title || it?.id || '').replace(/^[^:]{0,80}\bwants to:\s*/i, '').trim();
674
+ const more = it?.summary || it?.description || it?.detail || '';
675
+ if ((!t || /^approval (?:required|needed)\b/i.test(t)) && more) t = String(more).trim();
676
+ return t || String(it?.id || 'approval');
677
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * /feedback: tell the Quox team something, through the public quox.ai feedback API
3
+ * (POST https://api.quox.ai/api/feedback, anonymous, 5 per hour per IP; lands in the quox.ai admin feedback view).
4
+ * /gripe stays the GitHub path. What is sent is type + message (the CLI version is appended to the message)
5
+ * and nothing else: no name, email, org, profile or token. The user sees the exact payload and says y first.
6
+ */
7
+ export const FEEDBACK_URL = 'https://api.quox.ai/api/feedback';
8
+ const MIN = 5, MAX = 5000; // the endpoint refuses outside these (api/routes/feedback.js in quox-website)
9
+ const TIMEOUT_MS = 15000;
10
+
11
+ /** -> { type:'feedback', message } | null when the endpoint would refuse it. */
12
+ export function feedbackPayload(text, version) {
13
+ const body = String(text || '').trim();
14
+ if (body.length < MIN) return null;
15
+ const message = `${body}\n\n(sent with /feedback from quox CLI ${version || 'unknown'})`;
16
+ if (message.length > MAX) return null;
17
+ return { type: 'feedback', message };
18
+ }
19
+
20
+ /** The lines shown before asking: where it goes and exactly what is in the body. */
21
+ export function feedbackPreview(payload, url = FEEDBACK_URL) {
22
+ return [
23
+ `POST ${url} (the Quox team at quox.ai, not your Quox server)`,
24
+ 'type: feedback',
25
+ 'message:',
26
+ ...String(payload.message).split('\n').map(l => ` ${l}`),
27
+ 'Nothing else: no name, email, org or token.',
28
+ ];
29
+ }
30
+
31
+ /** -> { ok:true, id, url } | { ok:false, message }. Status is checked before the body is trusted. */
32
+ export async function sendFeedback(payload, { fetchImpl = globalThis.fetch, url = FEEDBACK_URL, version = null, timeoutMs = TIMEOUT_MS } = {}) {
33
+ const ctl = new AbortController();
34
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
35
+ try {
36
+ const res = await fetchImpl(url, {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': `quox-cli/${version || 'unknown'}` },
39
+ body: JSON.stringify({ type: payload.type, message: payload.message }),
40
+ signal: ctl.signal,
41
+ });
42
+ let data = null;
43
+ try { data = await res.json(); } catch { data = null; }
44
+ if (!res.ok) return { ok: false, message: `HTTP ${res.status}: ${(data && data.error) || 'the feedback API refused it'}` };
45
+ if (!data || data.success !== true) return { ok: false, message: `HTTP ${res.status} but the reply did not confirm it was stored` };
46
+ return { ok: true, id: data.id ?? null, url };
47
+ } catch (e) {
48
+ return { ok: false, message: e?.name === 'AbortError' ? `no answer from ${url} within ${Math.round(timeoutMs / 1000)} s` : (e?.message || 'failed') };
49
+ } finally { clearTimeout(t); }
50
+ }
@@ -1,17 +1,19 @@
1
1
  /**
2
2
  * `quox motion`: the product version of the animation lab (~/quoxon-work/intro/lab). Plays every Q motion
3
- * with a caption: Orbit & Lock, Portal, Particle collapse, the Dragon's Eye, the perpetual status Q in its
3
+ * with a caption: Orbit & Lock, Portal, Particle collapse, the perpetual status Q in its
4
4
  * four states, the hello, the composer ring and the reveal effects. No server, no account, nothing sent.
5
5
  *
6
6
  * Scenes are pure: galleryScenes() returns { id, title, caption, ms, frame(t) -> ANSI lines }. runGallery()
7
7
  * is the only part that touches a terminal (cursor, timers, signals).
8
8
  */
9
9
  import {
10
- labSize, orbitCellsAt, portalCellsAt, particleCellsAt, eyeCellsAt, cellsToAnsi, qCells,
11
- INTRO_MS, PORTAL_MS, EYE_MS, SKY, VIOLET, AMBER,
10
+ labSize, orbitCellsAt, portalCellsAt, particleCellsAt, cellsToAnsi, qCells,
11
+ INTRO_MS, PORTAL_MS, SKY, VIOLET, AMBER,
12
12
  } from './qart.js';
13
13
  import { qGlyph, glyphAnsi, GLYPH_STATES } from './qglyph.js';
14
14
  import { helloCells, helloAnsi, helloGradient, helloMs } from './qhello.js';
15
+ import { CLIPS, MOOD_CLIPS, clipCells, clipCaption } from './qdragonScenes.js';
16
+ import { isLight } from './theme.js';
15
17
 
16
18
  const RESET = '\x1b[0m';
17
19
  const fg = (rgb) => `\x1b[38;2;${rgb.map((v) => Math.max(0, Math.min(255, Math.round(v)))).join(';')}m`;
@@ -125,8 +127,12 @@ export const PERP_MS = 2600;
125
127
  * The gallery's scenes for a terminal. narrow forces the 40-column layout (the compact particle Q).
126
128
  * Each scene: { id, title, caption, ms, frame(t, { mono }) -> string[] }.
127
129
  */
128
- export function galleryScenes({ cols = 100, rows = 30, narrow = false } = {}) {
130
+ export function galleryScenes({ cols = 100, rows = 30, narrow = false, theme = isLight() ? 'light' : 'dark' } = {}) {
129
131
  const W = narrow ? 40 : cols;
132
+ // the dragon (qdragonScenes.js): eyes as quadrant blocks with a background colour, the lab's light palette on light terminals
133
+ const fit = (str) => (str.length > W ? str.slice(0, W - 1) + '…' : str);
134
+ const dragon = (name, t, o) => [...art(clipCells(name, t, { cols: W, theme, mono: o.mono })), '', paint(fit(clipCaption(name, t)), MUTED, o.mono)];
135
+ const MOOD_SPAN = MOOD_CLIPS.map((m) => CLIPS[m].dur + 400);
130
136
  const size = narrow ? 'small' : labSize(W, rows);
131
137
  const big = size === 'small' ? 'small' : size;
132
138
  const art = (cells) => cellsToAnsi(cells);
@@ -138,8 +144,6 @@ export function galleryScenes({ cols = 100, rows = 30, narrow = false } = {}) {
138
144
  frame: (t, o) => art(portalCellsAt(big, t, { color: !o.mono })) },
139
145
  { id: 'particle', title: 'Particle collapse', caption: 'density rises on a circle, then collapses into the compact Q', ms: 1100 + 500,
140
146
  frame: (t, o) => art(particleCellsAt('small', t, { color: !o.mono })) },
141
- { id: 'eye', title: "The Dragon's Eye", caption: 'a slit-pupil eye opens in the bowl, blinks once, and dissolves back into the Q (/eye; rarely on launch)', ms: EYE_MS + 500,
142
- frame: (t, o) => art(eyeCellsAt(big, t, { color: !o.mono })) },
143
147
  { id: 'hello', title: 'Hello', caption: 'the Q locks, then says hello: the line decodes in from katakana with a short afterglow', ms: INTRO_MS + helloMs(hello) + 600,
144
148
  frame: (t, o) => {
145
149
  const q = art(t < INTRO_MS ? orbitCellsAt(big, t, { color: !o.mono }) : qCells(big, { color: !o.mono }));
@@ -162,9 +166,19 @@ export function galleryScenes({ cols = 100, rows = 30, narrow = false } = {}) {
162
166
  frame: (t, o) => ringLines(Math.min(W - 2, 72), ringState(t), o.mono) },
163
167
  { id: 'fx', title: 'Reveal effects', caption: 'tool row, answer drop-in, approval arming, receipt seal, ProofFlow, closing line', ms: FX_MS,
164
168
  frame: (t, o) => fxLines(t, Math.min(W - 2, 78), o.mono) },
169
+ { id: 'dragon', title: CLIPS.pick.title, caption: CLIPS.pick.caption, ms: CLIPS.pick.dur, frame: (t, o) => dragon('pick', t, o) },
170
+ { id: 'dragon-fire', title: CLIPS.fire.title, caption: CLIPS.fire.caption, ms: CLIPS.fire.dur, frame: (t, o) => dragon('fire', t, o) },
171
+ { id: 'dragon-moods', title: 'Dragon moods', caption: 'at the cinematic motion level the dragon reacts: wide eyes and an amber glint while it waits for you, a wink when done, a puff of smoke on failure, and a greeting on launch',
172
+ ms: MOOD_SPAN.reduce((a, b) => a + b, 0),
173
+ frame: (t, o) => {
174
+ let i = 0, s0 = 0;
175
+ while (i < MOOD_CLIPS.length - 1 && t >= s0 + MOOD_SPAN[i]) s0 += MOOD_SPAN[i++];
176
+ const m = MOOD_CLIPS[i];
177
+ return [...art(clipCells(m, t - s0, { cols: Math.min(W, 64), theme, mono: o.mono })), '', paint(fit(`${m}: ${CLIPS[m].caption.split(':')[1].replace(/\s*\(.*\)$/, '').trim()}`), MUTED, o.mono)];
178
+ } },
165
179
  ];
166
180
  }
167
- export const SCENE_IDS = ['orbit', 'portal', 'particle', 'eye', 'hello', 'perpetual', 'ring', 'fx'];
181
+ export const SCENE_IDS = ['orbit', 'portal', 'particle', 'hello', 'perpetual', 'ring', 'fx', 'dragon', 'dragon-fire', 'dragon-moods'];
168
182
 
169
183
  // ---- runner (the only impure part) ---------------------------------------------------------------------------
170
184
  const visible = (s) => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ''); // eslint-disable-line no-control-regex