quoxcli 0.3.1 → 0.4.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 (188) hide show
  1. package/lib/api/endpoints/actions.js +15 -0
  2. package/lib/api/endpoints/admin.js +20 -0
  3. package/lib/api/endpoints/blog.js +16 -0
  4. package/lib/api/endpoints/chat.js +7 -0
  5. package/lib/api/endpoints/contactsq.js +4 -0
  6. package/lib/api/endpoints/devServerRegistry.js +25 -0
  7. package/lib/api/endpoints/engagement.js +26 -0
  8. package/lib/api/endpoints/evidence.js +20 -0
  9. package/lib/api/endpoints/hat.js +14 -0
  10. package/lib/api/endpoints/index.js +12 -0
  11. package/lib/api/endpoints/loops.js +26 -0
  12. package/lib/api/endpoints/mon.js +173 -0
  13. package/lib/api/endpoints/objectives.js +6 -0
  14. package/lib/api/endpoints/quoxseo.js +6 -0
  15. package/lib/api/endpoints/server.js +21 -0
  16. package/lib/api/endpoints/setup.js +6 -0
  17. package/lib/api/endpoints/v1.js +34 -0
  18. package/lib/commands/actions.js +81 -11
  19. package/lib/commands/agent.js +11 -4
  20. package/lib/commands/agentAttach.js +46 -7
  21. package/lib/commands/approvals.js +5 -0
  22. package/lib/commands/ask.js +3 -1
  23. package/lib/commands/blog.js +110 -0
  24. package/lib/commands/chat.js +127 -28
  25. package/lib/commands/chatCompanion.js +27 -0
  26. package/lib/commands/chatState.js +45 -0
  27. package/lib/commands/claude.js +3 -32
  28. package/lib/commands/contacts.js +85 -4
  29. package/lib/commands/contactsSendAt.js +21 -0
  30. package/lib/commands/engagement.js +90 -0
  31. package/lib/commands/evidence.js +154 -0
  32. package/lib/commands/ext.js +23 -0
  33. package/lib/commands/fleet.js +25 -0
  34. package/lib/commands/hat.js +76 -0
  35. package/lib/commands/image.js +68 -0
  36. package/lib/commands/kuma.js +1050 -0
  37. package/lib/commands/kumaSla.js +174 -0
  38. package/lib/commands/licence.js +39 -1
  39. package/lib/commands/loop.js +291 -0
  40. package/lib/commands/objective.js +48 -1
  41. package/lib/commands/org.js +21 -4
  42. package/lib/commands/provider.js +58 -0
  43. package/lib/commands/quoxseo.js +31 -0
  44. package/lib/commands/research.js +79 -0
  45. package/lib/commands/rows.js +9 -0
  46. package/lib/commands/run.js +18 -1
  47. package/lib/commands/scope.js +2 -2
  48. package/lib/commands/server.js +113 -0
  49. package/lib/commands/shortcut.js +41 -0
  50. package/lib/commands/skills.js +20 -4
  51. package/lib/commands/vm.js +2 -2
  52. package/lib/commands/youtube.js +172 -0
  53. package/lib/core/actionItems.js +115 -0
  54. package/lib/core/agentAlias.js +2 -0
  55. package/lib/core/agentFamilies.js +7 -0
  56. package/lib/core/client.js +10 -2
  57. package/lib/core/commandRegistrars.js +9 -0
  58. package/lib/core/config.js +1 -1
  59. package/lib/core/findingReconcile.js +90 -0
  60. package/lib/core/global-flags.js +1 -1
  61. package/lib/core/governedSession.js +1 -3
  62. package/lib/core/liveSessionsReport.js +1 -1
  63. package/lib/core/loopGlyph.js +97 -0
  64. package/lib/core/offload.js +36 -7
  65. package/lib/core/output.js +7 -7
  66. package/lib/core/sessionHeartbeat.js +12 -8
  67. package/lib/core/shortcut.js +83 -0
  68. package/lib/core/version.js +1 -1
  69. package/lib/core/vm.js +5 -0
  70. package/lib/dragon/companionView.js +34 -0
  71. package/lib/dragon/frames.js +118 -0
  72. package/lib/dragon/jobs.js +123 -0
  73. package/lib/dragon/renderWorker.js +15 -0
  74. package/lib/dragon/select.js +72 -0
  75. package/lib/evidence/bundleExport.js +47 -0
  76. package/lib/evidence/bundleVerify.js +77 -0
  77. package/lib/quoxon/EXTENSIONS.md +23 -0
  78. package/lib/quoxon/api.js +192 -6
  79. package/lib/quoxon/app.js +655 -89
  80. package/lib/quoxon/approvalFacts.js +8 -6
  81. package/lib/quoxon/approvalSentence.js +44 -0
  82. package/lib/quoxon/approvalsView.js +63 -4
  83. package/lib/quoxon/commandShort.js +15 -3
  84. package/lib/quoxon/commands.js +75 -10
  85. package/lib/quoxon/countdownLine.js +10 -1
  86. package/lib/quoxon/deck.js +30 -0
  87. package/lib/quoxon/demo.autoplay.js +1 -1
  88. package/lib/quoxon/demo.js +3 -1
  89. package/lib/quoxon/dividerAocl.js +4 -3
  90. package/lib/quoxon/dividerRule.js +3 -3
  91. package/lib/quoxon/dragonCompanion.js +255 -0
  92. package/lib/quoxon/dragonModes.js +1 -0
  93. package/lib/quoxon/dragonMoments.js +82 -0
  94. package/lib/quoxon/dragonReads.js +68 -0
  95. package/lib/quoxon/dragonView.js +2 -2
  96. package/lib/quoxon/events.js +24 -3
  97. package/lib/quoxon/extensions.js +276 -0
  98. package/lib/quoxon/inboxView.js +79 -0
  99. package/lib/quoxon/installCli.js +79 -0
  100. package/lib/quoxon/keys.js +2 -2
  101. package/lib/quoxon/loopCard.js +456 -0
  102. package/lib/quoxon/loopCards.js +180 -0
  103. package/lib/quoxon/loopStream.js +87 -0
  104. package/lib/quoxon/main.js +12 -3
  105. package/lib/quoxon/mouse.js +12 -1
  106. package/lib/quoxon/objectiveOffer.js +82 -0
  107. package/lib/quoxon/objects/index.js +22 -4
  108. package/lib/quoxon/objects/model.js +33 -0
  109. package/lib/quoxon/objects/proof.js +12 -4
  110. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  111. package/lib/quoxon/onboarding/view.js +1 -1
  112. package/lib/quoxon/orgSwitch.js +27 -1
  113. package/lib/quoxon/planSteps.js +51 -0
  114. package/lib/quoxon/print.js +6 -3
  115. package/lib/quoxon/qdragonFly.js +7 -6
  116. package/lib/quoxon/qmotion.js +17 -0
  117. package/lib/quoxon/render.js +130 -33
  118. package/lib/quoxon/roster.js +26 -12
  119. package/lib/quoxon/rows.js +244 -1
  120. package/lib/quoxon/serverChip.js +42 -0
  121. package/lib/quoxon/session.js +10 -0
  122. package/lib/quoxon/slash/cliCommand.js +42 -1
  123. package/lib/quoxon/slash/cliLeaves.json +76 -1
  124. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  125. package/lib/quoxon/slash/palette.js +1 -1
  126. package/lib/quoxon/slash/qpeCmd.js +35 -0
  127. package/lib/quoxon/slash/restart.js +25 -1
  128. package/lib/quoxon/slash/resume.js +18 -1
  129. package/lib/quoxon/slash/todoCmd.js +122 -0
  130. package/lib/quoxon/suggestCommand.js +33 -2
  131. package/lib/quoxon/toolCards.js +30 -0
  132. package/lib/quoxon/whatsnew.jsonl +45 -0
  133. package/lib/quoxon/windowOrg.js +11 -1
  134. package/lib/server/devServers.js +137 -0
  135. package/package.json +2 -1
  136. package/vendor/dragonlab/SOURCE +4 -0
  137. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  138. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  139. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  140. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  141. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  142. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  143. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  144. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  145. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  146. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  147. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  148. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  149. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  150. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  151. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  152. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  153. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  154. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  155. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  156. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  157. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  158. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  159. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  160. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  161. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  162. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  163. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  164. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  165. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  166. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  167. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  168. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  169. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  170. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  171. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  172. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  173. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  174. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  175. package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
  176. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  177. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  178. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  179. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  180. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  181. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  182. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  183. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  184. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  185. package/vendor/dragonlab/lib/power-effects.js +294 -0
  186. package/vendor/dragonlab/lib/power-registry.js +74 -0
  187. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  188. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
@@ -38,7 +38,7 @@ export function paintFrame(f, { ruleColor, labelColor }) {
38
38
  const flush = () => { if (run) out += paint(run); run = ''; };
39
39
  for (const c of f.cells) {
40
40
  const rgb = c.rgb ? c.rgb.map(v => Math.max(0, Math.min(255, Math.round(v / RUN_Q) * RUN_Q))) : null;
41
- const st = { rgb, hex: rgb ? null : (c.label ? labelColor : ruleColor) || null, bold: Boolean(c.bold) };
41
+ const st = { rgb, hex: rgb ? null : (c.hex || (c.label ? labelColor : ruleColor)) || null, bold: Boolean(c.bold) };
42
42
  const k = `${rgb ? rgb.join(',') : st.hex}|${st.bold}`;
43
43
  if (k !== key) { flush(); key = k; style = st; }
44
44
  run += c.t;
@@ -48,10 +48,10 @@ export function paintFrame(f, { ruleColor, labelColor }) {
48
48
  }
49
49
 
50
50
  /** The bottom rule as a strip element, or null when the caller should draw its normal Rule (a plain call, not a component). */
51
- export function dividerRuleEl({ cols, label, divider, now, ruleColor, labelColor, mono = false, reduced = false, slow = null }) {
51
+ export function dividerRuleEl({ cols, label, extra = null, divider, now, ruleColor, labelColor, mono = false, reduced = false, slow = null }) {
52
52
  if (!divider) return null;
53
53
  const isSlow = noteFrameGap(slow, divider, now);
54
- const f = dividerFrame({ cols, label, d: divider, now, mono, reduced, still: isSlow }); // slow: keep the lit pips, drop the motion
54
+ const f = dividerFrame({ cols, label, extra, d: divider, now, mono, reduced, still: isSlow }); // slow: keep the lit pips, drop the motion
55
55
  if (!f) return null;
56
56
  return h(Text, { wrap: 'truncate-end' }, paintFrame(f, { ruleColor, labelColor }));
57
57
  }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Dragon companion for Quoxon (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher). The controller owns attach state and feeds REAL stream
3
+ * events to dragonlab's pure view (integration/companions-v1/view.mjs: the same reducer and renderer the CLI
4
+ * uses). No art, state logic or timers are duplicated here; this file only decides, translates and lays out.
5
+ * Decisions: docs/dragons/COMPANION-UX.md. The reducer never overwrites pending approvals or failures, but the host only SHOWS the dragon during a dwell (attach, key moment, invoke).
6
+ */
7
+ import React from 'react';
8
+ import { Box, Text } from 'ink';
9
+ import { existsSync } from 'node:fs';
10
+ import { pathToFileURL } from 'node:url';
11
+ import { cellNodes, dragonActive, clipRoom } from './qmotion.js';
12
+ import { useFrame } from './motion/frame.js';
13
+ import { loadCompanionView } from '../dragon/companionView.js';
14
+ import { ART_MIN_COLS as SLOT_MIN_COLS, ART_MIN_ROWS as SLOT_MIN_ROWS } from '../dragon/frames.js';
15
+ import { companionPlan, motionPolicy, normaliseMode, COMPANION_MODES, VENDORED_DRAGONLAB, dragonForAgent, companionMode } from '../dragon/select.js';
16
+ import { createMomentEngine, createRosterWatch, LONG_TASK_MS, SUMMON_COOLDOWN_MS } from './dragonMoments.js';
17
+ import { moodForEvent } from './motion/level.js';
18
+
19
+ const h = React.createElement;
20
+ const AGES_OUT = 10 * 60 * 1000; // an attach that is not a greeting starts with its entrance already over
21
+ /** How long the dragon stays after any appearance (attach, key moment, invoke). The one dwell constant. */
22
+ export const DRAGON_DWELL_MS = 10000;
23
+ /** A keypress inside the first part of a dwell does not send it away: invoking with Enter and then typing must not kill it. */
24
+ export const KEY_GRACE_MS = 2000;
25
+ /** A turn at least this long counts as a "long task" when it finishes (defined with the moment engine). */
26
+ export { LONG_TASK_MS };
27
+ /** Art that is still being drawn when its dwell would end is waited for this long (from the appearance) before giving up. */
28
+ export const FIRST_FRAME_GRACE_MS = 20000;
29
+
30
+ /** Art needs room; below this the companion is one plain text line. */
31
+ export const ART_MIN_COLS = SLOT_MIN_COLS, ART_MIN_ROWS = SLOT_MIN_ROWS;
32
+
33
+ /**
34
+ * Whether this /attach greets. Every explicit, typed /attach does (even a repeat of the same agent in one session); only an
35
+ * automatic re-attach (after a restart, or resuming a conversation) is quiet. The quiet flag is read AND cleared in one step
36
+ * at the start of the command, so an attach that bails early (a slot, a busy turn) can never leave it armed for the next typed one.
37
+ */
38
+ export function takeGreeting(flags) { const quiet = Boolean(flags.noGreeting); flags.noGreeting = false; return !quiet; }
39
+
40
+ /** Quoxon stream/poll facts -> the reducer's event vocabulary. Unknown shapes pass through (the reducer ignores them). */
41
+ export function toCompanionEvent(action) {
42
+ if (!action) return null;
43
+ if (action.type === 'turn.begin') return { type: 'turn.started', turn_id: action.turnId };
44
+ if (action.type === 'approval.found') {
45
+ const key = action.item?.id || action.item?.approval_id || action.item?.approvalId;
46
+ return key ? { type: 'approval.requested', call_id: String(key) } : null;
47
+ }
48
+ if (action.type === 'event') return action.ev || null;
49
+ return null;
50
+ }
51
+
52
+ export function createCompanionController({ settings = () => ({}), env = process.env, isTTY = true, exists = existsSync, now = Date.now, importer = null, makeView = loadCompanionView, jobs = null, typing = () => false } = {}) {
53
+ let noticed = false, showUntil = 0, shownAt = 0, unpainted = false, waitingFrame = false, trigger = 'attach', forced = false, timer = null, job = null;
54
+ let summoned = null, summonCaption = null; const lastSummon = new Map(); const rosterWatch = createRosterWatch(); // a domain moment's dragon (not the attached one) and its truthful caption
55
+ const moments = createMomentEngine({ now });
56
+ let agent = null, plan = { enabled: false, why: 'no agent attached' }, view = null, state = null, token = 0, greet = true;
57
+ const listeners = new Set();
58
+ const notify = () => listeners.forEach(f => f());
59
+ const replan = (as = null) => companionPlan({ agent: as ?? agent, settings: settings(), env: forced ? { ...env, QUOX_DRAGON_COMPANION: 'greeting' } : env, isTTY, exists, bundled: VENDORED_DRAGONLAB });
60
+
61
+ /** (Re)start the dwell from shownAt; a timer wakes the renderer at expiry. */
62
+ function arm() {
63
+ showUntil = shownAt + DRAGON_DWELL_MS;
64
+ clearTimeout(timer);
65
+ timer = setTimeout(() => { if (summoned && now() >= showUntil) restore(); else notify(); }, Math.max(0, showUntil - now()) + 50); timer.unref?.();
66
+ }
67
+ /** Show for one dwell, then leave on its own. The dwell and the keypress grace start when the first frame is actually drawn (see snapshot). */
68
+ function appear(why = 'attach') {
69
+ if (!view) return;
70
+ trigger = why; shownAt = now(); unpainted = true; waitingFrame = false;
71
+ arm();
72
+ readJob();
73
+ notify();
74
+ }
75
+ /** The dragon's one job: a real read (cached 30 s inside `jobs`), asked only when the dragon is about to be shown. Its caption replaces the generic one when it lands. */
76
+ function readJob() {
77
+ const mine = token, id = plan.id;
78
+ if (!jobs || !id) return;
79
+ const first = jobs.peek?.(id);
80
+ if (first) job = first;
81
+ Promise.resolve().then(() => jobs.run(id)).then((r) => { if (mine === token && r) { job = r; notify(); } }, () => { /* a failed job read leaves the generic caption: never a made-up one */ });
82
+ }
83
+ /** Still on screen: inside the dwell, or waiting for art that has not been drawn yet (the dwell then counts from the first picture). */
84
+ const live = () => now() < showUntil || (waitingFrame && unpainted && now() < shownAt + FIRST_FRAME_GRACE_MS);
85
+ /** Key moments only in mode `on`; `greeting` and `off` never show for approvals or failures. */
86
+ const keyMoment = () => { if (view && companionMode(settings(), env) === 'on') appear('moment'); };
87
+
88
+ /** A summoned dragon's dwell is over (or it was dismissed): drop it and put the attached agent's dragon back, quietly. */
89
+ function restore() {
90
+ summoned = null; summonCaption = null;
91
+ if (agent) load({ quiet: true }); else { token++; view?.dispose?.(); view = null; state = null; showUntil = 0; plan = replan(); notify(); }
92
+ }
93
+
94
+ async function load({ show = false, persist = null, why = 'attach', as = null, caption = null, quiet = false } = {}) {
95
+ const mine = ++token;
96
+ view?.dispose?.(); view = null; state = null; showUntil = 0; job = null;
97
+ summoned = as; summonCaption = caption;
98
+ plan = replan(as);
99
+ if (!plan.enabled) return notify();
100
+ try {
101
+ const opts = { still: plan.still, mono: plan.mono, persist: persist ?? plan.persist };
102
+ let v;
103
+ if (importer) v = await (await importer(pathToFileURL(plan.viewFile).href)).loadDragonView(plan.id, opts);
104
+ else {
105
+ // The playback path: frames come from a worker through a cache (dragon/frames.js). If the worker cannot start, the
106
+ // art still shows through dragonlab's own view (slower, inline), as before.
107
+ try { v = await makeView(plan.root, plan.id, { ...opts, persist: true, onFrame: notify }); } // persist: this host's dwell (showUntil) decides how long it stays, not the entrance length (Lucid's is 3 s)
108
+ catch { v = await (await import(pathToFileURL(plan.viewFile).href)).loadDragonView(plan.id, opts); }
109
+ }
110
+ if (mine !== token) { v.dispose?.(); return; }
111
+ view = v; state = v.initial(now() - (greet && !quiet ? 0 : AGES_OUT));
112
+ if (!quiet && (show || greet)) appear(why);
113
+ } catch (e) {
114
+ if (mine !== token) return;
115
+ plan = { ...plan, enabled: false, why: `could not load dragon art: ${e.message}` };
116
+ }
117
+ notify();
118
+ }
119
+
120
+ return {
121
+ subscribe(f) { listeners.add(f); return () => listeners.delete(f); },
122
+ /** Attach: entrance when `greet` (explicit attach), none when re-attaching after a restart. */
123
+ attach(a, { greet: g = true } = {}) { agent = a || null; greet = g; forced = false; moments.reset(); return load(); },
124
+ detach() { agent = null; token++; view?.dispose?.(); view = null; state = null; showUntil = 0; job = null; forced = false; clearTimeout(timer); plan = replan(); notify(); },
125
+ /** Re-read the saved mode (after /dragon companion on|off|greeting). An enable greets. */
126
+ refresh() { greet = true; forced = false; return load(); },
127
+ /** Explicit /dragon: show the attached agent's dragon for one dwell, in any mode (even off). false = no dragon agent attached. */
128
+ async invoke() {
129
+ if (!agent || !dragonForAgent(agent)) return false;
130
+ // The view hides itself once its arrival is over unless it persists, and `greeting`/`off` (or a resumed attach) do not persist:
131
+ // an explicit call reloads it persistent so the whole dwell shows, whatever mode and however long ago it attached.
132
+ if (!view || !plan.persist) { forced = true; await load({ show: true, persist: true, why: 'invoke' }); return Boolean(view); }
133
+ appear('invoke'); return true;
134
+ },
135
+ /** Send it away now. */
136
+ dismiss() { if (showUntil) { showUntil = 0; if (summoned) restore(); else notify(); } },
137
+ /** A keypress: sends it away, but not during the first KEY_GRACE_MS of a dwell (the Enter that invoked it is followed by typing). */
138
+ keypress() { if (showUntil && now() - shownAt >= KEY_GRACE_MS) this.dismiss(); },
139
+ /**
140
+ * A domain moment calls a dragon: companion mode `on` only, never while the user is typing, never while another dragon is on
141
+ * screen, and not the same dragon again inside SUMMON_COOLDOWN_MS. Works with nothing attached. The caption is the moment's own
142
+ * truthful line. -> true when it showed.
143
+ */
144
+ async summon(dragon, caption) {
145
+ if (companionMode(settings(), env) !== 'on' || typing() || live()) return false;
146
+ const last = lastSummon.get(dragon);
147
+ if (last != null && now() - last < SUMMON_COOLDOWN_MS) return false;
148
+ lastSummon.set(dragon, now());
149
+ if (agent && dragonForAgent(agent) === dragon && view && plan.persist) { summonCaption = caption; appear('summon'); return true; }
150
+ await load({ show: true, as: dragon, caption, persist: true, why: 'summon' });
151
+ return Boolean(view);
152
+ },
153
+ /** Roster rows (every refresh) -> the first objective/loop that just finished or failed summons its dragon. */
154
+ watchRoster(rows) { const m = rosterWatch.observe(rows)[0]; return m ? this.summon(m.dragon, m.caption) : Promise.resolve(false); },
155
+ /** True while the dragon is on screen and moving: the host asks the shared clock for frames only then. */
156
+ animating() { return Boolean(view && state && !plan.still && live()); },
157
+ /** Feed a reducer action (turn.begin / event / approval.found). Cheap no-op when no companion is active. */
158
+ tap(action) {
159
+ if (!view) return;
160
+ const ev = toCompanionEvent(action);
161
+ if (ev) { state = view.reduce(state, ev, now()); jobs?.observe?.(ev); }
162
+ if (action?.type === 'turn.begin') moments.turnStarted();
163
+ const m = moments.observe(ev);
164
+ if (m?.companion) keyMoment();
165
+ },
166
+ /** True while an attached companion shows key moments (mode on): it owns them, so the Q dragon stays quiet. */
167
+ ownsMoments() { return Boolean(view && companionMode(settings(), env) === 'on'); },
168
+ /** The Q dragon's mood for a state change, or null when the companion owns the moment (one dragon per moment). */
169
+ moodFor(prev, action, next) { return this.ownsMoments() ? null : moodForEvent(prev, action, next); },
170
+ plan: () => plan,
171
+ /** Why the art cannot show (mode on/greeting but art missing or unloadable), or null. Independent of the attached agent. */
172
+ artProblem() {
173
+ if (companionMode(settings(), env) === 'off') return null;
174
+ if (/dragon art/.test(plan.why || '')) return plan.why;
175
+ const p = companionPlan({ agent: 'lucid', settings: settings(), env, isTTY: true, exists, bundled: VENDORED_DRAGONLAB });
176
+ return !p.enabled && /dragon art/.test(p.why || '') ? p.why : null;
177
+ },
178
+ /** One line, once per session, when a dragon agent attaches but the art cannot show. Otherwise null. */
179
+ takeArtNotice() {
180
+ if (noticed || !agent || !dragonForAgent(agent) || plan.enabled || !/dragon art/.test(plan.why || '')) return null;
181
+ noticed = true;
182
+ return `Dragon companion is ${plan.mode} but its art could not be loaded, so no dragon will show (${plan.why}). /dragon companion status`;
183
+ },
184
+ mode: () => plan.mode,
185
+ /** -> null | { kind:'art', frame, caption, name } | { kind:'label', text } */
186
+ snapshot(t, { cols, rows }) {
187
+ const out = this.snapshotNow(t, { cols, rows });
188
+ if (out && unpainted) { unpainted = false; shownAt = now(); arm(); } // first picture on screen: the dwell and the keypress grace count from here, not from when the art finished loading
189
+ return out;
190
+ },
191
+ snapshotNow(t, { cols, rows }) {
192
+ if (summoned && view && !live()) { restore(); return null; } // a summoned dragon's dwell ended: back to the attached one (never while its art is still loading: a frame drawn then cancelled the summon)
193
+ if (!view || !state || !live()) return null;
194
+ const big = cols >= ART_MIN_COLS && rows >= ART_MIN_ROWS;
195
+ let v;
196
+ const mood = job?.mood === 'concerned' && !state.approvals.length ? 'concerned' : null;
197
+ try { v = view.view(state, t, { cols, rows: big ? rows : 1, mood }); } catch { return null; }
198
+ if (v.hidden) return null;
199
+ // One caption per trigger: "Attached." only on an attach; an invoke says who is here; an approval always says so.
200
+ let caption = v.caption;
201
+ const summonedCaption = summonCaption;
202
+ if (state.approvals.length) caption = 'Waiting for your approval.';
203
+ else if (job?.caption && !state.issue) caption = job.caption; // the dragon's job: a real read (jobs.js); an approval or a recorded failure still comes first
204
+ else if (state.phase === 'connecting') caption = trigger === 'attach' ? 'Attached.' : trigger === 'invoke' ? `${view.name} is here.` : v.caption;
205
+ if (summonedCaption && now() < showUntil) caption = summonedCaption; // a domain moment states its own fact
206
+ if (!big) return { kind: 'label', text: `${view.name}: ${caption}` };
207
+ if (!v.frame) { waitingFrame = true; return null; } // the first frame is still being drawn off the main thread: draw nothing rather than a gap
208
+ return { kind: 'art', frame: v.frame, caption, name: view.name };
209
+ },
210
+ };
211
+ }
212
+
213
+ /** "/dragon companion <arg>" -> { text, tone, save?, refresh? }. Pure. */
214
+ export function companionCommand(arg, { settings = {}, plan = null, artProblem = null } = {}) {
215
+ // `invoke: true` = the host also plays the attached agent's dragon for a dwell (a status check is a call for the dragon too).
216
+ const w = String(arg || '').trim().toLowerCase();
217
+ const cur = normaliseMode(settings.dragonCompanion);
218
+ if (!w || w === 'status') {
219
+ const mode = cur || plan?.mode || 'on';
220
+ const now = plan?.enabled ? `showing ${plan.id}` : `not showing (${plan?.why || 'no agent attached'})`;
221
+ if (artProblem && mode !== 'off') return { text: `Dragon companion: ${mode}, but the art can't be loaded so no dragon will show (${artProblem}). /dragon companion on|greeting|off.`, tone: 'warn' };
222
+ return { text: `Dragon companion: ${mode}, ${now}. /dragon companion on|greeting|off. Only Lucid, Hyperion, Seer, Sentinel, Drago and Cipher have dragons.`, invoke: true };
223
+ }
224
+ const m = normaliseMode(w);
225
+ if (!m) return { text: `Usage: /dragon companion ${COMPANION_MODES.join('|')}|status`, tone: 'warn' };
226
+ const what = { on: 'appears on attach and at key moments such as a new approval, a failure or a long task finishing, then leaves after a few seconds', greeting: 'appears on attach only, then leaves', off: 'never appears on its own' }[m];
227
+ if (artProblem && m !== 'off') return { text: `Dragon companion: ${m} saved, but the art can't be loaded so no dragon will show (${artProblem}).`, tone: 'warn', save: m, refresh: true };
228
+ return { text: `Dragon companion: ${m} (${what}). /dragon always calls it. Saved to this profile.`, tone: 'ok', save: m, refresh: true };
229
+ }
230
+
231
+ /** The live block. Re-renders on the shared clock; draws nothing when there is no companion. */
232
+ export function DragonCompanionLive({ ctl, cols, rows }) {
233
+ const t = useFrame();
234
+ const [, bump] = React.useState(0);
235
+ React.useEffect(() => ctl.subscribe(() => bump(x => x + 1)), [ctl]);
236
+ const snap = ctl.snapshot(t, { cols, rows });
237
+ if (!snap) return null;
238
+ if (dragonActive()) return null; // an explicit /dragon <clip> owns the canvas: the companion steps aside (both stacked overflow a 34-row terminal and Ink wipes the screen, 2026-10-25)
239
+ if (snap.kind === 'label') return h(Box, { paddingLeft: 2 }, h(Text, { wrap: 'truncate' }, snap.text));
240
+ return h(Box, { flexDirection: 'row', marginTop: 1 },
241
+ h(Box, { flexDirection: 'column', flexShrink: 0 }, ...snap.frame.map((r, k) => h(Text, { key: k, wrap: 'truncate' }, ...cellNodes(r, `dc${k}-`)))),
242
+ h(Box, { flexDirection: 'column', paddingLeft: 2 },
243
+ h(Text, { bold: true }, snap.name.toUpperCase()),
244
+ h(Text, { wrap: 'wrap' }, snap.caption)));
245
+ }
246
+
247
+ /**
248
+ * /detach back to Q: with dragons on (mode on or greeting) the Q dragon gets one greeting, the way an attach shows the agent's.
249
+ * -> null | { clip:'pick' } | { note } (the window is too small: say so in one line). Pure.
250
+ */
251
+ export function detachGreeting({ settings = {}, env = process.env, rows = null, chromeH = 10, hadAgent = true } = {}) {
252
+ if (!hadAgent || companionMode(settings, env) === 'off' || motionPolicy(env).off) return null;
253
+ const room = clipRoom('dragon', rows, chromeH);
254
+ return room.ok ? { clip: 'pick' } : { note: room.note };
255
+ }
@@ -54,6 +54,7 @@ export function parseDragonArg(restArg) {
54
54
  case 'hero': return rest.length ? null : { mode: 'hero' };
55
55
  // `/dragon explain <thing>` is a question about that thing (owner 2026-10-15: it used to print usage).
56
56
  case 'explain': return rest.length ? { mode: 'ask', question: rest.join(' ').trim() } : { mode: 'explain' };
57
+ case 'companion': return { mode: 'companion', arg: rest.join(' ') };
57
58
  case 'sleep': return rest.length ? null : { mode: 'sleep' };
58
59
  case 'fly': return rest.length ? null : { mode: 'fly' };
59
60
  case 'ask': return { mode: 'ask', question: rest.join(' ').trim() };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ONE moment engine for both dragon systems. A "moment" is a stream fact worth a dragon: an approval is waiting, a turn or the
3
+ * stream failed, a long turn finished, a turn finished. The classification (and its dedupe) lives here once; the table says what
4
+ * each moment calls: `mood` is the Q dragon's cinematic clip (qmotion playDragon), `companion` is whether an attached companion
5
+ * dragon shows itself. The controller (dragonCompanion.js) runs the engine on every stream fact and owns the routing: while a
6
+ * companion owns the moments the Q dragon stays quiet, so a moment is never two dragons.
7
+ */
8
+ export const MOMENTS = Object.freeze({
9
+ approval: Object.freeze({ mood: 'waiting', companion: true }),
10
+ failure: Object.freeze({ mood: 'failure', companion: true }),
11
+ long: Object.freeze({ mood: 'success', companion: true }),
12
+ success: Object.freeze({ mood: 'success', companion: false }),
13
+ });
14
+ /** A turn at least this long counts as a "long task" when it finishes. */
15
+ export const LONG_TASK_MS = 30000;
16
+
17
+ export function createMomentEngine({ now = Date.now } = {}) {
18
+ const seen = new Set();
19
+ let turnStart = null;
20
+ const moment = (kind) => ({ kind, ...MOMENTS[kind] });
21
+ return {
22
+ turnStarted() { turnStart = now(); },
23
+ reset() { seen.clear(); turnStart = null; },
24
+ /** A reducer event (the companion's vocabulary) -> { kind, mood, companion } | null. */
25
+ observe(ev) {
26
+ if (!ev) return null;
27
+ if (ev.type === 'approval.requested') {
28
+ // The same approval arrives twice (the stream event, then the inbox poll) under different ids: one moment for it.
29
+ const keys = [ev.call_id, ev.inbox_item_id, ev.approval_id].filter(Boolean).map(String);
30
+ if (keys.some(k => seen.has(k))) return null;
31
+ keys.forEach(k => seen.add(k));
32
+ return moment('approval');
33
+ }
34
+ if (ev.type === 'error') return moment('failure');
35
+ if (ev.type === 'turn.finished') {
36
+ const started = turnStart; turnStart = null;
37
+ if (ev.status === 'failed' || ev.status === 'unconfirmed') return moment('failure');
38
+ if (ev.status !== 'completed') return null;
39
+ return moment(started != null && now() - started >= LONG_TASK_MS ? 'long' : 'success');
40
+ }
41
+ return null;
42
+ },
43
+ };
44
+ }
45
+
46
+ // ---- domain moments: a fact from the estate summons the matching dragon (companion mode `on` only) ------------------------
47
+ /** A dragon summoned by a domain moment cannot be summoned again for this long. */
48
+ export const SUMMON_COOLDOWN_MS = 60000;
49
+
50
+ /**
51
+ * An objective or loop roster row -> { dragon, caption } when it has finished or failed, else null. The caption states only
52
+ * what the row says. The roster row carries no agent, so the dragon is Seer (the plan dragon); an agent field would pick its own.
53
+ */
54
+ export function objectiveMoment(row) {
55
+ if (!row || (row.kind !== 'objective' && row.kind !== 'loop') || (row.state !== 'done' && row.state !== 'failed')) return null;
56
+ const noun = row.kind === 'loop' ? 'Loop' : 'Objective', name = row.name || row.id;
57
+ const caption = row.state === 'done' ? `${noun} finished: ${name}.` : `${noun} failed: ${name}${row.activity ? ` (${row.activity})` : ''}.`;
58
+ return { dragon: 'seer', caption };
59
+ }
60
+
61
+ /**
62
+ * Watches roster rows and reports each objective/loop that was seen unfinished and is now done or failed, once. The first
63
+ * observation is a baseline (rows already finished at startup never fire); a reported key is retired for good.
64
+ */
65
+ export function createRosterWatch() {
66
+ const active = new Set(), retired = new Set();
67
+ let primed = false;
68
+ return {
69
+ observe(rows = []) {
70
+ const out = [];
71
+ for (const r of rows) {
72
+ if (!r || (r.kind !== 'objective' && r.kind !== 'loop') || retired.has(r.key)) continue;
73
+ if (r.state === 'done' || r.state === 'failed') {
74
+ if (active.delete(r.key) && primed) { const m = objectiveMoment(r); if (m) out.push(m); }
75
+ retired.add(r.key);
76
+ } else active.add(r.key);
77
+ }
78
+ primed = true;
79
+ return out;
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The reads behind the dragon jobs (lib/dragon/jobs.js). Each is the same call a `quox` verb already makes, read-only,
3
+ * bounded, retries off. The CLI client throws on a non-2xx, so a throw is the failure and a returned body is checked for the
4
+ * error shapes some routes use with a 200 (success:false). Nothing here turns an error into a count.
5
+ */
6
+ import { resolveEndpoint } from '../api/endpoints.js';
7
+ import { healthOf } from './objects/fleet.js';
8
+
9
+ const TIMEOUT = 5000;
10
+ const STATE = { 1: 'up', 0: 'down', 2: 'pending', 3: 'maintenance' };
11
+ const kumaState = (m) => (m && typeof m.state === 'string' ? m.state : m && Object.hasOwn(STATE, m.status) ? STATE[m.status] : 'unknown'); // same mapping as `quox kuma`
12
+ const whyOf = (e) => (e?.status ? `HTTP ${e.status}` : /timed out/i.test(e?.message || '') ? 'timed out' : e?.message || 'failed');
13
+
14
+ export function createDragonReads({ client }) {
15
+ const get = (name, params, query) => { const ep = resolveEndpoint(name, params); return client.get(ep.path, { service: ep.service, query, retries: 0, timeout: TIMEOUT }); };
16
+ return {
17
+ /** WARD inclusion receipt of one envelope (POST /ward/verify-receipt, the route /proof uses). -> { ok, result } | { ok:false, error } */
18
+ async verifyReceipt(envelopeId) {
19
+ try {
20
+ const d = await client.post('/ward/verify-receipt', { service: 'collector', body: { source_id: envelopeId, source_kind: 'AEE' }, retries: 0, timeout: TIMEOUT });
21
+ return { ok: true, result: d?.result || d };
22
+ } catch (e) { return { ok: false, error: whyOf(e) }; }
23
+ },
24
+ /** `quox kuma` live read -> { up, down, total, avgPing, downNames, ageSeconds } */
25
+ async routes() {
26
+ const d = await get('mon.kuma.live.get');
27
+ if (d?.success === false) throw new Error(`kuma returned an error body: ${d.error || 'no detail'}`);
28
+ if (!Array.isArray(d?.monitors)) throw new Error('kuma answered with no monitors list');
29
+ const ms = d.monitors;
30
+ const up = ms.filter(m => kumaState(m) === 'up'), down = ms.filter(m => kumaState(m) === 'down');
31
+ const pings = up.filter(m => m.ping != null).map(m => Number(m.ping)).filter(Number.isFinite); // a null ping is no measurement, not 0 ms
32
+ return { up: up.length, down: down.length, total: ms.length, avgPing: pings.length ? pings.reduce((a, b) => a + b, 0) / pings.length : null, downNames: down.map(m => m.name || `monitor ${m.id}`), ageSeconds: Number.isFinite(d.ageSeconds) ? d.ageSeconds : null };
33
+ },
34
+ /** `quox plan focus` -> { id, done, total } of the first focus stream; null when nothing is in focus or there is no roadmap. */
35
+ async plan() {
36
+ const rms = (await get('quoxplan.roadmaps.list'))?.roadmaps;
37
+ if (!Array.isArray(rms)) throw new Error('quoxplan answered with no roadmaps list');
38
+ if (!rms.length) return null;
39
+ const streams = (await get('quoxplan.roadmap.streams.list', { roadmapId: rms[0].id }, { lens: 'focus' }))?.streams;
40
+ if (!Array.isArray(streams)) throw new Error('quoxplan answered with no streams list');
41
+ if (!streams.length) return null;
42
+ // The first few focus streams, in the server's order: the first one that has phases gives "x of y". A stream with none (a meta
43
+ // stream) or whose phases could not be read is only named, never given a made-up count.
44
+ const top = streams.slice(0, 3);
45
+ const details = await Promise.allSettled(top.map(t => get('quoxplan.stream.get', { id: t.id })));
46
+ const rows = top.map((t, i) => {
47
+ const phases = details[i].status === 'fulfilled' && Array.isArray(details[i].value?.phases) ? details[i].value.phases : null;
48
+ return phases ? { id: t.id, done: phases.filter(p => p.done_at || /^(done|complete)/i.test(String(p.status || ''))).length, total: phases.length } : { id: t.id, done: null, total: null };
49
+ });
50
+ return rows.find(r => r.total > 0) || rows[0];
51
+ },
52
+ /** `quox fleet hosts` rows bucketed by health. */
53
+ async fleet() {
54
+ const d = await get('v1.fleet.hosts.list');
55
+ const hosts = Array.isArray(d) ? d : d?.hosts;
56
+ if (!Array.isArray(hosts)) throw new Error('unexpected fleet response');
57
+ const c = { total: hosts.length, healthy: 0, degraded: 0, unhealthy: 0, offline: 0, unknown: 0 };
58
+ for (const h of hosts) c[healthOf(h)]++;
59
+ return c;
60
+ },
61
+ /** The org's open security findings (engagement store). */
62
+ async findings() {
63
+ const d = await get('engagement.findings.open', {});
64
+ if (!Array.isArray(d?.findings)) throw new Error('unexpected findings response');
65
+ return { open: d.findings.length, severe: d.findings.filter(f => /^(critical|high)$/i.test(String(f.severity || ''))).length };
66
+ },
67
+ };
68
+ }
@@ -32,10 +32,10 @@ export function DragonTalk({ cols, label, talking, since, still = false }) {
32
32
  }
33
33
 
34
34
  /** /dragon-fly: one pass of the dragon in flight across the live region (qdragonFly.js). `still`: a single frame, no motion. */
35
- export function FlyLive({ cols, rows, startedAt, still = false }) {
35
+ export function FlyLive({ cols, rows, startedAt, still = false, chromeH = null }) {
36
36
  useFrame();
37
37
  const c = flyCols(cols);
38
- const cells = flyFrame(Date.now() - startedAt, c, { still, mono: mono(), theme: isLight() ? 'light' : 'dark', termRows: rows });
38
+ const cells = flyFrame(Date.now() - startedAt, c, { still, mono: mono(), theme: isLight() ? 'light' : 'dark', termRows: rows, chromeH });
39
39
  return h(Box, { flexDirection: 'column', marginTop: 1 },
40
40
  ...cells.map((row, k) => h(Text, { key: k, wrap: 'truncate' }, ...cellNodes(row, `f${k}-`))));
41
41
  }
@@ -11,8 +11,10 @@
11
11
  * turn : null | { id, status: running|interrupting, startedAt, stage, activity }
12
12
  */
13
13
 
14
+ import { approvalSentence } from './approvalSentence.js';
14
15
  import { dividerStep } from './dividerAocl.js';
15
16
  import { approvalFacts } from './approvalFacts.js';
17
+ import { planLines } from './planSteps.js';
16
18
  import { isConsentError, consentErrorText } from './consent.js';
17
19
  import { splitSettled } from './markdownSettle.js'; // not markdown.js: that loads React + Ink (QUOXON-LATENCY)
18
20
  import { nextActivity, actionActivity } from './activity.js';
@@ -125,7 +127,8 @@ export function cardFromInboxItem(it, { since = 0, userId = null, conversationId
125
127
  return {
126
128
  callId: `inbox:${it.id}`, inboxItemId: it.id, approvalId,
127
129
  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,
130
+ url: it.dashboard_url || null, shownAt: now, undescribable: !d.tool && !d.target && !it._plan,
131
+ plan: it._plan ? { lines: planLines(it._plan.steps), loaded: it._plan.loaded !== false, orgId: it._plan.orgId || null, orgName: it._plan.orgName || null } : null, // the plan's steps + the item's own org
129
132
  facts: approvalFacts(it), // APPROVAL-WWWH: the exact command, hosts, chain, reason, time and read-only class
130
133
  // only an item that names THIS conversation is ours; anything else came from another session and must not take the keyboard
131
134
  foreign: !here,
@@ -161,6 +164,18 @@ export function groupApprovals(items, { conversationId = null, cardIds = [] } =
161
164
  return Object.entries(g).filter(([, v]) => v.length).map(([group, v]) => ({ group, items: v }));
162
165
  }
163
166
 
167
+ /**
168
+ * QLOOP-LIVE: which approval card the keyboard goes to first. Cards about a loop THIS window is watching lead, then the rest of
169
+ * this conversation, then other sessions' items (stable inside each rank). The loop id appears in the card's target / description.
170
+ */
171
+ export function orderApprovals(cards, mineIds = []) {
172
+ const ids = (mineIds || []).filter(Boolean).map(String);
173
+ const rank = (c) => (ids.length && ids.some((id) => [c.target, c.what, c.facts?.where].some((t) => t != null && String(t).includes(id))) ? 0 : c.foreign ? 2 : 1);
174
+ return cards.map((c, i) => ({ c, i, r: rank(c) })).sort((a, b) => a.r - b.r || a.i - b.i).map((x) => x.c);
175
+ }
176
+ /** Cards raised by other sessions (the "other sessions: N" tail). */
177
+ export const otherSessionsCount = (cards) => (cards || []).filter((c) => c.foreign).length;
178
+
164
179
  /** Privileged/elevated grants (hat switches and anything the server marks privileged or elevated) need an explicit confirm. */
165
180
  export function isPrivileged({ tool = '', risk = null, flag = false, title = '' } = {}) {
166
181
  if (flag === true) return true;
@@ -345,6 +360,8 @@ function reduceInner(s, a) {
345
360
  return { ...s, history: [...s.history, { id: nid(), kind: 'bang', text: a.text }] };
346
361
  case 'slotmsg': // QUOXON-MESH: a slot chat line (slotChat.js slotLine), incoming or sent
347
362
  return { ...s, history: [...s.history, { id: nid(), kind: 'slotmsg', line: a.line }] };
363
+ case 'loopcard': // QLOOP-LIVE: a live loop card; the card object is mutated in place by loopCards.js
364
+ return { ...s, history: [...s.history, { id: nid(), kind: 'loopcard', card: a.card }] };
348
365
  case 'tip': // a "did you know" tip (tips.js); text + the command to try
349
366
  return { ...s, history: [...s.history, { id: nid(), kind: 'tip', text: a.text, cmd: a.cmd }] };
350
367
  case 'bcard': // QUOXON-BROADCAST: the sender's framed delivery card (slash/broadcast.js broadcastCard), pure data
@@ -443,8 +460,8 @@ function reduceInner(s, a) {
443
460
  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
461
  if (a.batch) return rest; // /approve N prints its own one-line summary
445
462
  if (a.quiet && a.action === 'approve') return rest;
446
- if (a.action !== 'approve') return note(rest, `denied: ${a.tool || 'approval'}${a.target ? ` on ${a.target}` : ''}`, 'warn');
447
- if (a.foreign) return note(rest, `✔ Approved ${a.tool || 'approval'}${a.target ? ` on ${a.target}` : ''}; the plugin or agent will act on it (its outcome is not tracked here)`, 'ok');
463
+ if (a.action !== 'approve') return note(rest, approvalSentence(a, false), 'warn');
464
+ if (a.foreign) return note(rest, approvalSentence(a, true), 'ok');
448
465
  if (a.tool === 'hat.switch') { // applied server-side by the inbox approve; nothing to wait for
449
466
  const to = String(a.target || '').split('\u2192').pop().replace(/\(privileged\)/, '').trim() || 'the new hat';
450
467
  return note(rest, `Hat switched to ${to}. Ask again and I'll carry on.`, 'ok');
@@ -485,6 +502,9 @@ export function reduce(s, a) {
485
502
  const dv = dividerStep(s.divider ?? null, a, a.now || 0);
486
503
  if (dv !== (n.divider ?? null)) n = { ...n, divider: dv };
487
504
  }
505
+ // A turn that ends WITHOUT a turn.finished event (stopped.local, turn.deferred, a /clear cancel) must not leave the
506
+ // strip open: endedAt stayed null, dividerLive stayed true, and the dot train + scrambled label looped on a dead turn.
507
+ if (n.divider && n.divider.endedAt == null && !n.turn) n = { ...n, divider: null };
488
508
  // The honest activity word: only real events move it (see activity.js).
489
509
  if (n.turn) {
490
510
  const act = a.type === 'event' ? nextActivity(s.turn?.act, a.ev, a.now || 0, { hasText: Boolean(n.turn.chars) })
@@ -558,6 +578,7 @@ function reduceEvent(s, ev, now) {
558
578
  let withTool = upsertTool(s, ev, {
559
579
  status, resultSummary: toolResultText(ev.result_summary, s.verbose), rawSummary: ev.result_summary, durationMs: ev.duration_ms,
560
580
  envelopeId: ev.envelope_id, receipt: ev.receipt || null,
581
+ ...(ev.card && typeof ev.card === 'object' && Array.isArray(ev.card.rows) ? { card: ev.card } : {}), // HITL-INBOX-BAR: a list answer's rows (toolCards.js)
561
582
  ...(Number.isFinite(ev.tools_used) ? { toolsUsed: ev.tools_used } : {})
562
583
  });
563
584
  if (first && withTool.turn) withTool = { ...withTool, turn: { ...withTool.turn, finishedTools: withTool.turn.finishedTools + 1 } };