quoxcli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/admin.js +7 -0
  6. package/lib/api/endpoints/agent-attach.js +7 -0
  7. package/lib/api/endpoints/compliance.js +25 -0
  8. package/lib/api/endpoints/inbox.js +6 -0
  9. package/lib/api/endpoints/inboxq.js +19 -0
  10. package/lib/api/endpoints/index.js +2 -0
  11. package/lib/api/endpoints/mon.js +122 -0
  12. package/lib/api/endpoints/tools.js +13 -0
  13. package/lib/api/endpoints/v1.js +13 -0
  14. package/lib/commands/admin.js +35 -0
  15. package/lib/commands/agentAttach.js +15 -0
  16. package/lib/commands/approvals.js +193 -3
  17. package/lib/commands/ask.js +1 -1
  18. package/lib/commands/auth.js +64 -11
  19. package/lib/commands/backup.js +11 -4
  20. package/lib/commands/capabilities.js +3 -2
  21. package/lib/commands/chat.js +15 -0
  22. package/lib/commands/claude.js +4 -1
  23. package/lib/commands/compliance.js +109 -0
  24. package/lib/commands/connect.js +69 -0
  25. package/lib/commands/dev.js +6 -8
  26. package/lib/commands/doctor.js +22 -3
  27. package/lib/commands/evidence.js +2 -1
  28. package/lib/commands/gripe.js +24 -13
  29. package/lib/commands/inbox.js +37 -3
  30. package/lib/commands/inboxq.js +147 -0
  31. package/lib/commands/kuma.js +78 -5
  32. package/lib/commands/licenceLinkView.js +311 -0
  33. package/lib/commands/linkedin.js +19 -0
  34. package/lib/commands/loop.js +30 -3
  35. package/lib/commands/lucid.js +1 -0
  36. package/lib/commands/mail.js +161 -20
  37. package/lib/commands/memory.js +68 -4
  38. package/lib/commands/memorySync.js +158 -0
  39. package/lib/commands/mon.js +55 -144
  40. package/lib/commands/notes.js +29 -7
  41. package/lib/commands/notesSeal.js +259 -0
  42. package/lib/commands/objective.js +5 -2
  43. package/lib/commands/offload.js +2 -3
  44. package/lib/commands/plan.js +17 -0
  45. package/lib/commands/provider.js +29 -8
  46. package/lib/commands/qlarity.js +32 -1
  47. package/lib/commands/run.js +2 -1
  48. package/lib/commands/server.js +37 -0
  49. package/lib/commands/setup.js +127 -75
  50. package/lib/commands/skills.js +4 -1
  51. package/lib/commands/slot.js +49 -9
  52. package/lib/commands/status.js +92 -44
  53. package/lib/commands/tool.js +30 -0
  54. package/lib/commands/vault.js +6 -3
  55. package/lib/commands/walkthrough.js +14 -2
  56. package/lib/commands/watch.js +11 -2
  57. package/lib/commands/wazuh.js +824 -0
  58. package/lib/commands/wazuhIntake.js +317 -0
  59. package/lib/commands/wazuhReport.js +192 -0
  60. package/lib/commands/workflow.js +47 -4
  61. package/lib/commands/x.js +50 -0
  62. package/lib/core/announce.js +86 -0
  63. package/lib/core/auth.js +20 -2
  64. package/lib/core/callMe.js +118 -0
  65. package/lib/core/client.js +27 -1
  66. package/lib/core/commandRegistrars.js +8 -0
  67. package/lib/core/config.js +28 -2
  68. package/lib/core/confirm.js +68 -7
  69. package/lib/core/crossOrgApprovals.js +69 -0
  70. package/lib/core/errors.js +52 -1
  71. package/lib/core/governedSession.js +135 -5
  72. package/lib/core/inboxResolution.js +3 -2
  73. package/lib/core/loginGuard.js +73 -0
  74. package/lib/core/offload.js +37 -0
  75. package/lib/core/org-override.js +5 -1
  76. package/lib/core/output.js +1 -1
  77. package/lib/core/profileTrust.js +182 -0
  78. package/lib/core/quoxglyph.js +22 -1
  79. package/lib/core/router.js +14 -0
  80. package/lib/core/stepUpPrompt.js +99 -0
  81. package/lib/core/version.js +1 -1
  82. package/lib/core/walkthrough.js +9 -5
  83. package/lib/dragon/select.js +2 -2
  84. package/lib/quoxon/EXTENSIONS.md +142 -19
  85. package/lib/quoxon/api.js +185 -34
  86. package/lib/quoxon/app.js +1032 -152
  87. package/lib/quoxon/approvalBatch.js +117 -0
  88. package/lib/quoxon/approvalFacts.js +56 -2
  89. package/lib/quoxon/approvalSentence.js +8 -4
  90. package/lib/quoxon/approvalsView.js +69 -11
  91. package/lib/quoxon/autoOpen.js +94 -0
  92. package/lib/quoxon/backgroundWork.js +59 -0
  93. package/lib/quoxon/bang.js +1 -1
  94. package/lib/quoxon/capabilities.js +7 -2
  95. package/lib/quoxon/commandShort.js +7 -0
  96. package/lib/quoxon/commands.js +40 -10
  97. package/lib/quoxon/connect.js +5 -3
  98. package/lib/quoxon/deckLazy.js +58 -0
  99. package/lib/quoxon/destructive.js +35 -0
  100. package/lib/quoxon/dnd.js +71 -0
  101. package/lib/quoxon/dragonCompanion.js +37 -7
  102. package/lib/quoxon/dragonMoments.js +78 -0
  103. package/lib/quoxon/echo.js +1 -0
  104. package/lib/quoxon/events.js +43 -7
  105. package/lib/quoxon/extensions.js +398 -38
  106. package/lib/quoxon/gripeQueue.js +135 -0
  107. package/lib/quoxon/inboxView.js +125 -22
  108. package/lib/quoxon/keys.js +2 -2
  109. package/lib/quoxon/loopCard.js +5 -1
  110. package/lib/quoxon/loopCards.js +17 -8
  111. package/lib/quoxon/loopStream.js +20 -9
  112. package/lib/quoxon/mailView.js +71 -0
  113. package/lib/quoxon/main.js +20 -5
  114. package/lib/quoxon/markdown.js +1 -1
  115. package/lib/quoxon/model.js +15 -0
  116. package/lib/quoxon/neverAnswered.js +20 -0
  117. package/lib/quoxon/objects/away.js +43 -5
  118. package/lib/quoxon/onboarding/connectServer.js +162 -0
  119. package/lib/quoxon/onboarding/copy.js +6 -0
  120. package/lib/quoxon/onboarding/index.js +5 -43
  121. package/lib/quoxon/onboarding/probe.js +20 -1
  122. package/lib/quoxon/onboarding/view.js +16 -3
  123. package/lib/quoxon/orgSwitch.js +22 -0
  124. package/lib/quoxon/print.js +2 -0
  125. package/lib/quoxon/profileSwitch.js +7 -0
  126. package/lib/quoxon/promptHistory.js +3 -1
  127. package/lib/quoxon/render.js +63 -14
  128. package/lib/quoxon/roster.js +10 -5
  129. package/lib/quoxon/rosterSessions.js +44 -0
  130. package/lib/quoxon/screenContext.js +1 -1
  131. package/lib/quoxon/secretShape.js +86 -0
  132. package/lib/quoxon/slash/agents.js +2 -1
  133. package/lib/quoxon/slash/argSources.js +9 -2
  134. package/lib/quoxon/slash/args.js +4 -2
  135. package/lib/quoxon/slash/broadcast.js +18 -5
  136. package/lib/quoxon/slash/broadcastRun.js +13 -2
  137. package/lib/quoxon/slash/cliLeaves.json +63 -1
  138. package/lib/quoxon/slash/hatWalk.js +1 -1
  139. package/lib/quoxon/slash/palette.js +22 -1
  140. package/lib/quoxon/slash/paletteController.js +10 -4
  141. package/lib/quoxon/slash/restart.js +7 -4
  142. package/lib/quoxon/slash/stepUp.js +5 -5
  143. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  144. package/lib/quoxon/slashSpinner.js +35 -0
  145. package/lib/quoxon/slotAttach.js +8 -0
  146. package/lib/quoxon/slotChat.js +17 -0
  147. package/lib/quoxon/slotFormat.js +1 -1
  148. package/lib/quoxon/slotOutbox.js +113 -0
  149. package/lib/quoxon/suggestCommand.js +1 -1
  150. package/lib/quoxon/term.js +25 -1
  151. package/lib/quoxon/transcript.js +2 -1
  152. package/lib/quoxon/welcome.js +1 -1
  153. package/lib/quoxon/whatsnew.js +1 -1
  154. package/lib/quoxon/whatsnew.jsonl +36 -0
  155. package/lib/server/installFlow.js +168 -0
  156. package/lib/server/status.js +15 -1
  157. package/lib/toolOutputShape.js +55 -0
  158. package/package.json +3 -2
  159. package/vendor/dragonlab/SOURCE +3 -3
  160. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  161. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  162. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  163. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  164. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  165. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  166. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  167. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  168. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  169. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  170. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  171. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  172. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  173. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  174. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  175. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -6,6 +6,7 @@
6
6
  import { readFileSync, appendFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
8
8
  import { join, dirname } from 'node:path';
9
+ import { redactSecrets } from './secretShape.js';
9
10
 
10
11
  export const HISTORY_CAP = 500;
11
12
  export const defaultHistoryPath = (home = homedir()) => join(home, '.quox', 'history.jsonl');
@@ -22,7 +23,8 @@ export function fileHistory(path = defaultHistoryPath(), { cap = HISTORY_CAP } =
22
23
  return out.slice(-cap);
23
24
  } catch { return []; }
24
25
  };
25
- const add = (text) => {
26
+ const add = (text0) => {
27
+ const text = redactSecrets(text0); // SECRET-GUARD: a value never reaches the file
26
28
  try {
27
29
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
28
30
  appendFileSync(path, JSON.stringify({ t: text }) + '\n', { mode: 0o600 });
@@ -30,6 +30,7 @@ import { Markdown, wrapCell, markdownRows, wrapRows, WText, Heading } from './ma
30
30
  import { parseAsk, keysLabel, askText } from './ask.js';
31
31
  import { parseAsideNote } from './asideView.js';
32
32
  import { formatSlotBody, inlineable, codeRows } from './slotFormat.js';
33
+ import { BACKGROUND_NOTE } from './backgroundWork.js';
33
34
  import { termRows } from './term.js';
34
35
  import { cellWidth, ambiguousWide } from './cells.js';
35
36
  import { welcomeModel } from './welcome.js';
@@ -733,10 +734,12 @@ function TipView({ item, cols }) {
733
734
  const body = wrapPlain(item.text, inner).map((l, i) => row(`t${i}`, [h(Text, { key: 0, color: c('prose') }, l)], [...l].length));
734
735
  const cmdText = clip(item.cmd, inner - 5);
735
736
  const tryRow = row('try', [h(Text, { key: 0, color: c('prose'), bold: true }, 'Try: '), h(Text, { key: 1, color: c('command') }, cmdText)], 5 + [...cmdText].length);
736
- const hintRow = row('hint', runs(paintRuns(TIP_HINT, 'quiet')), [...stripMarks(TIP_HINT)].length);
737
+ const hintPlain = stripMarks(TIP_HINT); // too long for the frame: wrap it inside, never let the terminal wrap the border
738
+ const hintRows = [...hintPlain].length <= inner ? [row('hint', runs(paintRuns(TIP_HINT, 'quiet')), [...hintPlain].length)]
739
+ : wrapPlain(hintPlain, inner).map((l, i) => row(`hint${i}`, [h(Text, { key: 0, color: c('quiet') }, l)], [...l].length));
737
740
  return h(Box, { paddingLeft: 2, flexDirection: 'column' },
738
741
  h(Text, edge, '╭─ ', h(Text, { bold: true }, 'Tip'), ' ', '─'.repeat(W - 8), '╮'),
739
- ...body, tryRow, hintRow,
742
+ ...body, tryRow, ...hintRows,
740
743
  h(Text, edge, '╰', '─'.repeat(W - 2), '╯'));
741
744
  }
742
745
 
@@ -764,9 +767,13 @@ function WhatsNewView({ item, cols }) {
764
767
  return out;
765
768
  });
766
769
  if (more) rows.push(row('more', [h(Text, { key: 0, color: c('prose') }, lead), h(Text, { key: 1, color: c('prose'), bold: true }, 'Try: '), h(Text, { key: 2, color: c('command') }, '/whatsnew')], [...more].length));
770
+ // a head that does not fit in the top edge (the compact "N new commands (...)" line at a narrow width) goes inside the
771
+ // frame, wrapped, under a bare top edge: the terminal must never be the one to wrap a border row
772
+ const headFits = [...HEAD].length <= W - 6;
773
+ if (!headFits) rows.unshift(...wrapPlain(HEAD, inner).map((l, i) => row(`h${i}`, [h(Text, { key: 0, color: c('prose'), bold: true }, l)], [...l].length)));
767
774
  if (item.pad) { rows.unshift(row('p0', [], 0)); rows.push(row('p1', [], 0)); }
768
775
  return h(Box, { paddingLeft: 2, flexDirection: 'column' },
769
- h(Text, edge, '╭─ ', h(Text, { bold: true }, HEAD), ' ', '─'.repeat(Math.max(1, W - 5 - HEAD.length)), '╮'),
776
+ headFits ? h(Text, edge, '╭─ ', h(Text, { bold: true }, HEAD), ' ', '─'.repeat(W - 5 - [...HEAD].length), '╮') : h(Text, edge, '╭', '─'.repeat(W - 2), '╮'),
770
777
  ...rows,
771
778
  h(Text, edge, '╰', '─'.repeat(W - 2), '╯'));
772
779
  }
@@ -843,6 +850,12 @@ export function modelRuleLabel(cols, model, reserve = 0) {
843
850
  return reserve ? '' : clip(name, room); // with the approvals indicator the model is dropped before the indicator is cut
844
851
  }
845
852
 
853
+ /** Pure: a failed approvals poll goes stale (footer `?`) only after 3 consecutive misses or 60 s without a good read. */
854
+ export function approvalsPollStale({ fails, lastOkAt, now }) {
855
+ if (!(fails > 0)) return false;
856
+ return fails >= 3 || (typeof lastOkAt === 'number' && now - lastOkAt > 60000);
857
+ }
858
+
846
859
  /**
847
860
  * Pure: the permanent approvals indicator on the bottom rule. `a` = { count, error, signedOut }.
848
861
  * Never a false 0: signed out, an unreachable inbox, or a count not read yet is `approvals ?` (warn).
@@ -850,9 +863,20 @@ export function modelRuleLabel(cols, model, reserve = 0) {
850
863
  export function approvalsIndicator(a) {
851
864
  if (!a) return null;
852
865
  const n = a.count;
866
+ if (!a.signedOut && a.error && a.slow) return { text: 'approvals ? (server slow)', role: 'warn' }; // sustained failure, never an all-clear
853
867
  if (a.signedOut || a.error || typeof n !== 'number') return { text: 'approvals ?', role: 'warn' };
868
+ // Never an all-clear next to outstanding items or an unseen source: scope the claim to approvals and say what else is open.
869
+ const out = Number(a.outstanding) || 0, blind = Number(a.blind) || 0;
870
+ if (n === 0 && (out > 0 || blind > 0)) {
871
+ const more = [out > 0 ? `${out} other item${out === 1 ? '' : 's'} open` : null, blind > 0 ? `can't see ${blind} source${blind === 1 ? '' : 's'}` : null].filter(Boolean).join(' · ');
872
+ return { text: `no approvals in this view · ${more}`, role: 'warn' };
873
+ }
874
+ // Other orgs the user belongs to (cached counts, core/crossOrgApprovals): never an all-clear beside a waiting approval elsewhere.
875
+ const others = (Array.isArray(a.others) ? a.others : []).filter((o) => o && o.count > 0);
876
+ const elsewhere = others.reduce((t, o) => t + o.count, 0);
877
+ if (n === 0 && elsewhere > 0) return { text: others.length === 1 ? `⏸ ${elsewhere} in ${others[0].name} · /approvals` : `⏸ ${elsewhere} in ${others.length} other orgs · /approvals`, role: 'warn' };
854
878
  if (n === 0) return { text: '✔ nothing waiting', role: 'ok' };
855
- return { text: `⏸ ${n} approval${n === 1 ? '' : 's'} waiting`, role: 'warn' };
879
+ return { text: `⏸ ${n} approval${n === 1 ? '' : 's'} waiting${elsewhere ? ` · +${elsewhere} in ${others.length === 1 ? others[0].name : `${others.length} other orgs`}` : ''}`, role: 'warn' };
856
880
  }
857
881
 
858
882
  /** Pure: the labels for this width. The indicator sits top-left, the model bottom-right: independent of each other. */
@@ -1031,11 +1055,12 @@ export function AskFoot({ footer }) {
1031
1055
  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
1056
  }
1033
1057
  const footRows = (footer, cols) => (HANG_ASK.test(footer) ? wrapRows(footer.slice(2), cols - 4) : wrapRows(footer, cols - 2));
1034
- export function NeedsYouCard({ cols, title, rows = [], block = [], notes = [], footer, arming = null, motionE = null }) {
1058
+ export function NeedsYouCard({ cols, title, rows = [], block = [], notes = [], footer, arming = null, motionE = null, lead = null }) {
1035
1059
  const e = motionE;
1036
1060
  return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
1037
1061
  e == null ? h(Rule, { cols, color: 'warn' }) : h(DrawRule, { cols, color: 'warn', e }),
1038
1062
  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)),
1063
+ lead ? h(Text, { color: c('warn'), bold: true, wrap: 'wrap' }, ` ${lead}`) : null, // a destructive approval leads with what it deletes
1039
1064
  // APPROVAL-WHAT: wrap at word boundaries under the label instead of cutting the action or target off
1040
1065
  ...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)))),
1041
1066
  // block: pre-shaped segment rows shown verbatim (a command to run: never word-wrapped, never clipped)
@@ -1068,7 +1093,16 @@ export const askCardRows = (text, cols) => {
1068
1093
  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);
1069
1094
  };
1070
1095
 
1071
- export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE = null, confirm = false, position = null }) {
1096
+ /** APPROVAL-BURST: one card for 2+ approvals from one agent for one tool (approvalBatch.batchModel). */
1097
+ export function BatchCard({ model, cols, armed, motionE = null }) {
1098
+ const e = motionE;
1099
+ const block = model.lines.map((l) => [{ t: `• ${clip(l, Math.max(10, cols - 8))}`, role: 'text' }]);
1100
+ if (model.more) block.push([{ t: model.more, role: 'dim' }]);
1101
+ const footer = askText(`Approve all ${model.n}?`, { keys: model.keys });
1102
+ return h(NeedsYouCard, { cols, title: model.title, rows: [], block, notes: model.notes, footer, arming: armed ? null : (e == null ? h(Text, { color: c('dim') }, ' arming…') : null), motionE: e });
1103
+ }
1104
+
1105
+ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE = null, confirm = false, position = null, signedOut = false }) {
1072
1106
  const when = card.askedAt ? String(card.askedAt).replace('T', ' ').slice(0, 16) + ' UTC' : null;
1073
1107
  const rows = card.undescribable ? [['what', card.what], ['from', card.askedBy || card.origin], ['asked', when], ['org', org]].filter(r => r[1]) : [
1074
1108
  ['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]
@@ -1082,8 +1116,9 @@ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE
1082
1116
  const f = card.facts || null; const block = [];
1083
1117
  if (f && !card.undescribable) {
1084
1118
  if (f.where && /^\d+ (hosts|targets):/.test(f.where)) { for (const k of ['target', 'what']) { const i = rows.findIndex(r => r[0] === k && (k === 'target' || String(r[1]).includes(f.hostList[1]))); if (i >= 0) rows.splice(i, 1); } }
1119
+ if (f.background) { const i = rows.findIndex(r => r[0] === 'what'); const note = BACKGROUND_NOTE[0].toUpperCase() + BACKGROUND_NOTE.slice(1); if (i >= 0) rows[i] = ['what', `${rows[i][1]}. ${note}`]; else rows.unshift(['what', note]); }
1085
1120
  if (f.command) { block.push([{ t: f.command.kind === 'input' ? 'input:' : 'command:', role: 'label' }]); block.push(...codeRows(f.command.shown.split('\n'), cols - 4)); }
1086
- for (const r of [['where', f.where], ['who', f.who], ['why', f.why], ['asked', [f.asked && `"${f.asked}"`, f.when].filter(Boolean).join(' · ') || null], ['class', f.access]]) if (r[1]) rows.push(r);
1121
+ for (const r of [['where', f.where], ['who', f.who], ['why', f.why], ['asked', [f.asked && (f.askedRich ? f.asked : `"${f.asked}"`), f.when].filter(Boolean).join(' · ') || null], ['class', f.access]]) if (r[1]) rows.push(r);
1087
1122
  }
1088
1123
  const e = motionE;
1089
1124
  const title = `⚠ Approval needed${position && position.n > 1 ? ` (${position.i} of ${position.n})` : ''}: execution is held`;
@@ -1091,12 +1126,15 @@ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE
1091
1126
  const arming = e == null || mono()
1092
1127
  ? h(Text, { color: c('dim') }, e == null ? ' arming…' : ` arming… ${'▰'.repeat(filled)}${'▱'.repeat(ARM_BAR - filled)}`)
1093
1128
  : h(Text, null, h(Text, { color: c('dim') }, ' arming… '), h(Text, { color: c('warn') }, '▰'.repeat(filled)), h(Text, { color: c('faint') }, '▱'.repeat(ARM_BAR - filled)));
1094
- const footer = card.privileged && confirm ? `▶ Grant ${[card.tool, card.target].filter(Boolean).join(' ')}? press y again to confirm, any other key cancels`
1129
+ const footer = signedOut ? askText('Read-only while signed out. Sign in to answer?', { keys: 'y signs in' })
1130
+ : card.privileged && confirm ? `▶ Grant ${[card.tool, card.target].filter(Boolean).join(' ')}? press y again to confirm, any other key cancels`
1095
1131
  : card.undescribable && confirm ? '▶ Quox could not describe this action. Approve it anyway? press y again to confirm, any other key cancels'
1132
+ : draftHint === 'reason' ? askText('Why deny it?', { keys: 'type a reason · Enter denies · Esc goes back to y/n' })
1096
1133
  : 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)
1097
1134
  : draftHint === 'draft' ? askText('Approve it?', { keys: 'y/n answer once your draft is empty · Tab to answer the approval card (your draft is kept)' })
1098
- : '▶ Approve it? y approve · n deny · c chat · Tab later · /approvals all';
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 });
1135
+ : card.destructive ? '▶ Delete it? y then type the name · n deny · c chat · Tab later · /approvals all'
1136
+ : '▶ Approve it? y approve · n deny · c chat · Tab later · /approvals all';
1137
+ return h(NeedsYouCard, { cols, title, rows, block, lead: card.destructive ? `DELETES ${card.destructive.label}` : null, notes: [...(f && f.askedNone ? ['nothing in your request asks for this'] : []), ...(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 });
1100
1138
  }
1101
1139
 
1102
1140
  /** Columns the status line's Q takes: 2 for the Braille glyph, 1 for the Ⓠ fallback (2 where Ambiguous is wide). */
@@ -1221,11 +1259,22 @@ function hintNodes(hint) {
1221
1259
  }
1222
1260
 
1223
1261
  /** Live, actionable counts under the status line. Never a transcript message. */
1224
- export function NotifyBar({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false, inbox = '' }) {
1262
+ export function NotifyBar({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false, inbox = '', spinner = '', away = false, awayCount = 0, dnd = false, dndCount = 0, awaySince = 0, dndUntil = 0, awayUnknown = false }) {
1263
+ const hm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
1264
+ if (away && dnd && !signedOut) { // DND: one persistent quiet line, shown even at 0 waiting so the mode is never invisible
1265
+ const head = dndUntil ? `🌙 do not disturb until ${hm(dndUntil)}` : '🌙 do not disturb';
1266
+ return h(Text, { color: c('ask'), bold: true }, ' ' + clip(`${head}${dndCount ? ` · ${dndCount} waiting` : ''} · /morningreport`, cols - 3));
1267
+ }
1268
+ if (away && !signedOut) { // AFK-QUIET-CARDS: away shows one quiet line, never a prompt
1269
+ const n = awayCount || approvals || 0;
1270
+ const head = awaySince ? `🌙 away since ${hm(awaySince)} · /back` : awayUnknown ? '🌙 away (since before this window opened) · /back' : '🌙 away · /back';
1271
+ return h(Text, { color: c('ask'), bold: true }, ' ' + clip(n ? `⏸ ${n} approval${n === 1 ? '' : 's'} waiting · /back to review` : head, cols - 3)); // >0 keeps the existing wording (asserted elsewhere)
1272
+ }
1225
1273
  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;
1274
+ const line = inbox && !signedOut ? h(Text, { key: 'inbox', color: c('ask'), bold: true }, ' ' + clip(inbox, cols - 3)) : null; // HITL-INBOX-BAR: 📨 N new · newest title
1275
+ const spin = spinner ? h(Text, { key: 'spin', color: c('ask') }, ' ' + clip(spinner, cols - 3)) : null; // INSTANT-FEEDBACK: ⠋ <verb>…
1276
+ const parts = [base, line, spin].filter(Boolean);
1277
+ return parts.length > 1 ? h(Box, { flexDirection: 'column' }, ...parts) : (parts[0] || base);
1229
1278
  }
1230
1279
  function NotifyBarBase({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false }) {
1231
1280
  if (signedOut) return h(Text, { color: c('warn') }, ' ' + clip('not signed in: approvals unavailable until you sign in', cols - 2));
@@ -258,14 +258,14 @@ export function countsLine(rows) {
258
258
  * one row each plus a summary line (owner, live 05:29): the bottom area must never grow with the inbox. A single approval
259
259
  * keeps its own row. Focus (the user is walking the roster) shows the real rows so selection stays truthful.
260
260
  */
261
- export function collapseApprovals(rows, now = Date.now()) {
261
+ export function collapseApprovals(rows, now = Date.now(), staleAt = null) {
262
262
  const aps = rows.filter(r => r.kind === 'approval' && r.state === 'waiting');
263
- if (aps.length < 2) return rows;
263
+ if (aps.length < (staleAt ? 1 : 2)) return rows;
264
264
  const started = aps.map(r => r.startedAt).filter(x => x != null);
265
265
  const newest = aps.reduce((a, b) => ((b.startedAt ?? -Infinity) > (a.startedAt ?? -Infinity) ? b : a));
266
266
  const one = { key: 'approval:*', id: '*', source: 'approvals', kind: 'approval', state: 'waiting', background: false, collapsed: true, count: aps.length,
267
267
  name: `${aps.length} approvals waiting`, activity: '/approvals', status: 'pending', startedAt: started.length ? Math.min(...started) : null, finishedAt: null,
268
- oldestMs: started.length ? now - Math.min(...started) : null, newestTitle: String(newest.detail?.description || newest.name || ''), detail: {} };
268
+ oldestMs: started.length ? now - Math.min(...started) : null, staleAt: staleAt || null, newestTitle: String(newest.detail?.description || newest.name || ''), detail: {} };
269
269
  let placed = false;
270
270
  return rows.flatMap(r => (aps.includes(r) ? (placed ? [] : (placed = true, [one])) : [r]));
271
271
  }
@@ -273,9 +273,9 @@ export function collapseApprovals(rows, now = Date.now()) {
273
273
  const KIND_ICON = { loop: '∞', objective: '◎' }; // QuoxGlyph registry keys qloop / objective
274
274
  export const kindLabel = k => (KIND_ICON[k] ? `${KIND_ICON[k]} ${k}` : k);
275
275
 
276
- export function rosterView({ rows, cols, termRows = 40, max = MAX_ROWS, on = true, sel = 0, focus = false, now = Date.now(), motion = false }) {
276
+ export function rosterView({ rows, cols, termRows = 40, max = MAX_ROWS, on = true, sel = 0, focus = false, now = Date.now(), motion = false, staleAt = null }) {
277
277
  if (!on || !rows.length) return { mode: 'hidden', lines: [], more: 0 };
278
- if (!focus) rows = collapseApprovals(rows, now);
278
+ if (!focus || staleAt) rows = collapseApprovals(rows, now, staleAt); // signed out: the cached count is last-known, always one stale row
279
279
  if (cols < MIN_FULL_COLS || termRows < MIN_FULL_ROWS) {
280
280
  return { mode: 'summary', lines: [], more: 0, summary: clip(' ' + countsLine(rows) + ' · /roster', cols - 1) };
281
281
  }
@@ -293,6 +293,11 @@ export function rosterView({ rows, cols, termRows = 40, max = MAX_ROWS, on = tru
293
293
  const nameW = Math.max(8, cols - 2 - fixed - 1);
294
294
  const name = clip(r.name, nameW).padEnd(nameW);
295
295
  const marker = focus && i === sel ? '›' : ' ';
296
+ if (r.collapsed && r.staleAt) { // signed out: last-known count with its time, never worded as live (owner live 2026-10-27)
297
+ const d = new Date(r.staleAt), at = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
298
+ const text = clip(`${r.count} waiting as of ${at} · sign in to see more: /login`, cols - 4);
299
+ return { key: r.key, kind: 'approval', state: r.state, tone: 'dim', selected: false, marker: ' ', glyph: GLYPH[r.state], rest: ' ' + text, pulse: '', pulseRole: 'dim', pulseKind: 'none', text: ` ${GLYPH[r.state]} ${text}` };
300
+ }
296
301
  if (r.collapsed) { // one summary row: count, oldest age, newest title, where to go; clipped to the line
297
302
  const head = `${r.count} approvals waiting · oldest ${elapsed(r.oldestMs)} · newest: `, tail = ' · /approvals';
298
303
  const room = cols - 5 - [...head].length - [...tail].length;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * /roster text for the two things that are not work rows: attached agents (org lease list) and this box's live
3
+ * `claude --resume` processes. Pure; the process list and the attach list are injected by the caller.
4
+ */
5
+
6
+ const age = (sec) => {
7
+ const s = Math.max(0, Math.round(sec));
8
+ if (s < 60) return `${s}s`;
9
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
10
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
11
+ return `${Math.floor(s / 86400)}d`;
12
+ };
13
+
14
+ const sinceText = (iso, now) => {
15
+ const t = Date.parse(iso || '');
16
+ return Number.isNaN(t) ? '?' : age((now - t) / 1000);
17
+ };
18
+
19
+ /** attached agents -> one line each: agent, splinter, session, lease id, heartbeat age, expiry. */
20
+ export function attachLines(items, now = Date.now()) {
21
+ if (!items || !items.length) return [' attached agents: none'];
22
+ return items.map((a) => ` ${a.agent_id} ${a.splinter || 'S1'} · ${a.session_label || '(no label)'} · lease ${a.lease_id} · heartbeat ${sinceText(a.last_heartbeat_at, now)} ago · expires ${a.expires_at || '?'}`);
23
+ }
24
+
25
+ const where = (p) => (p.tmux ? `tmux ${p.tmux}` : `not in tmux${p.tty && p.tty !== '?' ? ` ${p.tty}` : ''}`);
26
+ const one = (p) => `${p.pid} ${where(p)} ${age(p.ageSec || 0)}`;
27
+
28
+ /** live claude processes -> lines, grouped by name case-insensitively; a name held more than once is flagged. */
29
+ export function sessionLines(procs) {
30
+ if (!procs || !procs.length) return [" this box's sessions: none"];
31
+ const groups = new Map();
32
+ for (const p of procs) {
33
+ const k = String(p.name).toLowerCase();
34
+ if (!groups.has(k)) groups.set(k, []);
35
+ groups.get(k).push(p);
36
+ }
37
+ const out = [];
38
+ for (const g of groups.values()) {
39
+ const name = g[0].name;
40
+ if (g.length === 1) out.push(` ${name}: ${one(g[0])}`);
41
+ else out.push(` ⚠ ${name} x${g.length}: ${g.map(one).join(', ')}`);
42
+ }
43
+ return out;
44
+ }
@@ -48,7 +48,7 @@ const age = (ms) => {
48
48
  };
49
49
 
50
50
  /** A line the user cannot have meant as a command they want repeated (PIN-bearing verbs): name only. */
51
- const PIN_VERBS = /^\/(authenticate|authorise|authorize|pin|login|connect|key)\b/i;
51
+ const PIN_VERBS = /^\/(authenticate|authorise|authorize|pin|login|connect|key|verify-email)\b/i;
52
52
 
53
53
  function whoSection(w) {
54
54
  const bits = [];
@@ -0,0 +1,86 @@
1
+ /**
2
+ * QUOXON-SECRET-GUARD: one detector for secret-shaped text, shared by the composer (offer before send), the
3
+ * history writer and the transcript writer (redact before write). Low false positives on purpose: a bare number
4
+ * never matches unless a keyword sits next to it, or it is a Luhn-valid card number.
5
+ */
6
+ const REDACTED = '[REDACTED';
7
+
8
+ const luhn = (digits) => {
9
+ let sum = 0, dbl = false;
10
+ for (let i = digits.length - 1; i >= 0; i--) { let d = digits.charCodeAt(i) - 48; if (dbl) { d *= 2; if (d > 9) d -= 9; } sum += d; dbl = !dbl; }
11
+ return sum % 10 === 0;
12
+ };
13
+
14
+ // keyword, then a value: after ":" or "=" anything non-space; after plain space/"is" only a value carrying a digit (4+ chars)
15
+ const KEYWORDS = [
16
+ ['pin', 'pin'], ['passcode', 'pass\\s?code'], ['password', 'password|passwd|pass\\s?word'],
17
+ ['api-key', 'api[ _-]?key'], ['secret', 'secret'], ['token', 'token']
18
+ ];
19
+ const kwRe = (kw) => new RegExp(`\\b(?:${kw})\\b(?:[ \\t]+(?:number|no\\.?|code))?(?:[ \\t]*[:=][ \\t]*["']?([^\\s"',;]+)|[ \\t]+(?:is[ \\t]+)?["']?([^\\s"',;]*\\d[^\\s"',;]*))`, 'gi');
20
+
21
+ const ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
22
+ const trimVal = (v) => v.replace(/^[(\[`<*_]+/, '').replace(/[)\]`>*_.,;:!?]+$/, '');
23
+ /**
24
+ * Precision mode: is a keyword-adjacent value really secret-shaped, or just prose ("PIN ask", "token budget",
25
+ * a ULID, a path, a placeholder)? pin/passcode need a 4-8 digit run; the others need a 8+ char value that mixes
26
+ * letters and digits (or carries a symbol) and is not an id, path, url, placeholder or short hex.
27
+ */
28
+ function preciseValueOk(kind, raw) {
29
+ if (/^[<$\{\[~\/]|^[a-z]+:\/\/|\.\.\.|^\$|^\.{0,2}\//i.test(raw)) return false;
30
+ const v = trimVal(raw);
31
+ if (kind === 'pin' || kind === 'passcode') return /^\d{4,8}$/.test(v);
32
+ if (v.length < 8 || ULID.test(v) || /^[0-9a-f]{7,12}$/i.test(v) || /^\d{4}-\d{2}-\d{2}/.test(v)) return false;
33
+ if (/[<>{}()/\\]|^\W/.test(v) && !/^[\w!@#$%^&*+=.-]+$/.test(v)) return false;
34
+ return /[A-Za-z]/.test(v) && /\d/.test(v);
35
+ }
36
+ const CARD_FORM = /^(?:\d{13,19}|\d{4}(?:[ -]\d{4}){2,3}(?:[ -]\d{1,3})?|\d{4}[ -]\d{6}[ -]\d{5})$/;
37
+
38
+ /** -> [{ kind, start, end }] value spans, sorted, non-overlapping. opts.precise = memory-sync mode (words are not secrets). */
39
+ export function findSecrets(text, opts = {}) {
40
+ const s = String(text ?? '');
41
+ const precise = !!opts.precise;
42
+ const hits = [];
43
+ const add = (kind, start, end) => { if (!/^\[redacted/i.test(s.slice(start, end))) hits.push({ kind, start, end }); };
44
+ const grp = (m, i) => { const v = m[i]; const at = m.index + m[0].lastIndexOf(v); return [at, at + v.length]; };
45
+ for (const [kind, kw] of KEYWORDS) {
46
+ for (const m of s.matchAll(kwRe(kw))) {
47
+ const i = m[1] ? 1 : 2;
48
+ if (i === 2 && m[2].length < 4) continue;
49
+ if (i === 1 && m[1].length < 2) continue;
50
+ if (precise && !preciseValueOk(kind, m[i])) continue;
51
+ add(kind, ...grp(m, i));
52
+ }
53
+ }
54
+ for (const m of s.matchAll(/sort[ \t]*code[^\d\n]{0,15}(\d{2}-?\d{2}-?\d{2})(?!\d)/gi)) add('sort-code', ...grp(m, 1));
55
+ for (const m of s.matchAll(/account(?:[ \t]*(?:number|no\.?|num))?[^\d\n]{0,15}(\d{8})(?!\d)/gi)) add('account-number', ...grp(m, 1));
56
+ for (const m of s.matchAll(/customer[ \t]*(?:number|no\.?|num|id)[^\d\n]{0,15}(\d{6,})(?!\d)/gi)) add('customer-number', ...grp(m, 1));
57
+ for (const m of s.matchAll(/(?<![\d-])\d(?:[ -]?\d){12,18}(?![\d-])/g)) {
58
+ const d = m[0].replace(/[ -]/g, '');
59
+ if (precise) {
60
+ if (!CARD_FORM.test(m[0]) || /[A-Za-z_]/.test(s[m.index - 1] ?? '') || /[A-Za-z_]/.test(s[m.index + m[0].length] ?? '')) continue;
61
+ if (/^\d{4}(?:-\d{4}){3}$/.test(m[0]) && /[0-9a-f]{4,}-$/i.test(s.slice(Math.max(0, m.index - 6), m.index))) continue;
62
+ }
63
+ if (d.length >= 13 && d.length <= 19 && luhn(d)) add('card-number', m.index, m.index + m[0].length);
64
+ }
65
+ for (const m of s.matchAll(/\bsk-[A-Za-z0-9_-]{8,}/g)) if (!precise || m[0].length >= 20) add('api-key', m.index, m.index + m[0].length);
66
+ for (const m of s.matchAll(/\b(?:ghp|gho|ghs)_[A-Za-z0-9]{16,}/g)) add('api-key', m.index, m.index + m[0].length);
67
+ for (const m of s.matchAll(/\bAKIA[0-9A-Z]{16}\b/g)) add('api-key', m.index, m.index + m[0].length);
68
+ for (const m of s.matchAll(/\beyJ[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]+){1,2}/g)) add('jwt', m.index, m.index + m[0].length);
69
+ hits.sort((a, b) => a.start - b.start || b.end - a.end);
70
+ const out = [];
71
+ for (const h of hits) { const last = out[out.length - 1]; if (last && h.start < last.end) continue; out.push(h); }
72
+ return out;
73
+ }
74
+
75
+ /** -> unique kinds in order of appearance ([] when clean). */
76
+ export const secretKinds = (text, opts) => [...new Set(findSecrets(text, opts).map(h => h.kind))];
77
+
78
+ /** Matched values become [REDACTED:<kind>]; everything else is untouched. */
79
+ export function redactSecrets(text) {
80
+ if (typeof text !== 'string' || !text) return text;
81
+ const hits = findSecrets(text);
82
+ if (!hits.length) return text;
83
+ let out = '', at = 0;
84
+ for (const h of hits) { out += text.slice(at, h.start) + `${REDACTED}:${h.kind}]`; at = h.end; }
85
+ return out + text.slice(at);
86
+ }
@@ -101,11 +101,12 @@ export const LOOSE_SAFE = new Set(['help', 'status', 'context', 'cost', 'agents'
101
101
  export function looseCommand(cmd, restArg) {
102
102
  const words = String(restArg || '').trim().split(/\s+/).filter(Boolean);
103
103
  for (let i = 0; i < words.length; i++) {
104
+ if (words[i].replace(/^\//, '').length < 2 && !words[i].startsWith('/')) continue; // a bare "r" in prose is not the /r alias
104
105
  const e = findEntry(words[i].replace(/^\//, ''));
105
106
  if (!e || e.kind !== 'command' || e.status !== 'active') continue;
106
107
  const rest = words.slice(i + 1).join(' ');
107
108
  if (LOOSE_SAFE.has(e.id)) return { run: `/${e.id}${rest ? ` ${rest}` : ''}`, name: `/${e.id}` };
108
- return { suggest: `/${e.id}${rest ? ` ${rest}` : ''}` };
109
+ return { suggest: `/${e.id}` }; // a command NAME only, never the user's message text
109
110
  }
110
111
  if (words.length && !suggestCommands(cmd).length) return { ask: `${String(cmd).replace(/^\//, '')} ${words.join(' ')}` };
111
112
  return null;
@@ -6,6 +6,7 @@
6
6
  import { CHOICES, connectedChoices, providerId } from '../model.js';
7
7
  import { liveModels } from '../modelPrefs.js';
8
8
  import { pathItems } from '../slotChat.js';
9
+ import { fromOf, activeMutes } from '../inboxView.js';
9
10
 
10
11
  /** Provider ids /connect understands (one per provider; the subscription has no key). */
11
12
  export const connectIds = () => [
@@ -14,7 +15,7 @@ export const connectIds = () => [
14
15
  ).values(),
15
16
  ];
16
17
 
17
- export function createArgSources({ api, getPrefs = () => ({ favourites: [], default: null }), getRows = () => [] }) {
18
+ export function createArgSources({ api, getPrefs = () => ({ favourites: [], default: null }), getRows = () => [], getExt = null, getInbox = null }) {
18
19
  return async function source(src, param) {
19
20
  switch (src) {
20
21
  case 'providers':
@@ -38,12 +39,18 @@ export function createArgSources({ api, getPrefs = () => ({ favourites: [], defa
38
39
  value: String(i + 1),
39
40
  desc: `${f.provider} ${f.model}`,
40
41
  }));
42
+ case 'ext.listed': return (getExt?.().listed?.() || []).map((v) => ({ value: v }));
43
+ case 'ext.loaded': return (getExt?.().loaded?.() || []).map((v) => ({ value: v }));
44
+ case 'ext.panels': return [...(getExt?.().panels?.() || []).map((p) => ({ value: p.name, desc: p.title })), { value: 'off', desc: 'collapse the panel region' }];
45
+ case 'ext.keys': return (getExt?.().keys?.(param) || []).map((d) => ({ value: d.key, desc: d.help }));
46
+ case 'inbox.sources': return [...new Set((getInbox?.().list?.() || []).map((i) => fromOf(i).toLowerCase()))].map((v) => ({ value: v }));
47
+ case 'inbox.mutes': return Object.keys(activeMutes(getInbox?.().mutes?.())).map((v) => ({ value: v }));
41
48
  case 'rows':
42
49
  return (getRows?.() || []).map((r) => ({ value: r.id, desc: r.source.type }));
43
50
  case 'orgs':
44
51
  return (await api.orgs()).map((o) => ({ value: o.slug || o.id, desc: o.name || '' }));
45
52
  case 'agents':
46
- return (await api.agents()).map((a) => ({
53
+ return [...(await api.agents())].sort((x, y) => String(x.agent_id).localeCompare(String(y.agent_id))).map((a) => ({
47
54
  value: a.agent_id,
48
55
  desc: a.name && a.name !== a.agent_id ? a.name : '',
49
56
  }));
@@ -41,7 +41,7 @@ const asItem = (x) =>
41
41
  /** Static choices of a level as items. */
42
42
  export const staticItems = (lv) => (lv?.choices || []).map(asItem);
43
43
 
44
- /** Prefix matches first, then substring; the exact sole match is dropped (nothing to add). */
44
+ /** Prefix matches first, then substring, then display-name matches; the exact sole match is dropped (nothing to add). */
45
45
  export function rankArgs(items, partial) {
46
46
  const q = String(partial || '').toLowerCase();
47
47
  const seen = new Set();
@@ -51,7 +51,9 @@ export function rankArgs(items, partial) {
51
51
  const sub = uniq.filter(
52
52
  (i) => !i.value.toLowerCase().startsWith(q) && i.value.toLowerCase().includes(q),
53
53
  );
54
- const out = [...pre, ...sub];
54
+ // A display name (desc) that matches also surfaces the item, after every id match: /attach vulcan finds forge.
55
+ const byDesc = uniq.filter((i) => !pre.includes(i) && !sub.includes(i) && String(i.desc || '').toLowerCase().includes(q));
56
+ const out = [...pre, ...sub, ...byDesc];
55
57
  return out.length === 1 && out[0].value.toLowerCase() === q ? [] : out;
56
58
  }
57
59
 
@@ -3,7 +3,7 @@
3
3
  * No I/O. v1 scope: all held slots or --slots, --priority urgent, --ack, /page. Scheduling, voice,
4
4
  * fleet and org reach, bulletins and drafting are not built.
5
5
  */
6
- export const BROADCAST_USAGE = 'Usage: /broadcast [--slots a,b] [--priority urgent] [--ack] <text>';
6
+ export const BROADCAST_USAGE = 'Usage: /broadcast [--slots a,b] [--priority urgent] [--ack] [--active [--dry-run]] <text>';
7
7
  export const PAGE_USAGE = 'Usage: /page <slot> <text>';
8
8
  const NOT_YET = new Set(['--fleet', '--org', '--voice', '--at']);
9
9
 
@@ -13,13 +13,15 @@ const NOT_YET = new Set(['--fleet', '--org', '--voice', '--at']);
13
13
  */
14
14
  export function parseBroadcastArgs(arg) {
15
15
  const toks = String(arg || '').trim().split(/\s+/).filter(Boolean);
16
- let slots = null; let priority = 'normal'; let ack = false;
16
+ let slots = null; let priority = 'normal'; let ack = false; let active = false; let dryRun = false;
17
17
  let i = 0;
18
18
  for (; i < toks.length && toks[i].startsWith('--'); i += 1) {
19
19
  const [flag, inline] = toks[i].split(/=(.*)/s);
20
20
  if (NOT_YET.has(flag)) return { ok: false, message: `${flag} is not available yet (v1 sends to held slots in this org: --slots, --priority, --ack).` };
21
21
  if (flag === '--all') { slots = null; continue; }
22
22
  if (flag === '--ack') { ack = true; continue; }
23
+ if (flag === '--active') { active = true; continue; }
24
+ if (flag === '--dry-run') { dryRun = true; continue; }
23
25
  const val = inline !== undefined ? inline : toks[++i];
24
26
  if (flag === '--slots') {
25
27
  const list = String(val || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
@@ -34,7 +36,12 @@ export function parseBroadcastArgs(arg) {
34
36
  }
35
37
  const text = toks.slice(i).join(' ');
36
38
  if (!text) return { ok: false, message: BROADCAST_USAGE };
37
- return { ok: true, text, slots, priority, ack };
39
+ return { ok: true, text, slots, priority, ack, ...(active ? { active: true } : {}), ...(dryRun ? { dryRun: true } : {}) };
40
+ }
41
+
42
+ /** QuoxGlyph announce line for a broadcast to active sessions: { reached, skipped, dryRun }. */
43
+ export function activeLine({ reached = 0, skipped = 0, dryRun = false } = {}) {
44
+ return `\u{1F4CD} QUOXSLOTS \u2502 broadcast to active \u2502 ${reached} ${dryRun ? 'would reach' : 'reached'} \u00B7 ${skipped} skipped dormant \u2714`;
38
45
  }
39
46
 
40
47
  /** `/page <slot> <text>` -> urgent, ack-required, one recipient. */
@@ -72,9 +79,10 @@ export function broadcastCard(r, { kind = 'broadcast' } = {}) {
72
79
  const c = r?.counts || {};
73
80
  const rc = roomC(r);
74
81
  const rows = [];
75
- if (!c.total) rows.push([['\u26A0 no held slots in this org to hear it', 'warn']]);
82
+ if (!c.total) rows.push([[r?.skipped_dormant ? `\u26A0 no active sessions to hear it \u00B7 ${r.skipped_dormant.length} dormant skipped` : '\u26A0 no held slots in this org to hear it', 'warn']]);
76
83
  else if (c.delivered) {
77
- rows.push([[`\u2714 ${c.delivered} ${c.delivered === 1 ? 'session has' : 'sessions have'} it`, 'ok']]);
84
+ const act = r?.skipped_dormant ? 'active ' : ''; // --active: say who was left alone
85
+ rows.push([[`\u2714 ${c.delivered} ${act}${c.delivered === 1 ? 'session has' : 'sessions have'} it${r?.skipped_dormant ? ` \u00B7 ${c.dormant_skipped ?? r.skipped_dormant.length} dormant skipped` : ''}`, 'ok']]);
78
86
  if (namesRow(r)) rows.push([[namesRow(r), 'prose']]); // an older server sends no per-slot list: no empty row
79
87
  }
80
88
  for (const d of badSlots(r).slice(0, 3)) rows.push([[`\u2716 ${failText(d)}`, 'error']]);
@@ -105,7 +113,12 @@ export function broadcastSummary(r) {
105
113
  const c = r?.counts || {};
106
114
  const rc = roomC(r);
107
115
  const lines = [];
116
+ if (!c.total && r.skipped_dormant) return [[`Broadcast ${r.id}: no active sessions to hear it \u00B7 ${r.skipped_dormant.length} dormant skipped${r.skipped_dormant.length ? ` (${r.skipped_dormant.join(', ')})` : ''}.`, 'warn']];
108
117
  if (!c.total) return [[`Broadcast ${r.id}: no held slots in this org to hear it.`, 'warn']];
118
+ if (r.skipped_dormant) { // --active: say who was left alone
119
+ const dn = r.skipped_dormant;
120
+ lines.push([`Broadcast ${r.id} ${titleWord(r)}: ${(c.delivered || 0) + (c.queued || 0)} active have it${okSlots(r).length ? ` (${okSlots(r).map((d) => d.slot).join(', ')})` : ''} \u00B7 ${dn.length} dormant skipped${dn.length ? ` (${dn.join(', ')})` : ''}`, c.failed ? 'warn' : 'plain']);
121
+ } else
109
122
  lines.push([`Broadcast ${r.id} ${titleWord(r)}: ${(c.delivered || 0) + (c.queued || 0)} of ${c.total} ${plural(c.total)} have it${okSlots(r).length ? ` (${okSlots(r).map((d) => d.slot).join(', ')})` : ''}`, c.failed ? 'warn' : 'plain']);
110
123
  if (c.delivered && roomText(rc, ', ')) lines.push([` chat rooms: ${roomText(rc, ', ')}`, rc.failed ? 'warn' : 'plain']);
111
124
  for (const d of badSlots(r)) lines.push([` ${failText(d)}`, 'warn']);
@@ -3,7 +3,7 @@
3
3
  * One server call fans out (POST /api/v1/slots/broadcast); with --ack the sender then sees the delivery line
4
4
  * update as recipients acknowledge. The poll is display only: the broadcast and its acks live on the server.
5
5
  */
6
- import { parseBroadcastArgs, parsePageArgs, parseAckArgs, parseStatusArgs, broadcastSummary, broadcastCard, BROADCAST_USAGE } from './broadcast.js';
6
+ import { parseBroadcastArgs, parsePageArgs, parseAckArgs, parseStatusArgs, broadcastSummary, broadcastCard, BROADCAST_USAGE, activeLine } from './broadcast.js';
7
7
 
8
8
  const roomC = (r) => ({ sent: 0, sending: 0, failed: 0, ...(r?.counts?.room || {}) });
9
9
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -22,9 +22,20 @@ export async function runBroadcast({ api, kind, arg, from, say, card = null, isC
22
22
  }
23
23
  const parsed = kind === 'page' ? parsePageArgs(arg) : parseBroadcastArgs(arg);
24
24
  if (!parsed.ok) return say(parsed.message, parsed.message === BROADCAST_USAGE ? 'plain' : 'warn');
25
- const r = await api.slotBroadcast({ text: parsed.text, slots: parsed.slots, priority: parsed.priority, ack: parsed.ack, from });
25
+ const r = await api.slotBroadcast({ text: parsed.text, slots: parsed.slots, priority: parsed.priority, ack: parsed.ack, active: parsed.active, ...(parsed.dryRun ? { dryRun: true } : {}), from });
26
26
  if (!r.ok) return say(`${kind === 'page' ? 'Page' : 'Broadcast'} failed: ${r.message}`, 'warn');
27
+ if (r.data?.dry_run) { // BROADCAST-ACTIVE --dry-run: who would hear it, who is dormant; nothing sent, no card
28
+ const would = r.data.would_reach || []; const dorm = r.data.skipped_dormant || [];
29
+ say(activeLine({ reached: would.length, skipped: dorm.length, dryRun: true }), 'ok');
30
+ if (would.length) say(` would reach: ${would.join(', ')}`, 'plain');
31
+ if (dorm.length) say(` skipped dormant: ${dorm.join(', ')}`, 'plain');
32
+ return say('Dry run: nothing was sent.', 'plain');
33
+ }
27
34
  show(r.data);
35
+ if (parsed.active && r.data?.skipped_dormant) {
36
+ say(activeLine({ reached: r.data.counts?.delivered || 0, skipped: r.data.skipped_dormant.length }), 'ok');
37
+ if (r.data.skipped_dormant.length) say(` skipped dormant: ${r.data.skipped_dormant.join(', ')}`, 'plain');
38
+ }
28
39
  // PRESENCE: it reached a human slot whose human is away; approvals to them will queue.
29
40
  if (r.data?.human_presence?.away && r.data.human_presence.notice) say(`⏸ ${r.data.human_presence.notice}`, 'warn');
30
41
  // POKE-WAKE: a page also asks each delivered slot's keeper to wake its idle session, so the page is read now,