quoxcli 0.4.0 → 0.6.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 (199) hide show
  1. package/CHANGELOG.md +110 -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/governance.js +7 -0
  9. package/lib/api/endpoints/inbox.js +6 -0
  10. package/lib/api/endpoints/inboxq.js +58 -0
  11. package/lib/api/endpoints/index.js +4 -0
  12. package/lib/api/endpoints/lucid.js +17 -0
  13. package/lib/api/endpoints/mcp.js +35 -0
  14. package/lib/api/endpoints/mon.js +122 -0
  15. package/lib/api/endpoints/notesq.js +2 -0
  16. package/lib/api/endpoints/quoxplan.js +51 -0
  17. package/lib/api/endpoints/tools.js +13 -0
  18. package/lib/api/endpoints/v1.js +13 -0
  19. package/lib/commands/actions.js +51 -13
  20. package/lib/commands/admin.js +35 -0
  21. package/lib/commands/agentAttach.js +15 -0
  22. package/lib/commands/approvals.js +193 -3
  23. package/lib/commands/ask.js +1 -1
  24. package/lib/commands/auth.js +64 -11
  25. package/lib/commands/backup.js +11 -4
  26. package/lib/commands/capabilities.js +3 -2
  27. package/lib/commands/chat.js +15 -0
  28. package/lib/commands/claude.js +4 -1
  29. package/lib/commands/compliance.js +109 -0
  30. package/lib/commands/connect.js +69 -0
  31. package/lib/commands/contacts.js +2 -0
  32. package/lib/commands/dev.js +6 -8
  33. package/lib/commands/doctor.js +22 -3
  34. package/lib/commands/evidence.js +2 -1
  35. package/lib/commands/gripe.js +24 -13
  36. package/lib/commands/inbox.js +37 -3
  37. package/lib/commands/inboxq.js +147 -0
  38. package/lib/commands/kuma.js +78 -5
  39. package/lib/commands/licenceLinkView.js +311 -0
  40. package/lib/commands/linkedin.js +19 -0
  41. package/lib/commands/loop.js +30 -3
  42. package/lib/commands/lucid.js +7 -0
  43. package/lib/commands/lucidGoals.js +389 -0
  44. package/lib/commands/mail.js +427 -25
  45. package/lib/commands/mcp.js +190 -0
  46. package/lib/commands/memory.js +68 -4
  47. package/lib/commands/memorySync.js +158 -0
  48. package/lib/commands/mon.js +69 -144
  49. package/lib/commands/monitoring.js +68 -0
  50. package/lib/commands/notes.js +29 -7
  51. package/lib/commands/notesSeal.js +617 -0
  52. package/lib/commands/objective.js +5 -2
  53. package/lib/commands/offload.js +52 -12
  54. package/lib/commands/plan.js +487 -52
  55. package/lib/commands/provider.js +29 -8
  56. package/lib/commands/qlarity.js +32 -1
  57. package/lib/commands/run.js +2 -1
  58. package/lib/commands/server.js +37 -0
  59. package/lib/commands/setup.js +127 -75
  60. package/lib/commands/skills.js +4 -1
  61. package/lib/commands/slot.js +49 -9
  62. package/lib/commands/status.js +92 -44
  63. package/lib/commands/tool.js +30 -0
  64. package/lib/commands/vault.js +6 -3
  65. package/lib/commands/volt.js +6 -1
  66. package/lib/commands/walkthrough.js +14 -2
  67. package/lib/commands/watch.js +11 -2
  68. package/lib/commands/wazuh.js +880 -0
  69. package/lib/commands/wazuhIntake.js +358 -0
  70. package/lib/commands/wazuhReport.js +192 -0
  71. package/lib/commands/workflow.js +47 -4
  72. package/lib/commands/x.js +50 -0
  73. package/lib/core/announce.js +86 -0
  74. package/lib/core/auth.js +20 -2
  75. package/lib/core/callMe.js +118 -0
  76. package/lib/core/client.js +27 -1
  77. package/lib/core/commandRegistrars.js +9 -0
  78. package/lib/core/config.js +42 -3
  79. package/lib/core/confirm.js +68 -7
  80. package/lib/core/crossOrgApprovals.js +69 -0
  81. package/lib/core/errors.js +52 -1
  82. package/lib/core/governedSession.js +135 -5
  83. package/lib/core/inboxResolution.js +3 -2
  84. package/lib/core/loginGuard.js +73 -0
  85. package/lib/core/offload.js +120 -0
  86. package/lib/core/org-override.js +5 -1
  87. package/lib/core/output.js +30 -5
  88. package/lib/core/profileTrust.js +182 -0
  89. package/lib/core/quoxglyph.js +22 -1
  90. package/lib/core/router.js +14 -0
  91. package/lib/core/selfUpdate.js +2 -0
  92. package/lib/core/stepUpPrompt.js +99 -0
  93. package/lib/core/version.js +1 -1
  94. package/lib/core/walkthrough.js +9 -5
  95. package/lib/dragon/select.js +2 -2
  96. package/lib/quoxon/EXTENSIONS.md +142 -19
  97. package/lib/quoxon/api.js +189 -34
  98. package/lib/quoxon/app.js +1277 -188
  99. package/lib/quoxon/approvalBatch.js +117 -0
  100. package/lib/quoxon/approvalFacts.js +56 -2
  101. package/lib/quoxon/approvalSentence.js +8 -4
  102. package/lib/quoxon/approvalsView.js +72 -12
  103. package/lib/quoxon/ask.js +4 -2
  104. package/lib/quoxon/autoOpen.js +94 -0
  105. package/lib/quoxon/backgroundWork.js +59 -0
  106. package/lib/quoxon/bang.js +8 -2
  107. package/lib/quoxon/capabilities.js +7 -2
  108. package/lib/quoxon/commandShort.js +8 -1
  109. package/lib/quoxon/commands.js +42 -12
  110. package/lib/quoxon/connect.js +5 -3
  111. package/lib/quoxon/deckLazy.js +58 -0
  112. package/lib/quoxon/delegationDragon.js +49 -0
  113. package/lib/quoxon/destructive.js +35 -0
  114. package/lib/quoxon/dnd.js +118 -0
  115. package/lib/quoxon/dragonCompanion.js +37 -7
  116. package/lib/quoxon/dragonMoments.js +78 -0
  117. package/lib/quoxon/echo.js +1 -0
  118. package/lib/quoxon/events.js +55 -8
  119. package/lib/quoxon/extensions.js +400 -38
  120. package/lib/quoxon/foreignText.js +49 -0
  121. package/lib/quoxon/gripeQueue.js +135 -0
  122. package/lib/quoxon/inboxView.js +125 -22
  123. package/lib/quoxon/keys.js +2 -2
  124. package/lib/quoxon/launcher.js +2 -1
  125. package/lib/quoxon/loopCard.js +7 -2
  126. package/lib/quoxon/loopCards.js +17 -8
  127. package/lib/quoxon/loopStream.js +20 -9
  128. package/lib/quoxon/mailView.js +71 -0
  129. package/lib/quoxon/main.js +27 -6
  130. package/lib/quoxon/markdown.js +1 -1
  131. package/lib/quoxon/model.js +15 -0
  132. package/lib/quoxon/neverAnswered.js +20 -0
  133. package/lib/quoxon/objects/away.js +43 -5
  134. package/lib/quoxon/onboarding/connectServer.js +162 -0
  135. package/lib/quoxon/onboarding/copy.js +6 -0
  136. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  137. package/lib/quoxon/onboarding/index.js +5 -43
  138. package/lib/quoxon/onboarding/probe.js +20 -1
  139. package/lib/quoxon/onboarding/view.js +16 -3
  140. package/lib/quoxon/orgSwitch.js +22 -0
  141. package/lib/quoxon/print.js +2 -0
  142. package/lib/quoxon/profileSwitch.js +7 -0
  143. package/lib/quoxon/promptHistory.js +3 -1
  144. package/lib/quoxon/render.js +68 -16
  145. package/lib/quoxon/resend.js +14 -0
  146. package/lib/quoxon/roster.js +10 -5
  147. package/lib/quoxon/rosterSessions.js +44 -0
  148. package/lib/quoxon/rows.js +3 -1
  149. package/lib/quoxon/screenContext.js +1 -1
  150. package/lib/quoxon/secretShape.js +88 -0
  151. package/lib/quoxon/slash/agents.js +2 -1
  152. package/lib/quoxon/slash/argSources.js +9 -2
  153. package/lib/quoxon/slash/args.js +4 -2
  154. package/lib/quoxon/slash/broadcast.js +48 -11
  155. package/lib/quoxon/slash/broadcastRun.js +24 -7
  156. package/lib/quoxon/slash/cliLeaves.json +98 -1
  157. package/lib/quoxon/slash/dashboard.js +2 -0
  158. package/lib/quoxon/slash/hatWalk.js +1 -1
  159. package/lib/quoxon/slash/listView.js +2 -1
  160. package/lib/quoxon/slash/palette.js +63 -1
  161. package/lib/quoxon/slash/paletteController.js +15 -5
  162. package/lib/quoxon/slash/restart.js +9 -4
  163. package/lib/quoxon/slash/stepUp.js +5 -5
  164. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  165. package/lib/quoxon/slashSpinner.js +35 -0
  166. package/lib/quoxon/slotAttach.js +8 -0
  167. package/lib/quoxon/slotChat.js +24 -3
  168. package/lib/quoxon/slotFormat.js +1 -1
  169. package/lib/quoxon/slotOutbox.js +113 -0
  170. package/lib/quoxon/slotRun.js +2 -0
  171. package/lib/quoxon/suggestCommand.js +1 -1
  172. package/lib/quoxon/term.js +25 -1
  173. package/lib/quoxon/transcript.js +2 -1
  174. package/lib/quoxon/webPty.js +15 -0
  175. package/lib/quoxon/welcome.js +1 -1
  176. package/lib/quoxon/whatsnew.js +1 -1
  177. package/lib/quoxon/whatsnew.jsonl +42 -0
  178. package/lib/render/planBrief.js +614 -0
  179. package/lib/server/installFlow.js +168 -0
  180. package/lib/server/status.js +15 -1
  181. package/lib/toolOutputShape.js +61 -3
  182. package/package.json +3 -2
  183. package/vendor/dragonlab/SOURCE +3 -3
  184. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  185. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  186. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  187. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  188. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  189. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  190. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  191. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  192. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  193. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  194. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  195. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  196. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  197. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  198. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  199. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -18,6 +18,7 @@ import * as T from './copy.js';
18
18
  import { matchIntent, yesNo, parseConnect } from './intent.js';
19
19
  import { loadJourney, saveJourney, clearJourney, resumeStep } from './journey.js';
20
20
  import { pendingRows, runPreflight, preflightPassed, detectOllama, defaultDeps } from './preflight.js';
21
+ import { probeLocalQuox } from './probe.js';
21
22
  import { credentialOptions, KEY_PROVIDERS, normaliseOllamaUrl, nextSelectable } from './credential.js';
22
23
  import { needsConsent, providerLabel } from '../consent.js';
23
24
  import { HelloIntro, helloIntroMs } from '../qmotion.js';
@@ -101,8 +102,8 @@ const Hint = ({ text, cols }) => h(Box, { paddingLeft: 2 }, h(Text, { color: c('
101
102
 
102
103
  // ---- the view ---------------------------------------------------------------------------------
103
104
  export function GettingStarted({
104
- columns, version = '0.4.0', cwd = '', welcome = false, animateIntro, earlyInput = null,
105
- journeyPath, dryRun = false, runner = null, preflightDeps, runChecks = runPreflight, findOllama = detectOllama,
105
+ columns, version = '0.6.0', cwd = '', welcome = false, animateIntro, earlyInput = null,
106
+ journeyPath, dryRun = false, runner = null, preflightDeps, runChecks = runPreflight, findLocal = probeLocalQuox, findOllama = detectOllama,
106
107
  requestCredential, broker = null, finisher = null, onExit = () => {}, now = () => Date.now()
107
108
  }) {
108
109
  const { stdin, setRawMode, isRawModeSupported } = useStdin();
@@ -179,7 +180,7 @@ export function GettingStarted({
179
180
 
180
181
  function offerLines() {
181
182
  const items = [{ kind: 'guide', text: T.OFFER }];
182
- items.push({ kind: 'ask', text: T.OFFER_ASK }, { kind: 'dim', text: T.ALREADY });
183
+ items.push({ kind: 'ask', text: T.OFFER_ASK }, { kind: 'dim', text: T.CHOICES }, { kind: 'dim', text: T.ALREADY });
183
184
  push(...items);
184
185
  set({ step: 'offer' });
185
186
  persist();
@@ -218,6 +219,10 @@ export function GettingStarted({
218
219
  if (text.startsWith('/')) { onSlash(text); return; }
219
220
  if (s.step === 'ask') return onRequest(text);
220
221
  if (s.step === 'offer' || s.step === 'declined') {
222
+ const pick = text.toLowerCase();
223
+ if (pick === 'a') { push({ kind: 'user', text }); startPreflight(); return; } // install here: the same flow as yes
224
+ if (pick === 'b') { push({ kind: 'user', text }, { kind: 'dim', text: T.LOCAL_CHECKING }); useLocal(); return; }
225
+ if (pick === 'c') { push({ kind: 'user', text }, { kind: 'guide', text: T.CONNECT_ELSEWHERE }); return; }
221
226
  const yn = yesNo(text);
222
227
  if (yn === true) { push({ kind: 'user', text: text || 'yes' }); startPreflight(); return; }
223
228
  if (yn === false) { push({ kind: 'user', text }, { kind: 'guide', text: T.DECLINED }, { kind: 'dim', text: T.DECLINED_HINT }); set({ step: 'declined' }); persist(); return; }
@@ -225,6 +230,14 @@ export function GettingStarted({
225
230
  }
226
231
  }
227
232
 
233
+ async function useLocal() {
234
+ const f = await findLocal();
235
+ if (!live.current || S.current.done) return;
236
+ if (!f.found) { push({ kind: 'guide', tone: 'warn', text: T.LOCAL_NONE }); return; }
237
+ push({ kind: 'guide', tone: 'ok', text: T.localFound(f.url) }, { kind: 'guide', text: T.connectChecking(f.url) });
238
+ exit({ action: 'connect', url: f.url });
239
+ }
240
+
228
241
  async function startPreflight() {
229
242
  set({ step: 'preflight', rows: pendingRows(), fixNote: null });
230
243
  persist();
@@ -8,7 +8,29 @@ export const orgRows = (list) => (Array.isArray(list) ? list : []).map((o) => ({
8
8
  * query found in the name or slug (so "acme widgets" finds "Acme Widgets Ltd"). Never guesses between ties.
9
9
  * -> { org } | { ambiguous: [orgs] } | { none: true }
10
10
  */
11
+ const VERBS = new Set(['switch', 'swith', 'swich', 'sw', 'to', 'go', 'use']);
12
+ /** Drop ONE leading verb-ish token ("swith quox dev" -> "quox dev"); a lone verb is kept (it may be the name). */
13
+ export function stripOrgVerb(query) {
14
+ const m = /^(\S+)\s+(\S[\s\S]*)$/.exec(String(query ?? '').trim());
15
+ return m && VERBS.has(m[1].toLowerCase()) ? m[2].trim() : String(query ?? '').trim();
16
+ }
17
+
18
+ /** Orgs for a picker when nothing matched uniquely: those hitting the most query words (verb stripped), at least one. */
19
+ export function orgCandidates(query, list) {
20
+ const words = norm(stripOrgVerb(query)).split(' ').filter(Boolean);
21
+ const scored = orgRows(list).map((o) => { const hay = `${norm(o.name)} ${norm(o.slug)}`; return { o, n: words.filter((w) => hay.includes(w)).length }; });
22
+ const best = Math.max(0, ...scored.map((x) => x.n));
23
+ return best ? scored.filter((x) => x.n === best).map((x) => x.o) : [];
24
+ }
25
+
11
26
  export function matchOrg(query, list) {
27
+ const first = matchOrgExact(query, list);
28
+ if (!first.none) return first;
29
+ const stripped = stripOrgVerb(query);
30
+ return stripped !== String(query ?? '').trim() ? matchOrgExact(stripped, list) : first;
31
+ }
32
+
33
+ function matchOrgExact(query, list) {
12
34
  const rows = orgRows(list);
13
35
  const q = norm(query);
14
36
  if (!q) return { none: true };
@@ -4,6 +4,7 @@
4
4
  * Exit codes: 0 completed, 2 held for approval, 3 interrupted, 4 auth, 5 unconfirmed
5
5
  * (stream closed without turn.finished), 1 other.
6
6
  */
7
+ import { eventLine } from '../core/announce.js';
7
8
  import { textGate } from './events.js';
8
9
  import { streamFields, fallbackNote } from './model.js';
9
10
  import { connectOfferFor } from './connect.js';
@@ -60,6 +61,7 @@ export async function runPrint({ api, prompt, agent = 'quox', output = 'text', c
60
61
  else if (ev.type === 'done' && !text && ev.full_response) text = ev.full_response;
61
62
  else if (ev.type === 'tool.started' && !seen.has(ev.call_id)) { seen.add(ev.call_id); sum.tools++; }
62
63
  else if (ev.type === 'tool.finished') { if (!seen.has(ev.call_id)) { seen.add(ev.call_id); sum.tools++; } if (ev.receipt?.ward_seq != null) sum.receipts.push(ev.receipt.ward_seq); }
64
+ else if (ev.type === 'quox_event') { if (output !== 'json') { const q = eventLine(ev); if (q) err(`${q}\n`); } } // QUOX-ANNOUNCE A2: stdout stays the answer
63
65
  else if (ev.type === 'approval.requested') { sawApproval = true; sum.approvals++; }
64
66
  else if (ev.type === 'approval.resolved') sawApproval = false;
65
67
  else if (ev.type === 'turn.objective_suggested' && ev.objective_suggested?.goal) objective = ev.objective_suggested; // QUOXON objective offer
@@ -23,6 +23,13 @@ export const switchAskLine = (profile, human) => askText('Switch?', {
23
23
  action: `switch this window to your login '${human}' (same conversation, attach, slot and groups)`,
24
24
  });
25
25
 
26
+ /** The startup offer on an agent login (owner 2026-10-28): restart as the human profile; /profile lists the rest. */
27
+ export const startupSwitchAskLine = (profile, human, others = 0) => askText(`Restart as you (${human})?`, {
28
+ title: `This window would run as the agent '${profile}'.`,
29
+ action: `restart this window as '${human}' (same directory, nothing else changes)`,
30
+ reason: others > 0 ? `${others} other signed-in profile${others === 1 ? '' : 's'}: /profile lists them` : '',
31
+ });
32
+
26
33
  /** The ask card for a /slot name that does not exist (the near misses are said on the line above). */
27
34
  export const slotCreateAskLine = (nm) => askText('Create it?', { title: `No slot ${nm}.`, action: `create and hold slot ${nm}` });
28
35
 
@@ -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 });
@@ -3,6 +3,7 @@
3
3
  * horizontal rules only (no side borders), semantic colour, mono-safe.
4
4
  */
5
5
  import { toolCardLines } from './toolCards.js';
6
+ import { DelegationDragon, delegationDragonLines } from './delegationDragon.js';
6
7
  import React from 'react';
7
8
  import chalk from 'chalk';
8
9
  import { Box, Text, useStdout } from 'ink';
@@ -30,6 +31,7 @@ import { Markdown, wrapCell, markdownRows, wrapRows, WText, Heading } from './ma
30
31
  import { parseAsk, keysLabel, askText } from './ask.js';
31
32
  import { parseAsideNote } from './asideView.js';
32
33
  import { formatSlotBody, inlineable, codeRows } from './slotFormat.js';
34
+ import { BACKGROUND_NOTE } from './backgroundWork.js';
33
35
  import { termRows } from './term.js';
34
36
  import { cellWidth, ambiguousWide } from './cells.js';
35
37
  import { welcomeModel } from './welcome.js';
@@ -555,7 +557,9 @@ export function HistoryItem({ item, cols, live = false, fx = null, pulse = null,
555
557
  : h(Text, { key: i, color: c('dim') }, ...runs(paintRuns(l, 'muted'))))));
556
558
  }
557
559
  case 'text': return h(Box, { marginTop: gap ?? 1, paddingLeft: 2, flexDirection: 'column' }, h(Markdown, { text: item.text, cols: cols - 2, live }));
558
- case 'tool': return h(ToolRow, { b: item, cols, fx, pulse, ignite });
560
+ case 'tool': return item.dragon // dragons on: a static dragon marks CommanderQ's hand-off (delegationDragon.js)
561
+ ? h(Box, { flexDirection: 'column' }, h(DelegationDragon, { b: item, cols }), h(ToolRow, { b: item, cols, fx, pulse, ignite }))
562
+ : h(ToolRow, { b: item, cols, fx, pulse, ignite });
559
563
  case 'trace': return h(TraceRow, { b: item, cols, fx, pulse });
560
564
  case 'aocl': return h(AoclRow, { item, cols });
561
565
  case 'error': return h(ErrorView, { item, cols, fx });
@@ -733,10 +737,12 @@ function TipView({ item, cols }) {
733
737
  const body = wrapPlain(item.text, inner).map((l, i) => row(`t${i}`, [h(Text, { key: 0, color: c('prose') }, l)], [...l].length));
734
738
  const cmdText = clip(item.cmd, inner - 5);
735
739
  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);
740
+ const hintPlain = stripMarks(TIP_HINT); // too long for the frame: wrap it inside, never let the terminal wrap the border
741
+ const hintRows = [...hintPlain].length <= inner ? [row('hint', runs(paintRuns(TIP_HINT, 'quiet')), [...hintPlain].length)]
742
+ : wrapPlain(hintPlain, inner).map((l, i) => row(`hint${i}`, [h(Text, { key: 0, color: c('quiet') }, l)], [...l].length));
737
743
  return h(Box, { paddingLeft: 2, flexDirection: 'column' },
738
744
  h(Text, edge, '╭─ ', h(Text, { bold: true }, 'Tip'), ' ', '─'.repeat(W - 8), '╮'),
739
- ...body, tryRow, hintRow,
745
+ ...body, tryRow, ...hintRows,
740
746
  h(Text, edge, '╰', '─'.repeat(W - 2), '╯'));
741
747
  }
742
748
 
@@ -764,9 +770,13 @@ function WhatsNewView({ item, cols }) {
764
770
  return out;
765
771
  });
766
772
  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));
773
+ // a head that does not fit in the top edge (the compact "N new commands (...)" line at a narrow width) goes inside the
774
+ // frame, wrapped, under a bare top edge: the terminal must never be the one to wrap a border row
775
+ const headFits = [...HEAD].length <= W - 6;
776
+ 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
777
  if (item.pad) { rows.unshift(row('p0', [], 0)); rows.push(row('p1', [], 0)); }
768
778
  return h(Box, { paddingLeft: 2, flexDirection: 'column' },
769
- h(Text, edge, '╭─ ', h(Text, { bold: true }, HEAD), ' ', '─'.repeat(Math.max(1, W - 5 - HEAD.length)), '╮'),
779
+ headFits ? h(Text, edge, '╭─ ', h(Text, { bold: true }, HEAD), ' ', '─'.repeat(W - 5 - [...HEAD].length), '╮') : h(Text, edge, '╭', '─'.repeat(W - 2), '╮'),
770
780
  ...rows,
771
781
  h(Text, edge, '╰', '─'.repeat(W - 2), '╯'));
772
782
  }
@@ -804,7 +814,7 @@ export function liveRows(blocks, cols) {
804
814
  case 'summary': return n + noteLines(b.text, cols - 4).length;
805
815
  case 'error': return n + errorRows(b.text, cols);
806
816
  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);
817
+ case 'tool': return n + 1 + delegationDragonLines(b, cols).length + (b.status === 'running' ? 0 : toolCardLines(b, cols).length);
808
818
  case 'loopcard': return n + (loopPinned(b.card) ? 0 : 1 + loopCardLines(b.card, cols).length);
809
819
  default: return n + 1;
810
820
  }
@@ -843,6 +853,12 @@ export function modelRuleLabel(cols, model, reserve = 0) {
843
853
  return reserve ? '' : clip(name, room); // with the approvals indicator the model is dropped before the indicator is cut
844
854
  }
845
855
 
856
+ /** Pure: a failed approvals poll goes stale (footer `?`) only after 3 consecutive misses or 60 s without a good read. */
857
+ export function approvalsPollStale({ fails, lastOkAt, now }) {
858
+ if (!(fails > 0)) return false;
859
+ return fails >= 3 || (typeof lastOkAt === 'number' && now - lastOkAt > 60000);
860
+ }
861
+
846
862
  /**
847
863
  * Pure: the permanent approvals indicator on the bottom rule. `a` = { count, error, signedOut }.
848
864
  * Never a false 0: signed out, an unreachable inbox, or a count not read yet is `approvals ?` (warn).
@@ -850,9 +866,20 @@ export function modelRuleLabel(cols, model, reserve = 0) {
850
866
  export function approvalsIndicator(a) {
851
867
  if (!a) return null;
852
868
  const n = a.count;
869
+ if (!a.signedOut && a.error && a.slow) return { text: 'approvals ? (server slow)', role: 'warn' }; // sustained failure, never an all-clear
853
870
  if (a.signedOut || a.error || typeof n !== 'number') return { text: 'approvals ?', role: 'warn' };
871
+ // Never an all-clear next to outstanding items or an unseen source: scope the claim to approvals and say what else is open.
872
+ const out = Number(a.outstanding) || 0, blind = Number(a.blind) || 0;
873
+ if (n === 0 && (out > 0 || blind > 0)) {
874
+ 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(' · ');
875
+ return { text: `no approvals in this view · ${more}`, role: 'warn' };
876
+ }
877
+ // Other orgs the user belongs to (cached counts, core/crossOrgApprovals): never an all-clear beside a waiting approval elsewhere.
878
+ const others = (Array.isArray(a.others) ? a.others : []).filter((o) => o && o.count > 0);
879
+ const elsewhere = others.reduce((t, o) => t + o.count, 0);
880
+ 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
881
  if (n === 0) return { text: '✔ nothing waiting', role: 'ok' };
855
- return { text: `⏸ ${n} approval${n === 1 ? '' : 's'} waiting`, role: 'warn' };
882
+ return { text: `⏸ ${n} approval${n === 1 ? '' : 's'} waiting${elsewhere ? ` · +${elsewhere} in ${others.length === 1 ? others[0].name : `${others.length} other orgs`}` : ''}`, role: 'warn' };
856
883
  }
857
884
 
858
885
  /** Pure: the labels for this width. The indicator sits top-left, the model bottom-right: independent of each other. */
@@ -1031,11 +1058,12 @@ export function AskFoot({ footer }) {
1031
1058
  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
1059
  }
1033
1060
  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 }) {
1061
+ export function NeedsYouCard({ cols, title, rows = [], block = [], notes = [], footer, arming = null, motionE = null, lead = null }) {
1035
1062
  const e = motionE;
1036
1063
  return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
1037
1064
  e == null ? h(Rule, { cols, color: 'warn' }) : h(DrawRule, { cols, color: 'warn', e }),
1038
1065
  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)),
1066
+ lead ? h(Text, { color: c('warn'), bold: true, wrap: 'wrap' }, ` ${lead}`) : null, // a destructive approval leads with what it deletes
1039
1067
  // APPROVAL-WHAT: wrap at word boundaries under the label instead of cutting the action or target off
1040
1068
  ...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
1069
  // block: pre-shaped segment rows shown verbatim (a command to run: never word-wrapped, never clipped)
@@ -1068,7 +1096,16 @@ export const askCardRows = (text, cols) => {
1068
1096
  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
1097
  };
1070
1098
 
1071
- export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE = null, confirm = false, position = null }) {
1099
+ /** APPROVAL-BURST: one card for 2+ approvals from one agent for one tool (approvalBatch.batchModel). */
1100
+ export function BatchCard({ model, cols, armed, motionE = null }) {
1101
+ const e = motionE;
1102
+ const block = model.lines.map((l) => [{ t: `• ${clip(l, Math.max(10, cols - 8))}`, role: 'text' }]);
1103
+ if (model.more) block.push([{ t: model.more, role: 'dim' }]);
1104
+ const footer = askText(`Approve all ${model.n}?`, { keys: model.keys });
1105
+ 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 });
1106
+ }
1107
+
1108
+ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE = null, confirm = false, position = null, signedOut = false }) {
1072
1109
  const when = card.askedAt ? String(card.askedAt).replace('T', ' ').slice(0, 16) + ' UTC' : null;
1073
1110
  const rows = card.undescribable ? [['what', card.what], ['from', card.askedBy || card.origin], ['asked', when], ['org', org]].filter(r => r[1]) : [
1074
1111
  ['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 +1119,9 @@ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE
1082
1119
  const f = card.facts || null; const block = [];
1083
1120
  if (f && !card.undescribable) {
1084
1121
  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); } }
1122
+ 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
1123
  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);
1124
+ 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
1125
  }
1088
1126
  const e = motionE;
1089
1127
  const title = `⚠ Approval needed${position && position.n > 1 ? ` (${position.i} of ${position.n})` : ''}: execution is held`;
@@ -1091,12 +1129,15 @@ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE
1091
1129
  const arming = e == null || mono()
1092
1130
  ? h(Text, { color: c('dim') }, e == null ? ' arming…' : ` arming… ${'▰'.repeat(filled)}${'▱'.repeat(ARM_BAR - filled)}`)
1093
1131
  : 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`
1132
+ const footer = signedOut ? askText('Read-only while signed out. Sign in to answer?', { keys: 'y signs in' })
1133
+ : card.privileged && confirm ? `▶ Grant ${[card.tool, card.target].filter(Boolean).join(' ')}? press y again to confirm, any other key cancels`
1095
1134
  : card.undescribable && confirm ? '▶ Quox could not describe this action. Approve it anyway? press y again to confirm, any other key cancels'
1135
+ : draftHint === 'reason' ? askText('Why deny it?', { keys: 'type a reason · Enter denies · Esc goes back to y/n' })
1096
1136
  : 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
1137
  : 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 });
1138
+ : card.destructive ? '▶ Delete it? y then type the name · n deny · c chat · Tab later · /approvals all'
1139
+ : '▶ Approve it? y approve · n deny · c chat · Tab later · /approvals all';
1140
+ 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
1141
  }
1101
1142
 
1102
1143
  /** Columns the status line's Q takes: 2 for the Braille glyph, 1 for the Ⓠ fallback (2 where Ambiguous is wide). */
@@ -1221,11 +1262,22 @@ function hintNodes(hint) {
1221
1262
  }
1222
1263
 
1223
1264
  /** Live, actionable counts under the status line. Never a transcript message. */
1224
- export function NotifyBar({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false, inbox = '' }) {
1265
+ 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 }) {
1266
+ const hm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
1267
+ if (away && dnd && !signedOut) { // DND: one persistent quiet line, shown even at 0 waiting so the mode is never invisible
1268
+ const head = dndUntil ? `🌙 do not disturb until ${hm(dndUntil)}` : '🌙 do not disturb';
1269
+ return h(Text, { color: c('ask'), bold: true }, ' ' + clip(`${head}${dndCount ? ` · ${dndCount} waiting` : ''} · /morningreport`, cols - 3));
1270
+ }
1271
+ if (away && !signedOut) { // AFK-QUIET-CARDS: away shows one quiet line, never a prompt
1272
+ const n = awayCount || approvals || 0;
1273
+ const head = awaySince ? `🌙 away since ${hm(awaySince)} · /back` : awayUnknown ? '🌙 away (since before this window opened) · /back' : '🌙 away · /back';
1274
+ 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)
1275
+ }
1225
1276
  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;
1277
+ 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
1278
+ const spin = spinner ? h(Text, { key: 'spin', color: c('ask') }, ' ' + clip(spinner, cols - 3)) : null; // INSTANT-FEEDBACK: ⠋ <verb>…
1279
+ const parts = [base, line, spin].filter(Boolean);
1280
+ return parts.length > 1 ? h(Box, { flexDirection: 'column' }, ...parts) : (parts[0] || base);
1229
1281
  }
1230
1282
  function NotifyBarBase({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false }) {
1231
1283
  if (signedOut) return h(Text, { color: c('warn') }, ' ' + clip('not signed in: approvals unavailable until you sign in', cols - 2));
@@ -14,6 +14,20 @@ export function isServerDown(e) {
14
14
  return /ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|UND_ERR_SOCKET/.test(String(code)) || /Could not connect to|fetch failed|ECONNREFUSED/i.test(msg);
15
15
  }
16
16
 
17
+ /**
18
+ * Owner 2026-11-07: a resend of the SAME turn_id that the server says it already holds (409 turn_id_reused, or
19
+ * turn_in_use while it still runs; also the in-stream error the registry race sends). The first send reached the
20
+ * server before the socket dropped, so the right move is to attach to that turn, never to show a bare failure.
21
+ */
22
+ export function isTurnIdTaken(e) {
23
+ if (!e) return false;
24
+ const words = `${e.code || ''} ${e.body?.error || ''} ${typeof e.error === 'string' ? e.error : ''} ${e.message || ''}`;
25
+ if (e.type === 'error') return /turn_id_reused|this turn_id already ran|this turn_id is already running/i.test(words);
26
+ return Number(e.status) === 409 && /turn_id_reused|turn_in_use|turn_id already ran|already running/i.test(words);
27
+ }
28
+ /** The text shown when the held turn cannot be shown either: the message is restored, and the owner told it may have run. */
29
+ export const HELD_NOT_SHOWN = 'The server already received this message before the connection dropped, but could not show what happened to it. Your message is back in the composer; it may have run, so check /resume before sending it again.';
30
+
17
31
  /** gh #1157: resume during a collector restart. Connection-class failures (refused, fetch failed, hang up, 502/503/504) are worth retrying; 401/404 are answers. */
18
32
  export function isResumeRetryable(e) {
19
33
  if (!e) return false;
@@ -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
+ }
@@ -22,6 +22,7 @@ import { homedir } from 'node:os';
22
22
  import { join, dirname } from 'node:path';
23
23
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
24
24
  import { cellWidth } from './cells.js';
25
+ import { isWebPty, SHELL_REFUSED } from './webPty.js';
25
26
 
26
27
  export const MAX_ROWS = 3;
27
28
  /** Lines drawn above the status bar (a row can bring several with `lines`); the stored-row cap stays MAX_ROWS. */
@@ -566,8 +567,9 @@ export async function plainFetch(url, { timeoutMs = URL_TIMEOUT_MS, fetchImpl =
566
567
  }
567
568
 
568
569
  /** Run a shell command via the user's shell: timeout, output cap. -> stdout. Throws a short reason. */
569
- export function runShell(cmd, { timeoutMs = FETCH_TIMEOUT_MS, shell = process.env.SHELL || '/bin/sh', exec = execFile } = {}) {
570
+ export function runShell(cmd, { timeoutMs = FETCH_TIMEOUT_MS, shell = process.env.SHELL || '/bin/sh', exec = execFile, env = process.env } = {}) {
570
571
  return new Promise((resolve, reject) => {
572
+ if (isWebPty(env)) return reject(new Error(SHELL_REFUSED)); // QUOXON web PTY: command rows never run on the server
571
573
  exec(shell, ['-c', cmd], { timeout: timeoutMs, maxBuffer: OUTPUT_CAP, killSignal: 'SIGKILL', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }, (err, stdout) => {
572
574
  if (!err) return resolve(String(stdout ?? ''));
573
575
  if (err.killed || err.signal === 'SIGKILL') return reject(new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`));
@@ -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,88 @@
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
+ // a digit run glued to letters/underscore is part of a longer token (hex disk serial, hash, id), never a PAN
60
+ if (/\w/.test(s[m.index - 1] ?? '') || /\w/.test(s[m.index + m[0].length] ?? '')) continue;
61
+ if (precise) {
62
+ 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;
63
+ 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;
64
+ }
65
+ if (d.length >= 13 && d.length <= 19 && luhn(d)) add('card-number', m.index, m.index + m[0].length);
66
+ }
67
+ 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);
68
+ 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);
69
+ for (const m of s.matchAll(/\bAKIA[0-9A-Z]{16}\b/g)) add('api-key', m.index, m.index + m[0].length);
70
+ 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);
71
+ hits.sort((a, b) => a.start - b.start || b.end - a.end);
72
+ const out = [];
73
+ for (const h of hits) { const last = out[out.length - 1]; if (last && h.start < last.end) continue; out.push(h); }
74
+ return out;
75
+ }
76
+
77
+ /** -> unique kinds in order of appearance ([] when clean). */
78
+ export const secretKinds = (text, opts) => [...new Set(findSecrets(text, opts).map(h => h.kind))];
79
+
80
+ /** Matched values become [REDACTED:<kind>]; everything else is untouched. */
81
+ export function redactSecrets(text) {
82
+ if (typeof text !== 'string' || !text) return text;
83
+ const hits = findSecrets(text);
84
+ if (!hits.length) return text;
85
+ let out = '', at = 0;
86
+ for (const h of hits) { out += text.slice(at, h.start) + `${REDACTED}:${h.kind}]`; at = h.end; }
87
+ return out + text.slice(at);
88
+ }
@@ -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
  }));