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
@@ -2,6 +2,7 @@
2
2
  * QUOXON view layer (Ink 6, React.createElement). Width-aware everywhere,
3
3
  * horizontal rules only (no side borders), semantic colour, mono-safe.
4
4
  */
5
+ import { toolCardLines } from './toolCards.js';
5
6
  import React from 'react';
6
7
  import chalk from 'chalk';
7
8
  import { Box, Text, useStdout } from 'ink';
@@ -23,7 +24,8 @@ import { dividerRuleEl } from './dividerRule.js';
23
24
  import * as themeNs from './theme.js';
24
25
  import { qArt, qArtFrame } from '../core/banner.js';
25
26
  import { cmd } from './palette.js';
26
- import { themeRole, rowParts, clipParts, labelWidth } from './rows.js';
27
+ import { themeRole, layoutRows, expandLines, limitGrid } from './rows.js';
28
+ import { wrapHang } from './objects/model.js';
27
29
  import { Markdown, wrapCell, markdownRows, wrapRows, WText, Heading } from './markdown.js';
28
30
  import { parseAsk, keysLabel, askText } from './ask.js';
29
31
  import { parseAsideNote } from './asideView.js';
@@ -41,6 +43,8 @@ import { CLIPS as DRAGON_CLIPS, clipCells } from './qdragonScenes.js';
41
43
  import { dragonLayout } from './qdragon.js';
42
44
  import { ObjectView } from './objects/view.js';
43
45
  import { aoclLineSegs, aoclTableLines } from './aocl.js';
46
+ import { cardLines, wrapText, loopPinned } from './loopCard.js';
47
+ import { orgLabel } from './orgLabel.js';
44
48
  export { COLORS, MODE_COLOR, MIN_COLS, clampCols, clip, reducedMotion };
45
49
 
46
50
  // ---- working indicator (the one animated line) -------------------------------------------------
@@ -254,15 +258,24 @@ export function ToolRow({ b, cols, fx = null, pulse = null, ignite = 0 }) {
254
258
  const running = b.status === 'running';
255
259
  // the facts after the result: duration, then witnessing state
256
260
  let after = [];
261
+ let envExtra = null; // an id never shares a clipped line: when it does not fit it wraps onto its own line(s)
257
262
  if (!running && b.receipt) {
258
263
  const v = b.receipt.verified;
259
264
  after = [dur, v === true ? 'witnessed' : v === false ? 'unverified' : 'verification pending', `receipt ward:${b.receipt.ward_seq}`].filter(Boolean);
265
+ if (b.envelopeId) envExtra = `envelope ${b.envelopeId}`;
260
266
  } else if (!running) {
261
- after = [dur, b.envelopeId ? `envelope ${String(b.envelopeId).slice(0, 8)}` : '', b.envelopeId ? 'witnessing pending' : ''].filter(Boolean);
267
+ after = [dur, b.envelopeId ? `envelope ${b.envelopeId}` : '', b.envelopeId ? 'witnessing pending' : ''].filter(Boolean);
262
268
  }
263
269
  const result = running ? '' : deleg && deleg.result ? deleg.result : (b.resultSummary ? `${b.status === 'unknown' ? 'unknown: ' : ''}${b.resultSummary}` : b.status);
264
270
  const bodyStr = clipWords(body0, running || !result ? room : Math.max(6, Math.min(gw(body0), Math.floor(room / 2))));
265
271
  const tailRoom = room - gw(bodyStr) - 3;
272
+ if (!running && !b.receipt && b.envelopeId && gw(after.join(' · ')) + 3 > Math.max(0, tailRoom)) {
273
+ envExtra = `envelope ${b.envelopeId} · witnessing pending`;
274
+ after = after.filter(f => !/^envelope /.test(f) && f !== 'witnessing pending');
275
+ }
276
+ const extra = envExtra ? wrapHang(envExtra, Math.max(12, cols - 6), 2).map((l, i) => h(Text, { key: `e${i}`, color: c('muted') }, ` ${l}`)) : [];
277
+ if (!running) extra.push(...toolCardLines(b, cols).map((l, i) => h(Text, { key: `c${i}`, color: c(l.head ? 'muted' : 'prose'), wrap: 'truncate-end' }, ` ${l.text}`))); // HITL-INBOX-BAR: a list answer as a table
278
+ const withExtra = (node) => (extra.length ? h(Box, { flexDirection: 'column' }, node, ...extra) : node);
266
279
  let tail = '';
267
280
  if (!running && tailRoom > 0) {
268
281
  const facts = after.join(' · ');
@@ -285,9 +298,9 @@ export function ToolRow({ b, cols, fx = null, pulse = null, ignite = 0 }) {
285
298
  if (fx?.settle != null && !running) {
286
299
  const flash = fx.settle < 0.12 && !mono();
287
300
  if (flash) head[1] = { t: glyph, color: '#ffffff', bold: true };
288
- return h(Box, null, ...fxNodes(head, null), ...fxNodes(tailSegs, { e: fx.settle, timing: TIMING.settle }, 7));
301
+ return withExtra(h(Box, null, ...fxNodes(head, null), ...fxNodes(tailSegs, { e: fx.settle, timing: TIMING.settle }, 7)));
289
302
  }
290
- return h(Box, null, ...fxNodes([...head, ...tailSegs], fx, 3));
303
+ return withExtra(h(Box, null, ...fxNodes([...head, ...tailSegs], fx, 3)));
291
304
  }
292
305
 
293
306
  /** Compact protocol detail: drop the quox.chat./quox. intent prefix and keep only the tail of long correlation ids, so the line fits instead of clipping. */
@@ -381,6 +394,7 @@ export function HelpView({ model, cols }) {
381
394
  const keyW = Math.min(24, Math.max(...model.filter(m => m.key).map(m => [...m.key].length), 8) + 2);
382
395
  return h(Box, { flexDirection: 'column', marginTop: 1, width: cols }, ...model.map((m, i) => {
383
396
  if (m.kind === 'gap') return h(Text, { key: i }, ' ');
397
+ if (m.kind === 'subhead') return h(Heading, { key: i, text: m.text });
384
398
  if (m.kind === 'head') return m.dim ? h(Text, { key: i, bold: true, color: c('muted') }, m.text) : h(Heading, { key: i, text: m.text });
385
399
  if (m.kind === 'row' || (m.kind === 'dim' && m.key)) {
386
400
  const dim = m.kind === 'dim';
@@ -511,6 +525,7 @@ const GROUP = { tool: 'tools', trace: 'tools', note: 'notice', error: 'notice',
511
525
  const FOOTER = new Set(['aocl', 'summary']);
512
526
  export function historyGap(prev, item, rows = null) {
513
527
  if (!item || SELF_SPACED.has(item.kind)) return 0;
528
+ if (item.kind === 'loopcard' && loopPinned(item.card)) return 0; // drawn above the composer, not here
514
529
  if (item.kind === 'user') return 1;
515
530
  if (!prev) return item.kind === 'text' ? 1 : 0;
516
531
  if (rows != null && rows < 20) return 0;
@@ -546,6 +561,7 @@ export function HistoryItem({ item, cols, live = false, fx = null, pulse = null,
546
561
  case 'error': return h(ErrorView, { item, cols, fx });
547
562
  case 'note': return h(NoteView, { item, cols, fx });
548
563
  case 'slotmsg': return h(SlotMsgView, { item, cols });
564
+ case 'loopcard': return loopPinned(item.card) ? null : h(LoopCardView, { item, cols });
549
565
  case 'tip': return h(TipView, { item, cols });
550
566
  case 'whatsnew': return h(WhatsNewView, { item, cols });
551
567
  case 'bcard': return h(BroadcastCardView, { item, cols });
@@ -572,7 +588,10 @@ export function noteLines(text, width) {
572
588
  }
573
589
  /** A note line that is one bare URL longer than the row: never hard-wrapped (a break inside a token breaks copy and click). */
574
590
  const longUrl = (l, width) => { const t = stripMarks(l); return /^https?:\/\/\S+$/.test(t) && graphemes(t).length > width; };
575
- export const noteRows = (text, cols, tone) => noteLines(toneText(text, tone), cols - 2).reduce((n, l) => n + (longUrl(l, cols - 2) ? 1 : wrapRows(stripMarks(l), cols - 2)), 0);
591
+ /** A note line that opens with a status glyph and wraps hangs under its text, not under the glyph (the glyph keeps its own 2 cells). */
592
+ const GLYPH_LEAD = /^(?:▶|✔|✖|⚠|∞) /u;
593
+ const hangs = (l, width) => GLYPH_LEAD.test(l) && !longUrl(l, width) && wrapRows(stripMarks(l), width) > 1;
594
+ export const noteRows = (text, cols, tone) => noteLines(toneText(text, tone), cols - 2).reduce((n, l) => n + (longUrl(l, cols - 2) ? 1 : hangs(l, cols - 2) ? wrapRows(stripMarks(l).slice(2), cols - 4) : wrapRows(stripMarks(l), cols - 2)), 0);
576
595
 
577
596
  /**
578
597
  * Z2H-7B3: a failure message can be several lines (what ran, what did not, the advice). Every line shows,
@@ -618,6 +637,8 @@ function NoteView({ item, cols, fx = null }) {
618
637
  // While it reveals, a line that fits decodes in; a line that wraps keeps plain wrapping (never mis-measured).
619
638
  return h(Box, { paddingLeft: 2, flexDirection: 'column', width: cols }, ...noteLines(text, cols - 2).map((l, i) => longUrl(l, cols - 2)
620
639
  ? h(Box, { key: i, width: graphemes(stripMarks(l)).length, flexShrink: 0 }, h(Text, { color: c('link'), wrap: 'truncate-end' }, stripMarks(l)))
640
+ : hangs(l, cols - 2)
641
+ ? h(Box, { key: i }, h(Box, { width: 2, flexShrink: 0 }, h(Text, null, ...runs(paintLine(l.slice(0, 2), item.tone)))), h(Box, { flexGrow: 1, flexShrink: 1 }, h(WText, { color, bold: item.tone === 'ask' }, ...runs(paintLine(l.slice(2), item.tone)))))
621
642
  : (fx && wrapRows(stripMarks(l), cols - 2) === 1
622
643
  ? h(Text, { key: i }, ...fxNodes(paintLine(l, item.tone).map(r => (typeof r === 'string' ? { t: r, color: 'prose' } : { t: r.text, color: r.role || 'prose' })), { e: fx.e - i * 0.05, timing: fx.timing }, 17 + i))
623
644
  : h(WText, { key: i, color, bold: item.tone === 'ask' }, ...runs(paintLine(l, item.tone))))));
@@ -628,6 +649,19 @@ function NoteView({ item, cols, fx = null }) {
628
649
  * outgoing "→ quox2 04:56 hello ✔" (sky: you). A file shows its name, three lines and a /slot log pointer;
629
650
  * /slot log prints the whole file (`full`).
630
651
  */
652
+ /** QLOOP-LIVE: the live loop card. The card object is updated in place by loopCards.js; this reads it on every draw. */
653
+ export const loopCardLines = (card, cols, now = Date.now(), hint = false) => cardLines(card.state, { cols, now, frame: Math.floor(now / 250), expanded: card.expanded, focused: card.focused, confirm: card.confirm, hint });
654
+ const cardMemo = new WeakMap(); // card -> { key, lines }: re-laid-out only when what it draws changed
655
+ export function LoopCardView({ item, cols, pinned = false }) {
656
+ const card = item.card, now = Date.now(), st = card.state;
657
+ const key = [cols, pinned, card.focused, card.expanded, card.confirm, Math.floor(now / 250), Math.floor(now / 1000), st, st.observed?.length, st.spool?.length].map(String).join('|');
658
+ let hit = cardMemo.get(card);
659
+ if (!hit || hit.st !== st || hit.key !== key) { hit = { st, key, lines: loopCardLines(card, cols, now, pinned) }; cardMemo.set(card, hit); }
660
+ const lines = hit.lines;
661
+ return h(Box, { flexDirection: 'column', paddingLeft: 2, width: cols },
662
+ ...lines.map((rs, i) => h(Text, { key: i }, ...rs.map((r, j) => h(Text, { key: j, color: c(r.role), bold: r.bold || undefined }, r.t)))));
663
+ }
664
+
631
665
  function SlotMsgView({ item, cols }) {
632
666
  const l = item.line || {};
633
667
  if (l.roomLine) { // QUOXON-ROOMS: `#group-chat QUOXON: text`, a stable colour per sender
@@ -770,6 +804,8 @@ export function liveRows(blocks, cols) {
770
804
  case 'summary': return n + noteLines(b.text, cols - 4).length;
771
805
  case 'error': return n + errorRows(b.text, cols);
772
806
  case 'slotmsg': return n + (b.line?.run && !b.line.run.settled ? 9 + Math.ceil(String(b.line.run.cmd || '').length / Math.max(10, cols - 8)) : 1); // ASK-SETTLE: an armed run card is held live
807
+ case 'tool': return n + 1 + (b.status === 'running' ? 0 : toolCardLines(b, cols).length);
808
+ case 'loopcard': return n + (loopPinned(b.card) ? 0 : 1 + loopCardLines(b.card, cols).length);
773
809
  default: return n + 1;
774
810
  }
775
811
  }, 0);
@@ -790,10 +826,11 @@ export const Grad = ({ text }) => {
790
826
  * never wraps: full, then a shortened provider (`anth claude-sonnet-5-5`), then the model alone, then the
791
827
  * model clipped. Leaves at least four rule cells on the left. Below 30 cols, or no model: ''.
792
828
  */
793
- export function modelRuleLabel(cols, model) {
829
+ export function modelRuleLabel(cols, model, reserve = 0) {
794
830
  const full = String(model || '').trim();
795
831
  if (!full || cols < 30) return '';
796
- const room = cols - 7; // ' label ' + one trailing dash + four leading dashes
832
+ const room = cols - 7 - reserve; // ' label ' + one trailing dash + four leading dashes (+ the approvals indicator)
833
+ if (reserve && room < 6) return '';
797
834
  const fits = (t) => cellWidth(t) <= room; // cells under the ambiguous-width policy (cells.js)
798
835
  if (fits(full)) return full;
799
836
  const segs = full.split(' · ');
@@ -803,7 +840,26 @@ export function modelRuleLabel(cols, model) {
803
840
  const provider = segs.length > 1 ? segs[0] : words.length > 1 ? words[0] : '';
804
841
  if (provider) { const short = `${provider.slice(0, 4)} ${name}`; if (fits(short)) return short; }
805
842
  if (fits(name)) return name;
806
- return clip(name, room);
843
+ return reserve ? '' : clip(name, room); // with the approvals indicator the model is dropped before the indicator is cut
844
+ }
845
+
846
+ /**
847
+ * Pure: the permanent approvals indicator on the bottom rule. `a` = { count, error, signedOut }.
848
+ * Never a false 0: signed out, an unreachable inbox, or a count not read yet is `approvals ?` (warn).
849
+ */
850
+ export function approvalsIndicator(a) {
851
+ if (!a) return null;
852
+ const n = a.count;
853
+ if (a.signedOut || a.error || typeof n !== 'number') return { text: 'approvals ?', role: 'warn' };
854
+ if (n === 0) return { text: '✔ nothing waiting', role: 'ok' };
855
+ return { text: `⏸ ${n} approval${n === 1 ? '' : 's'} waiting`, role: 'warn' };
856
+ }
857
+
858
+ /** Pure: the labels for this width. The indicator sits top-left, the model bottom-right: independent of each other. */
859
+ export function barLabels(cols, model, a) {
860
+ const ind = cols >= 30 ? approvalsIndicator(a) : null;
861
+ if (ind && cols - cellWidth(` ${ind.text} `) - 5 < 4) return { model: modelRuleLabel(cols, model), ind: null }; // no room for the whole indicator
862
+ return { model: modelRuleLabel(cols, model), ind };
807
863
  }
808
864
 
809
865
  /** Pure: the rule split around a right-aligned session name: `───── name ─`. Below 30 cols, or no name: plain. */
@@ -836,10 +892,23 @@ function ringString(str, rgbs, from = 0) {
836
892
  * A composer rule. `ring` (motion only, never under NO_COLOR) = { state, top }: the rule becomes half of the
837
893
  * light ring (motion/ring.js); the session name stays bright on top of it.
838
894
  */
839
- export const Rule = ({ cols, color, name, ring = null, labelColor = null }) => {
895
+ export const Rule = ({ cols, color, name, ring = null, labelColor = null, extra = null }) => {
840
896
  const p = ruleParts(cols, name);
841
897
  // `labelColor` (the model on the bottom rule): its own colour, not bold, so it reads as context, not a title
842
898
  const lab = labelColor ? { color: labelColor } : null;
899
+ if (extra) { // TUI-BAR: the approvals indicator at the LEFT, the session name (if any) at the RIGHT: `─ ✔ nothing waiting ──── name ─`
900
+ const d = cellWidth('─');
901
+ const el = ` ${extra.text} `;
902
+ const nm = name ? ` ${name} ` : '';
903
+ const fill = '─'.repeat(Math.max(1, Math.floor((cols - cellWidth(nm) - cellWidth(el) - 2 * d) / d)));
904
+ const ex = { color: c(extra.role), bold: true };
905
+ if (ring && ring.state && ring.state.k > 0 && !mono()) {
906
+ const rgbs = ruleColors(cols, ring.top, ring.state, COLORS[color] || color);
907
+ const fl = [...fill].length, ln = [...el].length;
908
+ return h(Text, null, ringString('─', rgbs), h(Text, ex, el), ringString(fill, rgbs, 1 + ln), nm ? h(Text, lab || { color: c('violet'), bold: true }, nm) : null, ringString('─', rgbs, 1 + ln + fl + [...nm].length));
909
+ }
910
+ return h(Text, null, h(Text, { color: c(color) }, '─'), h(Text, ex, el), h(Text, { color: c(color) }, fill), nm ? h(Text, lab || { color: c('violet'), bold: true }, nm) : null, h(Text, { color: c(color) }, '─'));
911
+ }
843
912
  if (ring && ring.state && ring.state.k > 0 && !mono()) {
844
913
  const rgbs = ruleColors(cols, ring.top, ring.state, COLORS[color] || color);
845
914
  if (!p.label) return h(Text, null, ringString(p.left, rgbs));
@@ -889,7 +958,13 @@ export function endsInRule({ card = null, asks = [], background = 0, pending = [
889
958
  return Boolean(card);
890
959
  }
891
960
 
892
- export function Composer({ buf, cols, mode, busy, placeholder, name, model = null, ring = null, divider = null, now = 0, topRule = true }) {
961
+ export function Composer({ buf, cols, mode, busy, placeholder, name, model = null, ring = null, divider = null, now = 0, topRule = true, approvals = null }) {
962
+ const bar = barLabels(cols, model, approvals);
963
+ // TUI-BAR (owner 2026-10-23): the indicator once, top-left; the session name top-right (it drops before the indicator
964
+ // is cut); the model bottom-right. The top rule is drawn whenever the indicator is, even above a card's closing rule.
965
+ const topExtra = bar.ind;
966
+ const nameRoom = topExtra ? cols - 4 - cellWidth(` ${topExtra.text} `) - 4 : cols - 8;
967
+ const topName = name && topExtra ? (nameRoom >= 6 ? clip(name, nameRoom) : '') : name;
893
968
  const empty = buf.text === '';
894
969
  const lines = composerLines(buf);
895
970
  const body = empty
@@ -902,10 +977,10 @@ export function Composer({ buf, cols, mode, busy, placeholder, name, model = nul
902
977
  ? h(Text, { color: c('input') }, l.text || ' ')
903
978
  : h(Text, { color: c('input') }, l.before, h(Text, { inverse: true }, l.at), l.after)));
904
979
  return h(Box, { flexDirection: 'column', width: cols },
905
- topRule ? h(Rule, { cols, color: MODE_COLOR[mode] || 'sky', name, ring: ring ? { state: ring, top: true } : null }) : null,
980
+ (topRule || topExtra) ? h(Rule, { cols, color: MODE_COLOR[mode] || 'sky', name: topName, extra: topExtra, labelColor: topExtra ? c('violet') : null, ring: ring ? { state: ring, top: true } : null }) : null,
906
981
  ...body,
907
- (ring && divider && dividerRuleEl({ cols, label: modelRuleLabel(cols, model), divider: divider.d, now, ruleColor: c('dim'), labelColor: modelColor(model), mono: mono(), reduced: cellWidth('─') !== 1, slow: divider.slow }))
908
- || h(Rule, { cols, color: 'dim', name: modelRuleLabel(cols, model), labelColor: modelColor(model), ring: ring ? { state: ring, top: false } : null }));
982
+ (ring && divider && dividerRuleEl({ cols, label: bar.model, extra: null, divider: divider.d, now, ruleColor: c('dim'), labelColor: modelColor(model), mono: mono(), reduced: cellWidth('─') !== 1, slow: divider.slow }))
983
+ || h(Rule, { cols, color: 'dim', name: bar.model, labelColor: modelColor(model), ring: ring ? { state: ring, top: false } : null }));
909
984
  }
910
985
 
911
986
  /** The model label's colour: its provider's brand colour, else the label role (never a grey). */
@@ -948,17 +1023,25 @@ export function WaitingNotice({ items = [], cols }) {
948
1023
  * a rule, a bold title saying who is asking, label/value rows (what a yes does, in full, and why), then the
949
1024
  * "▶ question? keys" footer in the ask colour, and a closing rule. `arming` replaces the footer until keys are live.
950
1025
  */
1026
+ /** The "▶ question? keys" line: the glyph keeps its own 2 cells and a wrapped question hangs under its text, not under the glyph. */
1027
+ const HANG_ASK = /^▶ /;
1028
+ export function AskFoot({ footer }) {
1029
+ const f = String(footer ?? '');
1030
+ if (!HANG_ASK.test(f)) return h(Text, { color: c('prose'), bold: true, wrap: 'wrap' }, ' ', ...runs(paintLine(f, 'ask')));
1031
+ return h(Box, { paddingLeft: 2 }, h(Box, { width: 2, flexShrink: 0 }, h(Text, { bold: true }, ...runs(paintLine('▶ ', 'ask')))), h(Box, { flexGrow: 1, flexShrink: 1 }, h(Text, { color: c('prose'), bold: true, wrap: 'wrap' }, ...runs(paintLine(f.slice(2), 'ask')))));
1032
+ }
1033
+ const footRows = (footer, cols) => (HANG_ASK.test(footer) ? wrapRows(footer.slice(2), cols - 4) : wrapRows(footer, cols - 2));
951
1034
  export function NeedsYouCard({ cols, title, rows = [], block = [], notes = [], footer, arming = null, motionE = null }) {
952
1035
  const e = motionE;
953
1036
  return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
954
1037
  e == null ? h(Rule, { cols, color: 'warn' }) : h(DrawRule, { cols, color: 'warn', e }),
955
1038
  e == null ? h(Text, { color: c('warn'), bold: true, wrap: 'wrap' }, title) : h(Text, null, ...fxNodes([{ t: title, color: 'warn', bold: true }], { e: e - 0.1, timing: TIMING.title }, 23)),
956
1039
  // APPROVAL-WHAT: wrap at word boundaries under the label instead of cutting the action or target off
957
- ...rows.flatMap(([k, v]) => wrapPlain(String(v), Math.max(10, cols - 10)).slice(0, 8).map((line, i) => h(Text, { key: `${k}${i}`, color: c('text') }, ' ', h(Text, { color: c('label') }, (i ? '' : k).padEnd(7)), h(Text, { color: c(k === 'risk' ? 'warn' : 'value') }, line)))),
1040
+ ...rows.flatMap(([k, v], ri) => wrapPlain(String(v), Math.max(10, cols - 10)).slice(0, 8).map((line, i) => h(Text, { key: `${ri}${k}${i}`, color: c('text') }, ' ', h(Text, { color: c('label') }, (i ? '' : k).padEnd(7)), h(Text, { color: c(k === 'risk' ? 'warn' : 'value') }, line)))),
958
1041
  // block: pre-shaped segment rows shown verbatim (a command to run: never word-wrapped, never clipped)
959
1042
  ...block.map((segs, i) => h(Box, { key: `b${i}`, paddingLeft: 2, width: cols }, h(Text, null, ...segs.map((sg, j) => h(Text, { key: j, color: c(sg.role || 'text'), bold: sg.bold || undefined }, sg.t))))),
960
1043
  ...notes.map((n, i) => h(Text, { key: `n${i}`, color: c('warn'), bold: true, wrap: 'wrap' }, ` ${n}`)),
961
- arming || h(Text, { color: c('prose'), bold: true, wrap: 'wrap' }, ' ', ...runs(paintLine(footer, 'ask'))),
1044
+ arming || h(AskFoot, { footer }),
962
1045
  e == null ? h(Rule, { cols, color: 'warn' }) : h(DrawRule, { cols, color: 'warn', e }));
963
1046
  }
964
1047
 
@@ -974,22 +1057,26 @@ export function askCardModel(text) {
974
1057
  export function AskCard({ text, cols }) {
975
1058
  if (!String(text ?? '').replace(/^\s*▶?\s*/, '').trim()) return null; // an empty ask draws nothing: no rules, no blank line
976
1059
  const m = askCardModel(text);
977
- if (m.plain) return h(Box, { width: cols, marginTop: 1 }, h(Text, { color: c('prose'), bold: true, wrap: 'wrap' }, ' ', ...runs(paintLine(m.footer, 'ask'))));
1060
+ if (m.plain) return h(Box, { width: cols, marginTop: 1, flexDirection: 'column' }, h(AskFoot, { footer: m.footer }));
978
1061
  return h(NeedsYouCard, { cols, title: m.title, rows: m.rows, footer: m.footer });
979
1062
  }
980
1063
  /** Rows an AskCard takes (sizes the live region). */
981
1064
  export const askCardRows = (text, cols) => {
982
1065
  if (!String(text ?? '').replace(/^\s*▶?\s*/, '').trim()) return 0;
983
1066
  const m = askCardModel(text);
984
- if (m.plain) return 1 + wrapRows(m.footer, cols - 2);
985
- return 4 + wrapRows(m.title, cols) + m.rows.reduce((n, [, v]) => n + Math.min(8, wrapPlain(String(v), Math.max(10, cols - 10)).length), 0) + wrapRows(m.footer, cols - 2);
1067
+ if (m.plain) return 1 + footRows(m.footer, cols);
1068
+ return 4 + wrapRows(m.title, cols) + m.rows.reduce((n, [, v]) => n + Math.min(8, wrapPlain(String(v), Math.max(10, cols - 10)).length), 0) + footRows(m.footer, cols);
986
1069
  };
987
1070
 
988
1071
  export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE = null, confirm = false, position = null }) {
989
1072
  const when = card.askedAt ? String(card.askedAt).replace('T', ' ').slice(0, 16) + ' UTC' : null;
990
1073
  const rows = card.undescribable ? [['what', card.what], ['from', card.askedBy || card.origin], ['asked', when], ['org', org]].filter(r => r[1]) : [
991
- ['what', card.what && card.what !== card.tool ? card.what : null], ['tool', card.tool], ['target', card.target], ['scope', card.scope], ['risk', card.risk], ['from', card.foreign ? card.origin : null], ['org', org]
1074
+ ['what', card.what && card.what !== card.tool ? card.what : null], ['tool', card.tool], ['target', card.target], ['scope', card.scope], ['risk', card.risk], ['from', card.foreign ? card.origin : null], ['org', card.plan && (card.plan.orgName || card.plan.orgId) ? orgLabel(card.plan.orgName, card.plan.orgId) : org]
992
1075
  ].filter(r => r[1]);
1076
+ if (card.plan) { // the plan itself, so the approver sees what they are approving
1077
+ if (card.plan.lines.length) card.plan.lines.forEach((l, i) => rows.push([i ? '' : 'plan', l]));
1078
+ else rows.push(['plan', card.plan.loaded ? 'this objective has no steps recorded' : 'could not load the plan steps, deny or check `quox objective get` first']);
1079
+ }
993
1080
  if (card.privileged) rows.push(['grants', `PRIVILEGED: ${[card.tool, card.target].filter(Boolean).join(' ')}`]);
994
1081
  // APPROVAL-WWWH: what exactly (verbatim), where, who, why, when, read-only or write. The command is a block: never word-wrapped.
995
1082
  const f = card.facts || null; const block = [];
@@ -1009,7 +1096,7 @@ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE
1009
1096
  : draftHint === 'focus' ? askText('Approve it?', { keys: 'y approve · n deny · card focused (Esc returns to your draft)' }) // RESTORE-TUI: every y/n goes through askText (same text)
1010
1097
  : draftHint === 'draft' ? askText('Approve it?', { keys: 'y/n answer once your draft is empty · Tab to answer the approval card (your draft is kept)' })
1011
1098
  : '▶ Approve it? y approve · n deny · c chat · Tab later · /approvals all';
1012
- return h(NeedsYouCard, { cols, title, rows, block, notes: card.undescribable && !confirm ? ['Quox could not describe this action'] : [], footer, arming: armed ? null : arming, motionE: e });
1099
+ return h(NeedsYouCard, { cols, title, rows, block, notes: [...(card.undescribable && !confirm ? ['Quox could not describe this action'] : []), ...(position && position.others > 0 ? [`other sessions: ${position.others} waiting · /approvals`] : [])], footer, arming: armed ? null : arming, motionE: e });
1013
1100
  }
1014
1101
 
1015
1102
  /** Columns the status line's Q takes: 2 for the Braille glyph, 1 for the Ⓠ fallback (2 where Ambiguous is wide). */
@@ -1046,8 +1133,10 @@ export function statusRows({ cols, slot, agent, groups = [], parts, hint, qw = s
1046
1133
  // every context item says what it is (owner: "profile moo .. ask?? what?"); /help status explains each in a line
1047
1134
  const word = { profile: 'profile ', org: 'org ', mode: 'mode ' };
1048
1135
  const val = (k) => k === 'mode' ? (MODE_WORDS[p.mode] || null) : k === 'auto' ? '↻ auto-restart' : String(p[k]);
1049
- for (const k of ['profile', 'org', 'mode', 'rules', 'auto'].filter(k => p[k])) tokens.push({ pre: ' · ',
1050
- segs: word[k] && val(k) ? [[word[k], 'label'], [val(k), k === 'mode' && p.mode === 'plan' ? 'violet' : 'value']] : [[k === 'mode' ? String(p[k]) : val(k), 'label']] });
1136
+ for (const k of ['profile', 'org', 'server', 'remote', 'mode', 'rules', 'auto'].filter(k => p[k])) tokens.push({ pre: ' · ',
1137
+ segs: k === 'server' ? [['⬡ ', 'icon'], ['server ', 'label'], [p.server.name, 'value'], ...(p.server.role ? [[` (${p.server.role})`, 'label']] : [])]
1138
+ : k === 'remote' ? [['→ ', 'icon'], [String(p.remote), 'value']]
1139
+ : word[k] && val(k) ? [[word[k], 'label'], [val(k), k === 'mode' && p.mode === 'plan' ? 'violet' : 'value']] : [[k === 'mode' ? String(p[k]) : val(k), 'label']] });
1051
1140
  const tlen = (tk) => tk.segs.reduce((n, sg) => n + len(sg[0]), 0);
1052
1141
  // split a token too wide for a row into row-sized pieces (whole characters by cell width, never an ellipsis)
1053
1142
  const fit = (tk) => {
@@ -1097,20 +1186,21 @@ export function statusRows({ cols, slot, agent, groups = [], parts, hint, qw = s
1097
1186
  }
1098
1187
 
1099
1188
  /** QUOXON-FW17: custom rows, one truncated line each, just above the status line. */
1100
- export function RowsBar({ rows, cols }) {
1189
+ export function RowsBar({ rows, cols, limit }) {
1101
1190
  if (!rows?.length) return null;
1102
- const shown = rows.slice(0, 3);
1103
- const labelW = labelWidth(shown); // labels line up across rows
1191
+ // TUI-QPE: layoutRows fits cells rows by keep (whole items drop, never mid-word), lines up a grid's columns and
1192
+ // leaves out rows below their min_cols; plain rows take the classic rowParts + clipParts path unchanged.
1193
+ // /qpe N keeps the first N lines of a grid; it applies to drawn lines (a row can bring several), so expand first.
1194
+ const laid = layoutRows(limit ? limitGrid(expandLines(rows), limit.grid, limit.n) : rows, cols);
1195
+ if (!laid.length) return null;
1104
1196
  return h(Box, { flexDirection: 'column', width: cols },
1105
- ...shown.map((r) => {
1106
- const parts = clipParts(rowParts(r, { labelW }), Math.max(1, cols - 2));
1197
+ ...laid.map(({ st: r, parts }) =>
1107
1198
  // ROWS: label, value, tone and trend as their own runs; every role is a palette colour (no grey, owner rule).
1108
- return h(Text, { key: r.row.id, wrap: 'truncate-end' }, ' ', ...parts.map(([t, role, bold], i) => h(Text, { key: i, color: c(themeRole(role)), bold: Boolean(bold) }, t)));
1109
- }));
1199
+ h(Text, { key: r.row.id, wrap: 'truncate-end' }, ' ', ...parts.map(([t, role, bold], i) => h(Text, { key: i, color: c(themeRole(role)), bold: Boolean(bold) }, t)))));
1110
1200
  }
1111
1201
 
1112
1202
  export function StatusLine({ cols, slot, agent, groups = [], parts, right }) {
1113
- const p = Array.isArray(parts) ? { profile: parts[0], org: parts[1], model: parts[2], mode: parts[3], rules: parts[4], auto: parts[5] } : parts;
1203
+ const p = Array.isArray(parts) ? { profile: parts[0], org: parts[1], model: parts[2], mode: parts[3], rules: parts[4], auto: parts[5], server: parts[6], remote: parts[7] } : parts;
1114
1204
  const qw = statusGlyphWidth();
1115
1205
  const rows = statusRows({ cols, slot, agent, groups, parts: p, hint: right, qw });
1116
1206
  // The still Q (qglyph.js): two Braille cells, or Ⓠ under NO_COLOR / no Braille. statusRows reserved its width.
@@ -1131,7 +1221,13 @@ function hintNodes(hint) {
1131
1221
  }
1132
1222
 
1133
1223
  /** Live, actionable counts under the status line. Never a transcript message. */
1134
- export function NotifyBar({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false }) {
1224
+ export function NotifyBar({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false, inbox = '' }) {
1225
+ const base = NotifyBarBase({ approvals, error, cols, signedOut, alerts, hideCount });
1226
+ if (!inbox || signedOut) return base;
1227
+ const line = h(Text, { key: 'inbox', color: c('ask'), bold: true }, ' ' + clip(inbox, cols - 3)); // HITL-INBOX-BAR: 📨 N new · newest title
1228
+ return base ? h(Box, { flexDirection: 'column' }, base, line) : line;
1229
+ }
1230
+ function NotifyBarBase({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false }) {
1135
1231
  if (signedOut) return h(Text, { color: c('warn') }, ' ' + clip('not signed in: approvals unavailable until you sign in', cols - 2));
1136
1232
  const count = approvals && !hideCount ? h(Text, { key: 'n', color: c('ask'), bold: true }, ' ' + clip(`▶ ${approvals} approval${approvals === 1 ? '' : 's'} waiting for you${error ? ' (last known)' : ''} · /approvals`, cols - 3)) : null;
1137
1233
  // Background workflows that keep failing: one deduped line each (max 2), never roster rows.
@@ -1155,13 +1251,14 @@ export function Roster({ view, peek = [], confirm = null, hint = null, cols, fx
1155
1251
  // glyph keeps the state colour; the rest of the line steps down in tone with position and (for finished rows) age
1156
1252
  ...view.lines.map((l, i) => {
1157
1253
  const e = fx && l.key != null ? fx(l.key) : null; // a row that just appeared decodes in (new rows land on top)
1158
- return h(Text, { key: 'r' + i, bold: l.selected, wrap: 'truncate-end' },
1254
+ const row = h(Text, { key: 'r' + i, bold: l.selected, wrap: 'truncate-end' },
1159
1255
  h(Text, { color: c(l.selected ? 'text' : (l.glyphRole || ROSTER_TONE[l.state])) }, ' ' + l.marker + l.glyph),
1160
1256
  e == null ? h(Text, { color: c(l.selected ? 'text' : l.tone) }, l.rest) : h(Text, null, ...fxNodes([{ t: l.rest, color: l.selected ? 'text' : l.tone }], { e, timing: TIMING.roster }, 29 + i)),
1161
1257
  l.pulse ? h(Text, { color: c(l.pulseRole === 'dim' && l.selected ? 'text' : l.pulseRole) }, l.pulse) : null);
1258
+ return l.extra ? h(Box, { key: 'rx' + i, flexDirection: 'column' }, row, ...l.extra.map((t, j) => h(Text, { key: j, color: c('error') }, ' ' + t))) : row; // the stop reason, whole and wrapped
1162
1259
  }),
1163
1260
  view.more ? h(Text, { key: 'more', color: c('dim') }, ` +${view.more} more · /roster`) : null,
1164
- ...peek.map((p, i) => h(Text, { key: 'p' + i, color: c('dim'), wrap: 'truncate-end' }, ' ' + clip(p, cols - 5))),
1261
+ ...peek.flatMap((p, i) => wrapText(p, cols - 5).map((t, j) => h(Text, { key: `p${i}.${j}`, color: c('dim') }, (j ? ' ' : ' ') + t))), // QLOOP-LIVE: a peek line wraps, it is never cut
1165
1262
  confirm ? h(Text, { key: 'cf', color: c('warn') }, ' ' + clip(confirm, cols - 2)) : null,
1166
1263
  hint ? h(Text, { key: 'h', color: c('dim') }, ' ' + clip(hint, cols - 2)) : null);
1167
1264
  }
@@ -5,10 +5,11 @@
5
5
  * nothing (never a placeholder row).
6
6
  */
7
7
  import { clip } from './theme.js';
8
+ import { humanReason, wrapText } from './loopCard.js';
8
9
 
9
10
  export const GLYPH = { running: '◐', waiting: '⏸', done: '✔', failed: '✖' };
10
11
  /** A row that ended without finishing says HOW: failed is the only error; the rest are neutral (no error colour). */
11
- export const OUTCOME_GLYPH = { failed: '✖', cancelled: '○', expired: '○', rejected: '⊘' };
12
+ export const OUTCOME_GLYPH = { failed: '✖', halted: '⚠', cancelled: '○', expired: '○', rejected: '⊘' };
12
13
  export const FRESH_MS = 10 * 60 * 1000; // finished rows linger this long
13
14
  export const USER_DONE_MS = 20 * 1000; // a user-started completion vanishes quickly: rows come and go
14
15
  export const PENDING_FRESH_MS = 24 * 3600 * 1000; // an older "pending" is stale, not waiting
@@ -42,13 +43,17 @@ const FAILED = new Set(['failed', 'error', 'halted', 'cancelled', 'canceled', 't
42
43
  /** The word for how an ended row ended (state stays 'failed' for the ended-badly group; this says which). */
43
44
  export function outcomeOf(status) {
44
45
  const st = String(status || '').toLowerCase();
46
+ if (st === 'halted') return 'halted';
45
47
  if (st === 'cancelled' || st === 'canceled') return 'cancelled';
46
48
  if (st === 'expired') return 'expired';
47
49
  if (st === 'rejected') return 'rejected';
48
50
  return 'failed';
49
51
  }
50
52
  /** Word, glyph and whether it is an error, for a row. Non-failed states return their own state. */
51
- export function endWord(r) { return r.state === 'failed' ? outcomeOf(r.status) : r.state; }
53
+ export function endWord(r) { return r.state === 'failed' ? (r.outcome || outcomeOf(r.status)) : r.state; }
54
+ /** A human-halted loop keeps objective status 'failed'; the server says loop_state 'halted' (older servers: the halt text). */
55
+ const HALT_TEXT = /^Loop (?:halted on demand|stopped by human review)/i;
56
+ const isHaltedLoop = (o) => o.execution_mode === 'loop' && (o.loop_state === 'halted' || String(o.status || '').toLowerCase() === 'halted' || (String(o.status || '').toLowerCase() === 'failed' && HALT_TEXT.test(String(o.error_message || ''))));
52
57
  export const glyphOf = (r) => (r.state === 'failed' ? OUTCOME_GLYPH[endWord(r)] : GLYPH[r.state]);
53
58
 
54
59
  /** How long a finished row lingers before it leaves the roster (null while running/waiting). */
@@ -83,18 +88,22 @@ export function fromObjective(o, now = Date.now()) {
83
88
  if (!state) return null;
84
89
  const loop = o.execution_mode === 'loop';
85
90
  let activity = '';
86
- if (state === 'failed' && o.error_message) activity = String(o.error_message);
91
+ const haltedLoop = state === 'failed' && isHaltedLoop(o);
92
+ // a halted loop reads its clean sentence (the server's loop_reason, with the person's name), never the raw record text
93
+ const shownError = !haltedLoop ? o.error_message : (o.loop_reason || ((n) => (n ? `Stopped by ${n[1].trim()}` : 'Halted on demand'))(/^Loop halted on demand by \S+ \(([^)]{1,80})\)/i.exec(String(o.error_message || ''))));
94
+ if (state === 'failed' && shownError) activity = humanReason(shownError); // the sentence whole, minus the raw spawner prefix; the view decides how to show it
95
+ else if (loop && state === 'done') activity = o.iteration_count > 0 ? `${o.iteration_count} turn${o.iteration_count === 1 ? '' : 's'}` : ''; // the row prints "done <age>" itself; a stale "iter 0/5" beside it was the lie
87
96
  else if (loop && o.max_iterations) activity = `iter ${o.iteration_count || 0}/${o.max_iterations}`;
88
97
  else if (o.task_count) activity = `${o.tasks_completed || 0}/${o.task_count} tasks`;
89
98
  else if (state === 'waiting') activity = 'waiting to start';
90
99
  const from = startedAt ?? createdAt;
91
100
  return {
92
101
  key: `objective:${o.id}`, id: o.id, source: 'objectives', background: false, kind: loop ? 'loop' : 'objective', state,
93
- name: o.title || o.id, activity, status: o.status,
102
+ name: o.title || o.id, activity, status: o.status, ...(haltedLoop ? { outcome: 'halted' } : {}),
94
103
  startedAt: from, finishedAt, ageMs: from != null ? (state === 'done' || state === 'failed' ? (finishedAt ?? now) - from : now - from) : null,
95
104
  expiryMs: expiryFor(state, USER_DONE_MS),
96
105
  progressAt: latest(parseTime(o.updated_at), parseTime(o.last_progress_at), parseTime(o.last_heartbeat_at)),
97
- detail: { description: o.description, error: o.error_message, tokens: o.tokens_used, toolCalls: o.tool_calls_used }
106
+ detail: { description: o.description, error: shownError ? humanReason(shownError) : shownError, tokens: o.tokens_used, toolCalls: o.tool_calls_used }
98
107
  };
99
108
  }
100
109
 
@@ -235,7 +244,7 @@ export function countsLine(rows) {
235
244
  const n = (s) => rows.filter(r => r.state === s).length;
236
245
  const o = (w) => rows.filter(r => r.state === 'failed' && endWord(r) === w).length;
237
246
  return [['running', 'running', n('running'), GLYPH.running], ['waiting', 'waiting', n('waiting'), GLYPH.waiting],
238
- ['failed', 'failed', o('failed'), OUTCOME_GLYPH.failed], ['cancelled', 'cancelled', o('cancelled'), OUTCOME_GLYPH.cancelled],
247
+ ['failed', 'failed', o('failed'), OUTCOME_GLYPH.failed], ['halted', 'halted', o('halted'), OUTCOME_GLYPH.halted], ['cancelled', 'cancelled', o('cancelled'), OUTCOME_GLYPH.cancelled],
239
248
  ['expired', 'expired', o('expired'), OUTCOME_GLYPH.expired], ['rejected', 'rejected', o('rejected'), OUTCOME_GLYPH.rejected], ['done', 'finished', n('done'), GLYPH.done]]
240
249
  .filter(([, , k]) => k).map(([, w, k, g]) => `${g} ${k} ${w}`).join(' · ');
241
250
  }
@@ -261,6 +270,9 @@ export function collapseApprovals(rows, now = Date.now()) {
261
270
  return rows.flatMap(r => (aps.includes(r) ? (placed ? [] : (placed = true, [one])) : [r]));
262
271
  }
263
272
 
273
+ const KIND_ICON = { loop: '∞', objective: '◎' }; // QuoxGlyph registry keys qloop / objective
274
+ export const kindLabel = k => (KIND_ICON[k] ? `${KIND_ICON[k]} ${k}` : k);
275
+
264
276
  export function rosterView({ rows, cols, termRows = 40, max = MAX_ROWS, on = true, sel = 0, focus = false, now = Date.now(), motion = false }) {
265
277
  if (!on || !rows.length) return { mode: 'hidden', lines: [], more: 0 };
266
278
  if (!focus) rows = collapseApprovals(rows, now);
@@ -269,13 +281,14 @@ export function rosterView({ rows, cols, termRows = 40, max = MAX_ROWS, on = tru
269
281
  }
270
282
  const shown = rows.slice(0, max);
271
283
  const ageW = Math.max(5, ...shown.map(r => [...ageText(r, now)].length));
272
- let pos = 0;
273
- const kindW = Math.min(9, Math.max(...shown.map(r => r.kind.length)));
284
+ let pos = 0, reasonShown = false;
285
+ const kindW = Math.min(11, Math.max(...shown.map(r => [...kindLabel(r.kind)].length)));
274
286
  const lines = shown.map((r, i) => {
275
287
  const el = ageText(r, now).padStart(ageW);
276
288
  const tone = toneName(r, pos, now); if (r.kind !== 'approval') pos++;
277
289
  const actW = Math.min(18, Math.max(0, ...shown.map(x => [...(x.activity || '')].length)));
278
- const act = clip(r.activity || '', actW).padEnd(actW);
290
+ const longReason = r.state === 'failed' && [...(r.activity || '')].length > actW; // a reason is never cut: the column says "stopped", the sentence follows wrapped
291
+ const act = (longReason ? clip('stopped', actW) : clip(r.activity || '', actW)).padEnd(actW);
279
292
  const fixed = 1 + 1 + 1 + kindW + 1 + 1 + actW + 1 + ageW + 2; // pulse column (space + glyph); marker glyph kind name act elapsed
280
293
  const nameW = Math.max(8, cols - 2 - fixed - 1);
281
294
  const name = clip(r.name, nameW).padEnd(nameW);
@@ -286,9 +299,10 @@ export function rosterView({ rows, cols, termRows = 40, max = MAX_ROWS, on = tru
286
299
  const text = room >= 12 ? `${head}${clip(r.newestTitle, room)}${tail}` : clip(`${r.count} approvals waiting · oldest ${elapsed(r.oldestMs)}${tail}`, cols - 4); // too narrow for a title
287
300
  return { key: r.key, kind: 'approval', state: r.state, tone: toneName(r, pos, now), selected: false, marker: ' ', glyph: GLYPH[r.state], rest: ' ' + text, pulse: '', pulseRole: 'dim', pulseKind: 'none', text: ` ${GLYPH[r.state]} ${text}` };
288
301
  }
289
- const rest = `${r.kind.padEnd(kindW)} ${name} ${act} ${el}`;
302
+ const rest = `${kindLabel(r.kind).padEnd(kindW)} ${name} ${act} ${el}`;
290
303
  const p = pulseOf(r, now, { motion });
291
- return { key: r.key, kind: r.kind, state: r.state, tone, selected: focus && i === sel, marker, glyph: glyphOf(r), glyphRole: r.state === 'failed' && endWord(r) !== 'failed' ? 'dim' : null, rest: ' ' + rest, pulse: ' ' + p.glyph, pulseRole: p.role, pulseKind: p.kind, text: `${marker}${glyphOf(r)} ${rest} ${p.glyph}` };
304
+ const extra = longReason && !reasonShown ? (reasonShown = true, wrapText(r.activity, Math.max(20, cols - 8))) : null; // spelled out for the newest stopped row only: the bar must not grow with every old failure
305
+ return { key: r.key, kind: r.kind, state: r.state, tone, extra, selected: focus && i === sel, marker, glyph: glyphOf(r), glyphRole: r.state === 'failed' && endWord(r) === 'halted' ? 'warn' : r.state === 'failed' && endWord(r) !== 'failed' ? 'dim' : null, rest: ' ' + rest, pulse: ' ' + p.glyph, pulseRole: p.role, pulseKind: p.kind, text: `${marker}${glyphOf(r)} ${rest} ${p.glyph}` };
292
306
  });
293
307
  return { mode: 'full', lines, more: Math.max(0, rows.length - shown.length) };
294
308
  }
@@ -302,6 +316,6 @@ export function peekLines(row, now = Date.now()) {
302
316
  if (d.description) out.push(`goal ${d.description}`);
303
317
  if (d.workflowId) out.push(`workflow ${d.workflowId}`);
304
318
  if (d.trigger) out.push(`trigger ${d.trigger}`);
305
- if (d.error && row.state === 'failed') out.push(`error ${typeof d.error === 'string' ? d.error : JSON.stringify(d.error)}`);
319
+ if (d.error && row.state === 'failed') out.push(`error ${typeof d.error === 'string' ? humanReason(d.error) : JSON.stringify(d.error)}`);
306
320
  return out;
307
321
  }