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
@@ -0,0 +1,456 @@
1
+ /**
2
+ * QLOOP-LIVE: the live loop card. Pure: events in, state out, lines out. No React, no network, no clock of its own,
3
+ * so every state of the card can be rendered in a test from a recorded stream.
4
+ *
5
+ * The event contract is pinned in ~/quoxon-work/qloop-live-design.md section 1:
6
+ * loop.started / loop.turn.started / loop.turn.output / loop.turn.tool / loop.turn.completed /
7
+ * loop.observe / loop.ward / loop.approval / loop.finished (each with event_seq, at, objective_id)
8
+ *
9
+ * The card is one live region per loop. It reads, top to bottom:
10
+ * ∞ LOOP <title> ◐ turn 3/10 · 1m12s
11
+ * ▕✔✔◐·······▏ receipts 2/2 witnessed ✔ host core-local · scope Read,Write
12
+ * │ <spool: last 3 lines, wrapped, Enter expands>
13
+ * ⋯ milestone: found 214 files
14
+ * Turn strip glyphs: ✔ ok · ⚠ denied / waiting for you · ✖ error · ◐ running · · pending.
15
+ * No grey text (house rule): every run uses a readable role. Nothing the user needs is cut: long text wraps.
16
+ */
17
+ import { graphemes } from './textBuffer.js';
18
+ import { cellWidth } from './cells.js';
19
+
20
+ /** Below this width the card is one line (the full title is printed once in chat when the loop starts). */
21
+ export const NARROW_COLS = 60;
22
+ export const SPOOL_CAP = 8192;
23
+ export const SPOOL_LINES = 3;
24
+ export const SPOOL_LINES_EXPANDED = 16;
25
+ export const STRIP_MAX = 24;
26
+ export const REDRAW_MIN_MS = 125; // <= 8 redraws per second
27
+
28
+ export const TERMINAL = new Set(['completed', 'completed_with_errors', 'failed', 'halted', 'interrupted']);
29
+ const FINAL = {
30
+ completed: { glyph: '✔', role: 'ok', word: 'completed' },
31
+ completed_with_errors: { glyph: '⚠', role: 'warn', word: 'completed with errors' },
32
+ failed: { glyph: '✖', role: 'error', word: 'failed' },
33
+ halted: { glyph: '⚠', role: 'warn', word: 'halted' },
34
+ interrupted: { glyph: '⚠', role: 'warn', word: 'interrupted' },
35
+ };
36
+ const WITNESSED = new Set(['witnessed', 'signed', 'verified', 'ok']);
37
+ const BAD_WARD = new Set(['failed', 'invalid', 'rejected', 'tampered', 'error']);
38
+ const SPIN = ['◐', '◓', '◑', '◒'];
39
+
40
+ /* ---- text helpers --------------------------------------------------------------------------- */
41
+
42
+ const oneLine = (s) => String(s ?? '').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '').replace(/\r/g, '');
43
+ const width = (s) => cellWidth(s);
44
+
45
+ /** Wrap at spaces; a token longer than the line is broken by cells, never dropped. Keeps blank lines. */
46
+ export function wrapText(text, w) {
47
+ const room = Math.max(4, w);
48
+ const out = [];
49
+ for (const raw of oneLine(text).split('\n')) {
50
+ if (!raw.trim()) { out.push(''); continue; }
51
+ let line = '';
52
+ for (const word of raw.split(/ +/)) {
53
+ if (!word) continue;
54
+ let rest = word;
55
+ while (width(rest) > room) { // hard-break an over-long token
56
+ if (line) { out.push(line); line = ''; }
57
+ let part = '', used = 0;
58
+ for (const g of graphemes(rest)) { const gw = width(g.text); if (used + gw > room) break; part += g.text; used += gw; }
59
+ if (!part) part = graphemes(rest)[0].text;
60
+ out.push(part); rest = rest.slice(part.length);
61
+ }
62
+ if (!line) line = rest;
63
+ else if (width(line) + 1 + width(rest) <= room) line += ' ' + rest;
64
+ else { out.push(line); line = rest; }
65
+ }
66
+ out.push(line);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ export function fmtElapsed(ms) {
72
+ const s = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
73
+ if (s < 60) return `${s}s`;
74
+ const m = Math.floor(s / 60);
75
+ if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`;
76
+ return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`;
77
+ }
78
+
79
+ /**
80
+ * A server stop reason for a human: the raw component prefix the spawner puts first (`fleetLoopSpawner: `) is dropped, the
81
+ * sentence is kept whole. Never shortened: the card wraps it.
82
+ */
83
+ export function humanReason(r) {
84
+ return oneLine(r).replace(/^\s*[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*:\s+/, '').trim();
85
+ }
86
+ const STATE_ALIAS = { cancelled: 'halted', aborted: 'interrupted' };
87
+ const isStop = (state) => state === 'failed' || state === 'interrupted';
88
+
89
+ /* ---- state ---------------------------------------------------------------------------------- */
90
+
91
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : null);
92
+ const t0 = (v) => { const t = Date.parse(v); return Number.isFinite(t) ? t : null; };
93
+
94
+ export function initLoop({ id, title = '', agent = null } = {}) {
95
+ return {
96
+ id, title: oneLine(title), agent, state: 'running', done: false,
97
+ turn: 0, maxTurns: null, host: null, backend: null, scope: [],
98
+ startedAt: null, finishedAt: null, reason: null, turnsTotal: null,
99
+ turns: {}, // n -> { status: run|ok|warn|err, summary, errors, denied, ms }; n is the stream's `turn`
100
+ // A runner-driven loop's stream `turn` counts MODEL MESSAGES (several per runner turn: live 2026-10-26 it read "turn 9/5"). The loop's
101
+ // own turn is the runner's iteration: from the event when the server sends it, else the snapshot (loopCards refreshes it), else the
102
+ // runner's own "(turn N of M)" in an observe line. null = not a runner loop (or not known yet): the stream's turn is then the turn.
103
+ iteration: null,
104
+ receipts: {}, // envelope_id -> witnessed|pending|failed
105
+ finalReceipts: null, // { total, witnessed } from loop.finished or the snapshot: the server's own count
106
+ spool: '', spoolTurn: 0,
107
+ milestone: null, approval: null, stall: null,
108
+ conn: 'live', connAttempt: 0, connNote: null,
109
+ lastSeq: 0, lastEventAt: null,
110
+ observed: [], // lines appended to chat, kept for tests and /loop peek
111
+ };
112
+ }
113
+
114
+ const wardOf = (ev) => ev.status ?? (typeof ev.ward === 'string' ? ev.ward : ev.ward?.status) ?? ev.ward_status;
115
+ /** A receipt's status never goes backwards: a late `pending` (the turn's own completion arriving after the ward event) must not un-witness it. */
116
+ const setReceipt = (s, id, cls) => { if (id && !(s.receipts[id] === 'witnessed' && cls === 'pending')) s.receipts[id] = cls; };
117
+ const wardClass = (status) => { const s = String(status || '').toLowerCase(); return WITNESSED.has(s) ? 'witnessed' : BAD_WARD.has(s) ? 'failed' : 'pending'; };
118
+
119
+ /** A loop that stopped (failed / interrupted) failed at the turn it was on; one that was halted was cut off. The rest did not run. */
120
+ function markStopped(s) {
121
+ const t = s.turns[s.turn]; if (!t) return;
122
+ if (isStop(s.state) && t.status !== 'err') s.turns[s.turn] = { ...t, status: 'err' };
123
+ else if (s.state === 'halted' && t.status === 'run') s.turns[s.turn] = { ...t, status: 'warn' };
124
+ else if (t.status === 'run') s.turns[s.turn] = { ...t, status: s.state === 'completed' ? 'ok' : 'warn' };
125
+ }
126
+
127
+ /** The snapshot (GET /api/v1/loops/:id) -> state. Tolerant of field spellings; never invents a field it was not given. */
128
+ export function applySnapshot(st, snap) {
129
+ if (!snap || typeof snap !== 'object') return st;
130
+ const s = { ...st, turns: { ...st.turns }, receipts: { ...st.receipts } };
131
+ const loop = snap.loop && typeof snap.loop === 'object' ? { ...snap, ...snap.loop } : snap;
132
+ if (loop.title && !s.title) s.title = oneLine(loop.title);
133
+ const state0 = loop.state || loop.status, state = STATE_ALIAS[state0] || state0;
134
+ if (state && TERMINAL.has(state)) { s.state = state; s.done = true; s.reason = loop.reason ? humanReason(loop.reason) : s.reason; }
135
+ const it = num(loop.iteration); if (it != null) s.iteration = Math.max(s.iteration ?? 0, it);
136
+ const turn = it != null ? null : num(loop.turn ?? loop.current_turn); if (turn != null) s.turn = Math.max(s.turn, turn);
137
+ const max = num(loop.max_turns ?? loop.max); if (max != null) s.maxTurns = max;
138
+ if (loop.host) s.host = String(loop.host);
139
+ if (loop.backend) s.backend = String(loop.backend);
140
+ if (Array.isArray(loop.scope)) s.scope = loop.scope.map(String);
141
+ const started = t0(loop.started_at ?? loop.at); if (started != null) s.startedAt = started;
142
+ const fin = t0(loop.finished_at); if (fin != null) s.finishedAt = fin;
143
+ const runnerLoop = loop.iteration != null || loop.until != null; // its last_turns are keyed by the message counter: not turns
144
+ for (const t of runnerLoop ? [] : (Array.isArray(loop.turns) ? loop.turns : (Array.isArray(loop.last_turns) ? loop.last_turns : []))) {
145
+ const n = num(t.turn ?? t.n); if (n == null || s.turns[n]) continue;
146
+ const ts = String(t.status || t.state || '').toLowerCase();
147
+ const tools = Array.isArray(t.tools) ? t.tools : [];
148
+ const byTools = tools.some((x) => x && x.denied) ? 'warn' : tools.some((x) => x && x.ok === false) ? 'err' : 'ok';
149
+ s.turns[n] = { status: /err|fail/.test(ts) ? 'err' : /deny|warn|approval/.test(ts) ? 'warn' : /run|start/.test(ts) ? 'run' : byTools, summary: oneLine(t.summary || ''), errors: 0, denied: null };
150
+ const w = t.ward?.status ?? t.ward_status ?? (typeof t.ward === 'string' ? t.ward : null); if (w && t.envelope_id) setReceipt(s, t.envelope_id, wardClass(w));
151
+ }
152
+ // The snapshot's receipts: { total, witnessed, items:[{ envelope_id, status }] }. Items fill the map; the totals are the server's own count.
153
+ if (loop.receipts && typeof loop.receipts === 'object') {
154
+ for (const it of Array.isArray(loop.receipts.items) ? loop.receipts.items : []) if (it?.envelope_id) setReceipt(s, it.envelope_id, wardClass(it.status));
155
+ const total = num(loop.receipts.total), wit = num(loop.receipts.witnessed);
156
+ if (total != null && wit != null) s.finalReceipts = { total, witnessed: wit };
157
+ }
158
+ // NOT done here: the snapshot's last_event_seq is the NEWEST event; adopting it would drop the replayed events the stream is about to send.
159
+ if (s.done) markStopped(s);
160
+ return s;
161
+ }
162
+
163
+ /** A snapshot read only for the runner's progress (its own turn and budget): never changes the loop's state or the replay position. */
164
+ export function applyProgress(st, snap) {
165
+ const loop = snap && typeof snap === 'object' ? (snap.loop && typeof snap.loop === 'object' ? { ...snap, ...snap.loop } : snap) : null;
166
+ if (!loop) return st;
167
+ const it = num(loop.iteration), max = num(loop.max_turns ?? loop.max);
168
+ if ((it == null || it <= (st.iteration ?? 0)) && (max == null || max === st.maxTurns)) return st;
169
+ return { ...st, iteration: it != null ? Math.max(st.iteration ?? 0, it) : st.iteration, maxTurns: max ?? st.maxTurns };
170
+ }
171
+
172
+ /**
173
+ * One event -> new state. Pure. An event with event_seq <= lastSeq has been seen (a resume replays the boundary): ignored.
174
+ */
175
+ export function reduceLoop(st, ev) {
176
+ if (!ev || typeof ev.type !== 'string') return st;
177
+ const seq = num(ev.event_seq);
178
+ if (seq != null && seq <= st.lastSeq) return st;
179
+ const s = { ...st, turns: { ...st.turns }, receipts: { ...st.receipts }, observed: st.observed };
180
+ if (seq != null) s.lastSeq = seq;
181
+ const at = t0(ev.at); if (at != null) s.lastEventAt = at;
182
+ s.conn = 'live'; s.connAttempt = 0; s.connNote = null;
183
+ const turn = num(ev.turn);
184
+ const iter = num(ev.iteration ?? ev.loop_turn); if (iter != null) s.iteration = Math.max(s.iteration ?? 0, iter);
185
+ switch (ev.type) {
186
+ case 'loop.started':
187
+ s.startedAt = at ?? s.startedAt; s.host = ev.host ?? s.host; s.backend = ev.backend ?? s.backend;
188
+ if (Array.isArray(ev.scope)) s.scope = ev.scope.map(String);
189
+ s.maxTurns = num(ev.max_turns) ?? s.maxTurns; break;
190
+ case 'loop.turn.started':
191
+ if (turn != null) { s.turn = turn; s.turns[turn] = { status: 'run', summary: '', errors: 0, denied: null, startedAt: at }; s.spool = ''; s.spoolTurn = turn; }
192
+ s.stall = null; s.approval = null; break;
193
+ case 'loop.turn.output': {
194
+ if (turn != null && turn !== s.spoolTurn) { s.spool = ''; s.spoolTurn = turn; }
195
+ const joined = s.spool + oneLine(ev.chunk).replace(/\u001b\[[0-9;]*[A-Za-z]/g, '');
196
+ s.spool = joined.length > SPOOL_CAP ? joined.slice(-SPOOL_CAP) : joined;
197
+ s.stall = null; s.approval = null; break; // output means it is moving again: no longer waiting for you
198
+ }
199
+ case 'loop.turn.tool': {
200
+ const t = turn != null && s.turns[turn] ? { ...s.turns[turn] } : null;
201
+ s.stall = null; s.approval = null;
202
+ if (t) { if (ev.denied) t.denied = String(ev.denied); else if (ev.ok === false) t.errors = (t.errors || 0) + 1; if (ev.note) t.noted = oneLine(ev.note); s.turns[turn] = t; }
203
+ break;
204
+ }
205
+ case 'loop.turn.completed': {
206
+ if (turn == null) break;
207
+ const prev = s.turns[turn] || { errors: 0, denied: null };
208
+ s.turns[turn] = { ...prev, status: prev.errors ? 'err' : (prev.denied || prev.noted) ? 'warn' : 'ok', summary: oneLine(ev.summary || '').slice(0, 200) };
209
+ setReceipt(s, ev.envelope_id, wardClass(wardOf({ ward: ev.ward, ward_status: ev.ward_status })));
210
+ break;
211
+ }
212
+ case 'loop.ward':
213
+ setReceipt(s, ev.envelope_id, wardClass(wardOf(ev)));
214
+ break;
215
+ case 'loop.observe':
216
+ { // the runner states its budget and the turn it is about to take in its own words ("next check in 14s (turn 2 of 5)")
217
+ const m = /\(turn\s+(\d+)\s+of\s+(\d+)\)/i.exec(String(ev.text || ''));
218
+ if (m) { if (!s.maxTurns) s.maxTurns = Number(m[2]); s.iteration = Math.max(s.iteration ?? 0, Number(m[1]) - 1); }
219
+ }
220
+ if (ev.kind === 'milestone') s.milestone = oneLine(ev.text);
221
+ if (ev.kind === 'stall') s.stall = { text: oneLine(ev.text), at };
222
+ if (ev.kind === 'error' && turn != null && s.turns[turn]) s.turns[turn] = { ...s.turns[turn], errors: Math.max(1, s.turns[turn].errors || 0) };
223
+ break;
224
+ case 'loop.approval':
225
+ s.approval = { id: ev.checkpoint_id ?? null, what: oneLine(ev.what) };
226
+ if (s.turn && s.turns[s.turn]) s.turns[s.turn] = { ...s.turns[s.turn], status: s.turns[s.turn].status === 'run' ? 'warn' : s.turns[s.turn].status, waiting: true };
227
+ break;
228
+ case 'loop.finished':
229
+ s.state = TERMINAL.has(STATE_ALIAS[ev.state] || ev.state) ? (STATE_ALIAS[ev.state] || ev.state) : 'failed'; s.done = true; s.finishedAt = at ?? s.finishedAt;
230
+ s.turnsTotal = num(ev.turns) ?? s.turn; // the server's count; for a runner loop it is a message count, so the renderers prefer `iteration`
231
+ s.reason = ev.reason ? humanReason(ev.reason) : s.reason;
232
+ s.approval = null; s.stall = null;
233
+ if (num(ev.receipts) != null) s.finalReceipts = { total: num(ev.receipts), witnessed: num(ev.witnessed) ?? 0 };
234
+ markStopped(s);
235
+ break;
236
+ default: return st; // unknown event types are ignored, never fatal (forward compatible)
237
+ }
238
+ return s;
239
+ }
240
+
241
+ /** The connection to the stream (not the loop) changed. */
242
+ export const setConn = (st, conn, { attempt = 0, note = null } = {}) => ({ ...st, conn, connAttempt: attempt, connNote: note });
243
+
244
+ export function receiptCounts(st) {
245
+ const v = Object.values(st.receipts);
246
+ const own = { total: v.length, witnessed: v.filter(x => x === 'witnessed').length, failed: v.filter(x => x === 'failed').length };
247
+ const f = st.finalReceipts; // the server's own count wins when it is higher: a ward event may have been missed, never invented
248
+ return f && (f.total > own.total || f.witnessed > own.witnessed) ? { ...own, total: Math.max(f.total, own.total), witnessed: Math.max(f.witnessed, own.witnessed) } : own;
249
+ }
250
+
251
+ /**
252
+ * Where the card is drawn. A running loop's card (and a finished one you are still on) is PINNED above the composer, so a long
253
+ * answer or an approval card can never push it off the screen; its history slot is empty until it is let go, and then holds the
254
+ * frozen card in place, where the story was told.
255
+ */
256
+ export const loopPinned = (card) => Boolean(card && (!card.state.done || card.focused));
257
+
258
+ /* ---- chat lines (the loop's story) ---------------------------------------------------------- */
259
+
260
+ /**
261
+ * The chat line an event appends beneath the card, or null. -> { text, tone? }. Observer points are the server's decision;
262
+ * this only formats what it sent. A turn completion with no observer point stays inside the card.
263
+ */
264
+ export function observerLine(ev, st) {
265
+ if (!ev) return null;
266
+ const turn = st?.iteration ?? num(ev.turn) ?? st?.turn ?? 0; // the loop's own turn when it has one
267
+ if (ev.type === 'loop.observe') {
268
+ const text = oneLine(ev.text);
269
+ switch (ev.kind) {
270
+ case 'milestone': return { text: `∞ turn ${turn} │ milestone: ${text}` };
271
+ case 'error': return { text: `∞ turn ${turn} │ ${text}`, tone: 'error' };
272
+ case 'approval': return { text: `∞ turn ${turn} │ waiting for you: ${text}`, tone: 'warn' };
273
+ case 'stall': return { text: `∞ turn ${turn} │ ${text || 'no progress'}`, tone: 'warn' };
274
+ default: return { text: `∞ turn ${turn} │ ${text} ✔` };
275
+ }
276
+ }
277
+ if (ev.type === 'loop.approval') return { text: `∞ turn ${turn} │ paused: ${oneLine(ev.what)}. It is in your approvals.`, tone: 'warn' };
278
+ return null;
279
+ }
280
+
281
+ /** The finish line: `∞ QLOOP │ <title> │ <state>, n turns, n/n witnessed`. Always followed by the proof command. */
282
+ export function finishLines(st) {
283
+ const f = FINAL[st.state] || FINAL.failed;
284
+ const r = receiptCounts(st), n = st.iteration ?? st.turnsTotal ?? st.turn;
285
+ const witnessed = r.total ? `${r.witnessed}/${r.total} witnessed` : 'no receipts yet';
286
+ const text = `∞ QLOOP │ ${st.title || st.id} │ ${f.word}, ${n} turn${n === 1 ? '' : 's'}, ${witnessed}${st.reason && st.state !== 'completed' ? ` (${humanReason(st.reason)})` : ''}`;
287
+ return [{ text, tone: st.state === 'completed' ? 'ok' : st.state === 'failed' ? 'error' : 'warn' }, { text: `Proof: quox loop status ${st.id}` }];
288
+ }
289
+
290
+ export function startLine(st) {
291
+ return { text: `∞ QLOOP │ ${st.title || `loop ${st.id}`} │ started${st.host ? ` on ${st.host}` : ''}. /loop peek ${st.id} for detail.` };
292
+ }
293
+
294
+ /* ---- the card ------------------------------------------------------------------------------- */
295
+
296
+ const R = (t, role, bold) => ({ t, role, ...(bold ? { bold: true } : {}) });
297
+ const runWidth = (rs) => rs.reduce((n, r) => n + width(r.t), 0);
298
+
299
+ /** A runner loop knows its own turn (the iteration): turns before it are done, it is the one running, the rest are pending. */
300
+ function runnerCell(st, n, frame) {
301
+ const it = st.iteration;
302
+ if (n < it) return R('✔', 'ok');
303
+ if (n > it) return R('·', 'prose');
304
+ if (st.done) return st.state === 'completed' ? R('✔', 'ok') : isStop(st.state) ? R('✖', 'error') : R('⚠', 'warn');
305
+ return st.approval ? R('⚠', 'warn') : R(SPIN[frame % 4], 'sky');
306
+ }
307
+
308
+ function stripCells(st, frame) {
309
+ let max = st.maxTurns || Math.max(st.iteration ?? st.turn, 1);
310
+ if (st.done && st.state === 'completed') max = Math.max(1, Math.min(max, st.iteration ?? (st.turnsTotal || st.turn || 1))); // it finished early: the runway it never used is not shown
311
+ const shown = Math.min(max, STRIP_MAX);
312
+ const first = max > STRIP_MAX ? Math.max(1, Math.min((st.iteration ?? st.turn) || 1, max) - Math.floor(STRIP_MAX / 2) + 1) : 1;
313
+ const start = Math.min(first, max - shown + 1);
314
+ const out = [];
315
+ for (let n = start; n < start + shown; n++) {
316
+ if (st.iteration != null) { out.push(runnerCell(st, n, frame)); continue; }
317
+ const t = st.turns[n];
318
+ if (!t) { out.push(R('·', 'prose')); continue; }
319
+ if (t.status === 'ok') out.push(R('✔', 'ok'));
320
+ else if (t.status === 'err') out.push(R('✖', 'error'));
321
+ else if (t.status === 'warn') out.push(R('⚠', 'warn'));
322
+ else out.push(R(st.done ? '⚠' : SPIN[frame % 4], st.done ? 'warn' : 'sky'));
323
+ }
324
+ return { cells: out, hidden: start > 1 };
325
+ }
326
+
327
+ /** Head status: glyph + words. */
328
+ const pulseRole = (st, frame) => (st.done || frame % 4 < 2 ? 'brand' : 'sky'); // the ∞ breathes while the loop runs and holds still once it is over
329
+
330
+ function headStatus(st, now, frame) {
331
+ const elapsed = st.startedAt != null ? fmtElapsed((st.finishedAt ?? now) - st.startedAt) : null;
332
+ const turn = `turn ${(st.iteration ?? st.turn) || 0}${st.maxTurns ? `/${st.maxTurns}` : ''}`;
333
+ if (st.done) {
334
+ const f = FINAL[st.state] || FINAL.failed, n = st.iteration ?? st.turnsTotal ?? st.turn;
335
+ return [R(`${f.glyph} `, f.role), R(`${f.word} · ${n} turn${n === 1 ? '' : 's'}${elapsed ? ` · ${elapsed}` : ''}`, f.role)];
336
+ }
337
+ if (st.startedAt == null && !st.turn) return [R('◐ ', 'sky'), R('waiting to start (approve it in /approvals)', 'warn')];
338
+ if (st.approval) return [R('⚠ ', 'warn'), R(`waiting for you · ${turn}${elapsed ? ` · ${elapsed}` : ''}`, 'warn')];
339
+ if (st.stall) return [R('⚠ ', 'warn'), R(`stalled · ${turn}${elapsed ? ` · ${elapsed}` : ''}`, 'warn')];
340
+ return [R(`${SPIN[frame % 4]} `, 'sky'), R(`${turn}${elapsed ? ` · ${elapsed}` : ''}`, 'text')];
341
+ }
342
+
343
+ /**
344
+ * The card as lines of coloured runs: [[{t, role, bold?}, ...], ...]. Width-aware; every line fits `cols`.
345
+ * opts: { cols, now, frame, expanded, focused, confirm, hint } (hint: the pinned card says how to reach its keys)
346
+ */
347
+ export function cardLines(st, { cols = 80, now = Date.now(), frame = 0, expanded = false, focused = false, confirm = false, hint = false } = {}) {
348
+ const w = Math.max(20, cols - 2); // the view indents the card by 2
349
+ const r = receiptCounts(st);
350
+ const lines = [];
351
+ const rc = r.total ? `${r.witnessed}/${r.total}` : '0/0';
352
+ const rcRole = r.failed ? 'error' : r.total && r.witnessed === r.total ? 'ok' : 'text';
353
+ const rcMark = r.failed ? ' ✖' : r.total && r.witnessed === r.total ? ' ✔' : r.total ? ' ◐' : '';
354
+
355
+ if (cols < NARROW_COLS) { // one line: the full title was printed when the loop started
356
+ const head = headStatus(st, now, frame);
357
+ const tail = [R(` ${rc}${rcMark}`, rcRole)];
358
+ const used = 2 + runWidth(head) + runWidth(tail) + 1;
359
+ const title = oneLine(st.title || st.id);
360
+ const room = Math.max(0, w - used);
361
+ let shown = title;
362
+ if (width(title) > room) { // the first cells that fit, then an ellipsis; the full title is one Enter away and in the start line
363
+ shown = ''; let sw = 0;
364
+ for (const x of graphemes(title)) { const gw = width(x.text); if (sw + gw > room - 1) break; shown += x.text; sw += gw; }
365
+ shown = room > 1 ? shown + '…' : '';
366
+ }
367
+ lines.push([R('∞ ', pulseRole(st, frame), true), ...head, ...tail, ...(shown ? [R(' ' + shown, 'text')] : [])]);
368
+ if (expanded) for (const l of wrapText(st.title || st.id, w - 2)) lines.push([R(' ' + l, 'text')]);
369
+ if (confirm) lines.push([R('Halt this loop? y to halt, any other key cancels', 'warn')]);
370
+ return lines;
371
+ }
372
+
373
+ // line 1: ∞ LOOP <title> ... status
374
+ const head = headStatus(st, now, frame);
375
+ const lead = [R('∞ ', pulseRole(st, frame), true), R('LOOP', 'brand', true), R(' ', 'text')];
376
+ const title = oneLine(st.title || st.id);
377
+ if (runWidth(lead) + width(title) + 2 + runWidth(head) <= w) {
378
+ const pad = w - runWidth(lead) - width(title) - runWidth(head);
379
+ lines.push([...lead, R(title, 'text', true), R(' '.repeat(pad), 'text'), ...head]);
380
+ } else { // a long title never shares the line with the status; it wraps under it
381
+ lines.push([...lead.slice(0, 2), R(' '.repeat(Math.max(1, w - 6 - runWidth(head))), 'text'), ...head]);
382
+ const wrapped = wrapText(title, w - 2); // capped at 2 lines (Enter expands): a long title is not laid out in full on every frame
383
+ const tl = expanded ? wrapped : wrapped.slice(0, 2);
384
+ if (tl.length < wrapped.length) { const last = [...graphemes(tl[1])]; while (last.length && width(last.map((x) => x.text).join('')) > w - 4) last.pop(); tl[1] = last.map((x) => x.text).join('') + '…'; }
385
+ for (const l of tl) lines.push([R(' ' + l, 'text', true)]);
386
+ }
387
+
388
+ // line 2: strip + receipts + where
389
+ const { cells, hidden } = stripCells(st, frame);
390
+ const strip = [R('▕', 'brand'), ...(hidden ? [R('…', 'prose')] : []), ...cells, R('▏', 'brand')];
391
+ const barN = Math.min(r.total, 8), barFill = r.total ? Math.round((r.witnessed / r.total) * barN) : 0;
392
+ const receipts = [R(' receipts ', 'label'), R(rc, rcRole, true), R(' witnessed' + rcMark, rcRole), ...(barN ? [R(' ' + '▰'.repeat(barFill) + '▱'.repeat(barN - barFill), rcRole)] : [])]; // fills as the receipts land
393
+ const where = [];
394
+ if (st.host) where.push(R('host ', 'label'), R(st.host, 'text'));
395
+ if (st.scope.length) { if (where.length) where.push(R(' · ', 'prose')); where.push(R('scope ', 'label'), R(st.scope.join(','), 'text')); }
396
+ const base = [...strip, ...receipts];
397
+ if (!where.length) lines.push(base);
398
+ else if (runWidth(base) + 3 + runWidth(where) <= w) lines.push([...base, R(' ', 'text'), ...where]);
399
+ else { lines.push(base); lines.push([R(' ', 'text'), ...where]); } // the scope list may wrap further
400
+ if (lines[lines.length - 1].length && runWidth(lines[lines.length - 1]) > w) { // a very long scope: wrap it as plain text
401
+ const last = lines.pop(); const flat = last.map(x => x.t).join('');
402
+ for (const l of wrapText(flat.trim(), w - 2)) lines.push([R(' ' + l, 'text')]);
403
+ }
404
+
405
+ // waiting / stalled / connection
406
+ if (st.approval) for (const l of wrapText(`waiting for you: ${st.approval.what}. Open your approvals to answer.`, w - 2)) lines.push([R('⚠ ', 'warn'), R(l, 'warn')]);
407
+ if (st.stall && !st.done) for (const l of wrapText(st.stall.text || 'no turn progress', w - 2)) lines.push([R('⚠ ', 'warn'), R(l, 'warn')]);
408
+ if (st.conn === 'reconnecting') lines.push([R('◐ ', 'sky'), R(`stream dropped, reconnecting (attempt ${st.connAttempt})`, 'warn')]);
409
+ if (st.conn === 'lost') for (const l of wrapText(`lost the live stream${st.connNote ? `: ${st.connNote}` : ''}. The loop itself may still be running: quox loop status ${st.id}`, w - 2)) lines.push([R('✖ ', 'error'), R(l, 'error')]);
410
+ if (st.done && st.reason && st.state !== 'completed') for (const l of wrapText(`reason: ${humanReason(st.reason)}`, w - 2)) lines.push([R(' ', 'text'), R(l, FINAL[st.state]?.role || 'text')]);
411
+
412
+ // spool
413
+ // The milestone is said once: the spool's copy of it is dropped (the ticker line below carries it) and, when the loop is over
414
+ // and the chat story already said it, the ticker goes too.
415
+ let spoolText = st.spool.replace(/\s+$/g, '');
416
+ if (st.milestone) {
417
+ const words = st.milestone.trim().split(/\s+/).map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
418
+ if (words.length) spoolText = spoolText.replace(new RegExp(`(?:MILESTONE:\\s*)?${words.join('\\s+')}`, 'i'), '').replace(/\n{3,}/g, '\n\n').replace(/\s+$/g, '');
419
+ }
420
+ if (spoolText) {
421
+ const all = wrapText(spoolText, w - 4).filter((l, i, a) => l || (i > 0 && i < a.length - 1));
422
+ const keep = expanded ? SPOOL_LINES_EXPANDED : SPOOL_LINES;
423
+ const tail = all.slice(-keep), cut = all.length - tail.length;
424
+ if (cut > 0) lines.push([R('│ ', 'brand'), R(`${cut} earlier line${cut === 1 ? '' : 's'}${expanded ? '' : ' (Enter shows more)'}`, 'label')]);
425
+ tail.forEach((l) => lines.push([R('│ ', 'brand'), R(l, 'text')])); // no per-line prefix beyond the bar: a prefix would push the wrap past the width
426
+ }
427
+ if (st.milestone && !(st.done && st.observed.some((l) => String(l).includes(st.milestone)))) for (const l of wrapText(`milestone: ${st.milestone}`, w - 2)) lines.push([R('⋯ ', 'violet'), R(l, 'text')]);
428
+ if (expanded && !spoolText) lines.push([R('│ ', 'brand'), R(st.done ? 'no output was captured' : 'no output yet this turn', 'label')]);
429
+
430
+ if (confirm) lines.push([R('⚠ ', 'warn'), R('Halt this loop? y to halt, any other key cancels', 'warn', true)]);
431
+ else if (focused && !st.done) lines.push([R('Enter ', 'key'), R('expand output · ', 'label'), R('s ', 'key'), R('halt · ', 'label'), R('Esc ', 'key'), R('collapse', 'label')]);
432
+ else if (focused) lines.push([R('Enter ', 'key'), R('expand · ', 'label'), R('Esc ', 'key'), R('back', 'label')]);
433
+ else if (hint && !st.done) lines.push([R('↓ ', 'key'), R('to control this loop (Enter expand · s halt)', 'label')]); // an EMPTY composer + Down focuses it; no letter is ever taken from a draft
434
+ return lines;
435
+ }
436
+
437
+ /* ---- redraw throttle ------------------------------------------------------------------------ */
438
+
439
+ /**
440
+ * Coalesces calls so `fn` runs at most once per `minMs` (trailing edge kept, so the last state always lands).
441
+ * `timer(fn, ms)` returns a cancel function; injectable for tests.
442
+ */
443
+ export const REDRAW_UNFOCUSED_MS = 500; // a card nobody is focused on redraws at most twice a second
444
+ export function createThrottle(fn, { minMs = REDRAW_MIN_MS, now = Date.now, timer = (f, ms) => { const t = setTimeout(f, ms); t.unref?.(); return () => clearTimeout(t); } } = {}) {
445
+ let last = -Infinity, cancel = null, pending = false;
446
+ const fire = () => { cancel = null; pending = false; last = now(); fn(); };
447
+ return {
448
+ poke() {
449
+ if (cancel) { pending = true; return; }
450
+ const wait = last + (typeof minMs === 'function' ? minMs() : minMs) - now();
451
+ if (wait <= 0) fire(); else { pending = true; cancel = timer(fire, wait); }
452
+ },
453
+ dispose() { cancel?.(); cancel = null; pending = false; },
454
+ get pending() { return pending; },
455
+ };
456
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * QLOOP-LIVE: the loop cards in the chat stream. One card per loop, updated in place; observer points and the finish line
3
+ * append as ordinary chat lines beneath it, so the history reads as the loop's story.
4
+ *
5
+ * Glue only (the logic is in loopCard.js and loopStream.js): it owns the watchers, the redraw throttle (<= 8 per second),
6
+ * the keys of a focused card and the finish moment. The host (app.js) supplies say / addItem / bump / dragon, which keeps this
7
+ * testable without Ink.
8
+ */
9
+ import { loopPinned, initLoop, applySnapshot, applyProgress, reduceLoop, setConn, observerLine, finishLines, startLine, createThrottle, REDRAW_MIN_MS, REDRAW_UNFOCUSED_MS, TERMINAL } from './loopCard.js';
10
+ import { watchLoop } from './loopStream.js';
11
+ import { objectiveMoment } from './dragonMoments.js';
12
+
13
+ const TICK_MS = 250; // a focused card's spinner and clock
14
+ const TICK_UNFOCUSED_EVERY = 4; // nobody on the card: every 4th tick (1 s), so idle cards cost little
15
+ const REFRESH_MIN_MS = 2000; // the runner's own turn is read from the snapshot at most this often per card
16
+ const REFRESH_CAP_MS = 1500; // and the finish never waits longer than this for the last read
17
+
18
+ /**
19
+ * The roster sees a loop finish (its row turns done) up to a minute BEFORE the stream's loop.finished reaches the card (the server
20
+ * drains receipts first), and its dragon then lands on a card that is still running and overflows the screen. A loop with a live card
21
+ * summons its dragon from the card, once, at its finish: the roster's watch skips those rows.
22
+ */
23
+ export const rowsWithoutLoopCards = (rows, has) => (rows || []).filter((r) => !(r?.kind === 'loop' && has(r.id)));
24
+
25
+ export function createLoopCards({ api, say, addItem, bump, dragon = null, now = Date.now, setTick = (f, ms) => { const t = setInterval(f, ms); t.unref?.(); return () => clearInterval(t); }, throttleTimer, watch = watchLoop, sleep } = {}) {
26
+ const cards = new Map(); // id -> ctrl
27
+ let focusId = null, cancelTick = null;
28
+ const throttle = createThrottle(() => bump(), { minMs: () => (focusId ? REDRAW_MIN_MS : REDRAW_UNFOCUSED_MS), now, ...(throttleTimer ? { timer: throttleTimer } : {}) });
29
+
30
+ const running = () => [...cards.values()].some((c) => !c.state.done);
31
+ /**
32
+ * A runner loop's stream `turn` counts model messages; its own turn is the snapshot's `iteration`. Read it again when a turn moves
33
+ * (at most every REFRESH_MIN_MS; a request inside the window is made by the next tick). -> a promise that settles when the read is over.
34
+ */
35
+ function refresh(c, force = false) {
36
+ if (!api.loopSnapshot || c.refreshing) return c.refreshing || Promise.resolve();
37
+ if (!force && now() - (c.lastSnapAt ?? -Infinity) < REFRESH_MIN_MS) { c.refreshDue = true; return Promise.resolve(); }
38
+ c.lastSnapAt = now(); c.refreshDue = false;
39
+ c.refreshing = Promise.resolve().then(() => api.loopSnapshot(c.id)).then((snap) => { c.state = applyProgress(c.state, snap); throttle.poke(); }, () => {}).finally(() => { c.refreshing = null; });
40
+ return c.refreshing;
41
+ }
42
+ let tickN = 0;
43
+ function tick() { // elapsed time and the spinner move once per tick, never faster than the throttle allows
44
+ if (!running()) { cancelTick?.(); cancelTick = null; return; }
45
+ if (!focusId && ++tickN % TICK_UNFOCUSED_EVERY) return;
46
+ for (const c of cards.values()) if (!c.state.done) { c.frame = (c.frame + 1) % 4; if (c.refreshDue) refresh(c); }
47
+ throttle.poke();
48
+ }
49
+ const ensureTick = () => { if (!cancelTick && running()) cancelTick = setTick(tick, TICK_MS); };
50
+
51
+ function finish(c, ev) {
52
+ if (!c.finishedSaid) { c.finishedSaid = true; for (const l of finishLines(c.state)) say(l.text, l.tone); }
53
+ c.confirm = false; // a focused card keeps its focus: Enter then expands the final output and Esc lets the frozen card go to scrollback
54
+ // The moment engine fires the dragon. Same call and same gates as a finished roster row (mode on, not typing, cooldown).
55
+ const st = c.state;
56
+ const m = !c.quiet && dragon?.summon && ['completed', 'completed_with_errors', 'failed'].includes(st.state)
57
+ ? objectiveMoment({ kind: 'loop', state: st.state === 'failed' ? 'failed' : 'done', name: st.title || st.id, activity: st.reason || '' }) : null;
58
+ if (m) Promise.resolve(dragon.summon(st.agent && typeof st.agent === 'string' ? st.agent : m.dragon, m.caption)).then((shown) => { if (shown) bump(); }, () => {});
59
+ return ev;
60
+ }
61
+
62
+ function onEvent(c, ev) {
63
+ const before = c.state;
64
+ c.state = reduceLoop(before, ev);
65
+ if (c.state === before) return;
66
+ const line = c.quiet ? null : observerLine(ev, c.state); // replaying a loop that was already over: the card tells it, the chat does not re-read it
67
+ if (line) { c.state.observed = [...c.state.observed, line.text].slice(-50); say(line.text, line.tone); }
68
+ if (ev.type === 'loop.turn.started' || ev.type === 'loop.turn.completed' || ev.type === 'loop.observe') refresh(c);
69
+ if (ev.type === 'loop.finished') { // the last read of the runner's turn first: "completed, N turns" must be the loop's count
70
+ c.finishing = true;
71
+ let t; const cap = new Promise((res) => { t = setTimeout(res, REFRESH_CAP_MS); t.unref?.(); });
72
+ Promise.race([refresh(c, true), cap]).then(() => { clearTimeout(t); finish(c, ev); throttle.poke(); });
73
+ }
74
+ throttle.poke();
75
+ }
76
+
77
+ async function run(c) {
78
+ const ctl = c.abort = new AbortController();
79
+ try {
80
+ const out = await watch({
81
+ api, id: c.id, signal: ctl.signal, sleep,
82
+ onSnapshot: (snap) => { c.state = applySnapshot(c.state, snap); if (c.state.done) c.quiet = true; throttle.poke(); }, // the finish line waits for the replay: it needs the receipts
83
+ onEvent: (ev) => onEvent(c, ev),
84
+ onConn: ({ conn, attempt, note }) => { if (!c.state.done) { c.state = setConn(c.state, conn, { attempt, note }); throttle.poke(); } },
85
+ });
86
+ if (out?.lost && !c.state.done) { c.state = setConn(c.state, 'lost', { attempt: 0, note: out.reason }); }
87
+ if (c.state.done && !c.finishedSaid && !c.finishing) { c.finishedSaid = true; for (const l of finishLines(c.state)) say(l.text, l.tone); } // finished before we looked (or the replay ended without a finish event)
88
+ } catch (e) {
89
+ c.state = setConn(c.state, 'lost', { note: e?.message || 'failed' });
90
+ } finally { throttle.poke(); }
91
+ }
92
+
93
+ return {
94
+ /** Put a live card in the chat stream for this loop and start following it. -> the card, or the existing one. */
95
+ watch(id, { title = '', agent = null, announce = true } = {}) {
96
+ if (!id) return null;
97
+ if (cards.has(id)) return cards.get(id);
98
+ const c = { id, state: initLoop({ id, title, agent }), expanded: false, focused: false, confirm: false, frame: 0, abort: null, finishedSaid: false };
99
+ cards.set(id, c);
100
+ if (announce) { const l = startLine(c.state); say(l.text, l.tone); } // the start line first, then the card, then the story beneath
101
+ addItem({ id: `loopcard-${id}`, kind: 'loopcard', card: c });
102
+ ensureTick();
103
+ run(c).then(() => bump());
104
+ return c;
105
+ },
106
+ /**
107
+ * A loop the chat just made (its `objective` tool answered `Loop "<title>" created ...`): the tool result carries no id, so look
108
+ * the loop up in the org's list (newest first) and put a card in this conversation for the live, unwatched one that matches
109
+ * the title. One retry, because the list can lag the tool's answer. -> ids watched.
110
+ */
111
+ async discover({ title = '' } = {}) {
112
+ if (!api.loopList) return [];
113
+ const want = String(title || '').replace(/…$/, '').trim().toLowerCase();
114
+ for (let attempt = 0; attempt < 2; attempt++) {
115
+ let rows = [];
116
+ try { const d = await api.loopList(); rows = Array.isArray(d) ? d : (d?.loops || []); } catch { rows = []; }
117
+ const hit = rows.filter((r) => {
118
+ const id = r.objective_id || r.id; if (!id || cards.has(id) || TERMINAL.has(r.state)) return false;
119
+ const age = now() - (Date.parse(String(r.created_at || '').replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(r.created_at || '')) ? '' : 'Z')) || 0);
120
+ return age < 20 * 60 * 1000 && (!want || String(r.title || '').toLowerCase().startsWith(want));
121
+ });
122
+ if (hit.length) { const r = hit[0]; const id = r.objective_id || r.id; this.watch(id, { title: r.title || title }); return [id]; }
123
+ if (attempt === 0) await (sleep || ((ms) => new Promise((res) => setTimeout(res, ms))))(1500);
124
+ }
125
+ return [];
126
+ },
127
+ /** Read the runner's turn now (tests, and a card that was just focused). */
128
+ refresh: (id) => (cards.has(id) ? refresh(cards.get(id), true) : Promise.resolve()),
129
+ has: (id) => cards.has(id),
130
+ get: (id) => cards.get(id) || null,
131
+ all: () => [...cards.values()],
132
+ /** True while this history item must stay in the live region (commitLimit): its loop has not finished. */
133
+ held: (item) => Boolean(item?.kind === 'loopcard' && item.card && (!item.card.state.done || item.card.focused)), // a focused card stays live even when finished, so Enter / Esc work on it
134
+ anyRunning: running,
135
+ /** The cards drawn above the composer: running ones, and a finished one you are still on. */
136
+ pinned: () => [...cards.values()].filter(loopPinned),
137
+ /** The card the keys act on, or null. */
138
+ focused: () => (focusId && cards.get(focusId)) || null,
139
+ /** Focus the newest card that is still running (or the named one). -> the card or null. */
140
+ focus(id = null) {
141
+ const pick = id ? cards.get(id) : [...cards.values()].reverse().find((c) => !c.state.done) || [...cards.values()].pop();
142
+ if (!pick) return null;
143
+ const old = this.focused(); if (old) { old.focused = false; old.confirm = false; }
144
+ pick.focused = true; focusId = pick.id; throttle.poke(); bump(); return pick;
145
+ },
146
+ blur() { const c = this.focused(); if (c) { c.focused = false; c.confirm = false; } focusId = null; bump(); },
147
+ /**
148
+ * Keys while a card is focused. Enter toggles the expanded spool, s asks to halt (y confirms, anything else cancels),
149
+ * Esc collapses and then leaves. -> true when the key was consumed.
150
+ */
151
+ key(name, text) {
152
+ const c = this.focused(); if (!c) return false;
153
+ if (c.confirm) {
154
+ c.confirm = false;
155
+ if (text === 'y') this.halt(c.id).then(() => bump(), () => bump()); else say('Halt cancelled.');
156
+ bump(); return true;
157
+ }
158
+ if (name === 'enter') { c.expanded = !c.expanded; bump(); return true; }
159
+ if (name === 'escape') { if (c.expanded) c.expanded = false; else this.blur(); bump(); return true; }
160
+ if (text === 's' && !c.state.done) { c.confirm = true; bump(); return true; }
161
+ if (name === 'up' || name === 'down' || name === 'tab') { // cycle between cards when there are several
162
+ const ids = [...cards.keys()]; if (ids.length > 1) { const i = ids.indexOf(c.id); this.focus(ids[(i + (name === 'up' ? ids.length - 1 : 1)) % ids.length]); }
163
+ return true;
164
+ }
165
+ if (text) { this.blur(); return false; } // ordinary typing goes to the draft
166
+ return true;
167
+ },
168
+ /** The halt goes through the same governed route as the roster's s: after an explicit y from the human, never silently. */
169
+ async halt(id) {
170
+ const c = cards.get(id); if (!c) return { ok: false, message: `no loop card for ${id}` };
171
+ if (!api.stopRow) { say('Halt is not available on this server.', 'warn'); return { ok: false }; }
172
+ const r = await api.stopRow({ source: 'objectives', kind: 'loop', id, name: c.state.title || id });
173
+ say(r.message || (r.ok ? 'Halt sent.' : 'Halt refused.'), r.ok ? undefined : 'warn');
174
+ return r;
175
+ },
176
+ dispose() { for (const c of cards.values()) c.abort?.abort(); cards.clear(); cancelTick?.(); cancelTick = null; throttle.dispose(); },
177
+ };
178
+ }
179
+
180
+ export { TERMINAL };