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
@@ -0,0 +1,117 @@
1
+ // APPROVAL-BURST (owner 2026-10-28; 2026-10-27 09:53 VIGIL filed nine wire_alerts approvals in a minute as nine cards).
2
+ // 2+ pending approvals from the same agent for the same tool, arriving within 5 minutes of each other, are ONE card.
3
+ // A destructive, risky, privileged or step-up item is NEVER part of a batch: it keeps its own card and its own confirm.
4
+ // Pure helpers; app.js owns the keys and the calls.
5
+ import { describeInboxItem } from './events.js';
6
+ import { isDestructive } from './destructive.js';
7
+ import { heldReason } from './bulkRisk.js';
8
+
9
+ /** Tools that restart, reboot, run commands or change a running system: one keypress must never do that on several hosts. */
10
+ export const DISRUPTIVE_TOOL = /(^|_|\.)(restart|reboot|shutdown|poweroff|stop|kill|exec|shell|run_command|ssh|deploy|rollback|migrate|upgrade)(_|\.|$)/i;
11
+ export const isDisruptiveTool = (tool) => DISRUPTIVE_TOOL.test(String(tool ?? '').trim());
12
+ const flagged = (o) => Boolean(o && o.disruptive === true);
13
+ /** True when the server marks the item disruptive, else when its tool name matches the list. */
14
+ export function isDisruptive(it) {
15
+ const p = it?.payload || {}; const c = p.approval_context || {};
16
+ return flagged(it) || flagged(p) || flagged(c) || flagged(c.method) || flagged(p.what) || isDisruptiveTool(describeInboxItem(it).tool);
17
+ }
18
+
19
+ export const BATCH_WINDOW_MS = 5 * 60 * 1000;
20
+ export const BATCH_MAX_LINES = 8;
21
+
22
+ const ms = (v) => { const t = Date.parse(String(v ?? '').includes('T') || !v ? v : `${String(v).replace(' ', 'T')}Z`); return Number.isFinite(t) ? t : null; };
23
+ const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, Math.max(0, n - 1))}…` : t; };
24
+ const key = (agent, tool) => `${String(agent).trim().toLowerCase()}\u0000${String(tool).trim().toLowerCase()}`;
25
+
26
+ /** Chain by time: the members of `list` ({ t }) whose gaps to a neighbour inside the chain are <= window, starting from `from`. */
27
+ function chainFrom(list, from, window) {
28
+ const sorted = [...list].sort((a, b) => a.t - b.t);
29
+ const at = sorted.indexOf(from); if (at < 0) return [from];
30
+ let lo = at; let hi = at;
31
+ while (lo > 0 && sorted[lo].t - sorted[lo - 1].t <= window) lo--;
32
+ while (hi < sorted.length - 1 && sorted[hi + 1].t - sorted[hi].t <= window) hi++;
33
+ return sorted.slice(lo, hi + 1);
34
+ }
35
+
36
+ /** What the owner sees on a card of the batch: the item's target, else what it is, else a short id. */
37
+ export const batchLabel = (c) => clip(c.target || c.what || String(c.inboxItemId || c.callId || 'item').slice(0, 8), 120);
38
+
39
+ // ---- cards (the live approval queue) ---------------------------------------------------------
40
+ const cardTime = (c) => ms(c.askedAt) ?? c.shownAt ?? 0;
41
+ const cardAgent = (c) => c.askedBy || null;
42
+ const cardHeld = (c) => Boolean(c.destructive) || Boolean(c.privileged) || Boolean(c.undescribable) || Boolean(c.plan) || Boolean(c.held) || Boolean(c.disruptive) || isDisruptiveTool(c.tool) || /high|critical/i.test(String(c.risk || ''));
43
+ const cardBatchable = (c) => Boolean(c && c.inboxItemId && c.tool && cardAgent(c) && !cardHeld(c));
44
+
45
+ /**
46
+ * The batch the head card belongs to, or null (a lone card). `cards` is the queue in the order shown.
47
+ * -> { head, agent, tool, cards, heldDeletes, heldOther }. heldDeletes / heldOther count the same agent's destructive
48
+ * and other risky cards inside the same window: they are NOT in `cards` and keep their own card.
49
+ */
50
+ export function batchFor(cards, head) {
51
+ if (!cardBatchable(head)) return null;
52
+ const k = key(cardAgent(head), head.tool);
53
+ const mates = cards.filter(c => cardBatchable(c) && key(cardAgent(c), c.tool) === k).map(c => ({ c, t: cardTime(c) }));
54
+ const me = mates.find(m => m.c === head) || mates.find(m => m.c.callId === head.callId);
55
+ if (!me) return null;
56
+ const chain = chainFrom(mates, me, BATCH_WINDOW_MS);
57
+ if (chain.length < 2) return null;
58
+ const members = chain.map(m => m.c);
59
+ const lo = chain[0].t - BATCH_WINDOW_MS; const hi = chain[chain.length - 1].t + BATCH_WINDOW_MS;
60
+ const held = cards.filter(c => c !== head && !members.includes(c) && cardAgent(c) && String(cardAgent(c)).trim().toLowerCase() === String(cardAgent(head)).trim().toLowerCase() && cardHeld(c) && cardTime(c) >= lo && cardTime(c) <= hi);
61
+ return { head, agent: cardAgent(head), tool: head.tool, cards: members, heldDeletes: held.filter(c => c.destructive).length, heldOther: held.filter(c => !c.destructive).length };
62
+ }
63
+
64
+ /** The card's text: title, the item lines (at most 8, then "+K more"), the held-back note and the footer. */
65
+ export function batchModel(b) {
66
+ const n = b.cards.length;
67
+ const lines = b.cards.slice(0, BATCH_MAX_LINES).map(batchLabel);
68
+ const more = n - lines.length;
69
+ const notes = [];
70
+ if (b.heldDeletes) notes.push(`+${b.heldDeletes} delete${b.heldDeletes === 1 ? '' : 's'} held for you one by one`);
71
+ if (b.heldOther) notes.push(`+${b.heldOther} held for you one by one (risky)`);
72
+ return { n, title: `⚠ ${b.agent} wants to run ${b.tool} on ${n} items`, lines, more: more > 0 ? `+${more} more · p to see all` : null, notes, keys: `y approve all ${n} · n deny all ${n} · p pick one by one · Tab later` };
73
+ }
74
+
75
+ // ---- raw inbox items (the arrival bar) -------------------------------------------------------
76
+ const isApproval = (it) => Boolean(it?.payload?.approval_context) && it?.payload?.type !== 'decision';
77
+ const itemInfo = (it) => { const d = describeInboxItem(it); return { agent: d.by, tool: d.tool }; };
78
+ const itemBatchable = (it) => { if (!isApproval(it)) return false; const i = itemInfo(it); return Boolean(i.agent && i.tool) && !isDestructive(it) && !isDisruptive(it) && heldReason(it) === null && !/high|critical/i.test(String(it.payload?.approval_context?.risk || '')); };
79
+
80
+ /**
81
+ * Raw approval items grouped into bursts: [{ agent, tool, items }] for each run of 2+ from one agent and tool inside the window.
82
+ * Held, destructive and non-approval items are never in a burst.
83
+ */
84
+ export function bursts(items) {
85
+ const by = new Map();
86
+ for (const it of items || []) {
87
+ if (!itemBatchable(it)) continue;
88
+ const i = itemInfo(it); const k = key(i.agent, i.tool);
89
+ (by.get(k) || by.set(k, []).get(k)).push({ it, t: ms(it.created_at ?? it.createdAt) ?? 0, agent: i.agent, tool: i.tool });
90
+ }
91
+ const out = [];
92
+ for (const list of by.values()) {
93
+ const seen = new Set();
94
+ for (const m of [...list].sort((a, b) => a.t - b.t)) {
95
+ if (seen.has(m)) continue;
96
+ const chain = chainFrom(list, m, BATCH_WINDOW_MS); chain.forEach(x => seen.add(x));
97
+ if (chain.length >= 2) out.push({ agent: m.agent, tool: m.tool, items: chain.map(x => x.it) });
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /** The one arrival line for a burst. */
104
+ export const burstLine = (b) => `📨 ${clip(b.agent, 30)}: ${b.items.length} ${clip(b.tool, 40)} waiting · /approvals`;
105
+
106
+ /** Arrival lines for fresh items: a burst collapses into one line, everything else keeps its own (`lineOf(item)`). */
107
+ export function collapseArrivals(items, numOf, lineOf = null) {
108
+ const bs = bursts(items); const inBurst = new Map();
109
+ bs.forEach(b => b.items.forEach(it => inBurst.set(it, b)));
110
+ const out = []; const done = new Set();
111
+ for (const it of items || []) {
112
+ const b = inBurst.get(it);
113
+ if (!b) { out.push(lineOf ? lineOf(it, numOf(it)) : `📨 ${clip(it.title || it.kind || 'untitled', 70)} · /inbox${numOf(it) ? ` ${numOf(it)}` : ''}`); continue; }
114
+ if (done.has(b)) continue; done.add(b); out.push(burstLine(b));
115
+ }
116
+ return out;
117
+ }
@@ -3,6 +3,8 @@
3
3
  // (host count and names), WHO (the delegation chain and the human it acts for) and WHY (reason, the user's own words,
4
4
  // when). Pure, so the card, the /approvals row and /approvals N detail cannot drift apart.
5
5
 
6
+ import { startsBackgroundWork } from './backgroundWork.js';
7
+
6
8
  export const CMD_CARD_MAX = 400;
7
9
  /** Server text can arrive HTML-escaped ("2&gt;/dev/null"); decode for display. Never renders HTML. */
8
10
  const dec = (t) => (typeof t === 'string' ? t.replace(/&(amp|lt|gt|quot|apos|#39|#x27);/gi, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", '#x27': "'" })[e.toLowerCase()]) : t);
@@ -79,17 +81,69 @@ export function whyText(it) {
79
81
  /** The user's own words that started this, when the server kept them. */
80
82
  export const excerptOf = (it) => dec(it?.payload?.approval_context?.on_behalf_of?.request_excerpt) || null;
81
83
 
84
+ /** The user's full ask when the server kept it (request_full), else the 300-char excerpt. */
85
+ const fullAskOf = (it) => dec(it?.payload?.approval_context?.on_behalf_of?.request_full) || excerptOf(it);
86
+
87
+ export const ASKED_BUDGET = 300;
88
+ const VERBS = [
89
+ [/delet|remov|destroy|drop|purge|wipe/, /\b(delet|remov|destroy|drop|purge|wipe|clean\s?up|tear\s?down|get rid)/i],
90
+ [/creat|build|add|new|make|install|register/, /\b(creat|build|add|make|set\s?up|install|register|new)/i],
91
+ [/run|exec|ssh|shell|start|trigger|invoke/, /\b(run|exec|ssh|start|trigger|invoke|launch|restart)/i],
92
+ [/updat|edit|modif|patch|chang|set|config/, /\b(updat|edit|modif|patch|chang|set|config|rename)/i],
93
+ [/restart|reboot|stop|kill/, /\b(restart|reboot|stop|kill|shut)/i],
94
+ [/send|post|publish|email|notify/, /\b(send|post|publish|email|notify)/i]
95
+ ];
96
+ const STOP = new Set(['tool', 'the', 'and', 'for', 'get', 'list', 'with', 'true', 'false', 'null']);
97
+
98
+ /** The sentences (and ", then"/"and finally" clauses) of an ask, in order. */
99
+ function clausesOf(ask) {
100
+ return ask.split(/(?<=[.!?])\s+|\n+/).flatMap(sn => sn.length > 160 ? sn.split(/,\s+(?=(?:and\s+)?(?:then|finally|after that|lastly)\b)/i) : [sn]).map(x => x.trim()).filter(Boolean);
101
+ }
102
+
103
+ /**
104
+ * Long ask: opening sentence (clipped) plus the clause that best matches the tool's verb and object and any id/name in its
105
+ * input, quoted in full. Null when the ask fits the budget. { text, none }: none = nothing in the ask matches the action.
106
+ */
107
+ export function askedFor(it, budget = ASKED_BUDGET) {
108
+ const ask = fullAskOf(it);
109
+ if (!ask || ask.length <= budget) return null;
110
+ const p = it?.payload || {}; const tool = String(p.toolName || p.approval_context?.method?.tool || '').toLowerCase();
111
+ const input = commandOf(it)?.text || '';
112
+ const parts = tool.split(/[^a-z0-9]+/).filter(w => w.length > 2);
113
+ const verbRe = (VERBS.find(([t]) => parts.some(w => t.test(w))) || [])[1] || null;
114
+ const objs = parts.filter(w => !STOP.has(w) && !VERBS.some(([t]) => t.test(w)));
115
+ const ids = [...new Set((input.match(/[A-Za-z0-9][A-Za-z0-9_.:-]{3,}/g) || []).filter(t => /[\d_-]/.test(t) && !STOP.has(t.toLowerCase())))];
116
+ const cl = clausesOf(ask);
117
+ const clip = (t, n) => (t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t);
118
+ let best = -1; let bi = -1;
119
+ cl.forEach((c, i) => {
120
+ if (!verbRe || !verbRe.test(c)) return; // the action's own verb must be in the clause
121
+ const lc = c.toLowerCase();
122
+ const sc = 2 + objs.filter(o => lc.includes(o)).length + 3 * ids.filter(id => lc.includes(id.toLowerCase())).length;
123
+ if (sc > best) { best = sc; bi = i; }
124
+ });
125
+ const open = `"${clip(cl[0] || ask, 120)}"`;
126
+ if (bi < 0) return { text: open, none: true };
127
+ if (bi === 0) return { text: `"${clip(cl[0], 240)}" (the part that asks for this)`, none: false };
128
+ return { text: `${open} … "${clip(cl[bi], 200)}" (the part that asks for this)`, none: false };
129
+ }
130
+
82
131
  /** One bag of the facts, for the card. */
83
132
  export function approvalFacts(it, { n = null } = {}) {
84
133
  const cmd = commandOf(it);
134
+ const af = askedFor(it);
85
135
  const when = it?.payload?.approval_context?.created_at || it?.created_at || it?.createdAt || null;
86
136
  return {
87
- command: cmd ? { ...cmd, shown: clipVisible(cmd.text, CMD_CARD_MAX, n) } : null,
137
+ // Only a command the server classed read-only may be clipped; anything else (write, or unclassified) is shown whole, so no part of it is hidden from the approver.
138
+ command: cmd ? { ...cmd, shown: accessOf(it) === 'read-only' ? clipVisible(cmd.text, CMD_CARD_MAX, n) : cmd.text } : null,
139
+ background: Boolean(cmd && cmd.kind === 'command' && startsBackgroundWork(cmd.text)),
88
140
  where: whereText(it, { n }),
89
141
  hostList: hostsOf(it).list,
90
142
  who: whoText(it),
91
143
  why: whyText(it),
92
- asked: excerptOf(it),
144
+ asked: af ? af.text : excerptOf(it),
145
+ askedNone: Boolean(af && af.none),
146
+ askedRich: Boolean(af),
93
147
  when: when ? String(when).replace('T', ' ').slice(0, 16) + ' UTC' : null,
94
148
  access: accessOf(it)
95
149
  };
@@ -19,6 +19,9 @@ export function shortWho(raw) {
19
19
 
20
20
  const clean = (s) => String(s || '').replace(/\s+/g, ' ').replace(/[.\s]+$/, '').trim();
21
21
 
22
+ /** "4 hosts: a, b, c (+1 more, /approvals 3 detail for all)" -> "4 hosts: a, b, c (+1 more)": the hint is for the card, not the sentence. */
23
+ const tidyWhere = (w) => String(w || '').replace(/\s*\(\+(\d+) more[^)]*\)/, ' (+$1 more)');
24
+
22
25
  function whatOf(a) {
23
26
  const plain = PLAIN[a.tool];
24
27
  if (plain) return plain;
@@ -32,13 +35,14 @@ function whatOf(a) {
32
35
  export function approvalSentence(a, approved) {
33
36
  const who = shortWho(a.who);
34
37
  const [inf, ger] = whatOf(a);
35
- const where = clean(a.where || a.target);
38
+ const where = tidyWhere(clean(a.where || a.target));
36
39
  const said = (t) => where && new RegExp(`\\bon ${where.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'i').test(t); // WHAT already ends "on <where>"
37
- const at = where && !(said(inf) || said(ger) || new RegExp(`\\b${where.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(inf)) ? ` on ${where}` : '';
40
+ const multi = /^\d+ hosts?:/i.test(where || '');
41
+ const at = where && !(multi && (/\bon \d+ hosts?:/i.test(inf) || /\bon \d+ hosts?:/i.test(ger))) && !(said(inf) || said(ger) || new RegExp(`\\b${where.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(inf)) ? ` on ${where}` : '';
38
42
  if (approved) {
39
- const tail = a.foreign ? ' It runs now; its result isn\'t tracked here.' : '';
43
+ const tail = a.foreign && !a.approvalId ? ' It runs now; its result isn\'t tracked here.' : '';
40
44
  const head = who ? `${who}${inf ? ` to ${inf}` : ''}` : (ger || 'the request');
41
- return `\u2714 You approved ${head}${at}.${tail}`;
45
+ return `\u2714 You approved ${head}${at}.${tail || (a.foreign ? ' It runs now; the result follows here.' : '')}`;
42
46
  }
43
47
  return `\u2716 You denied ${who ? `${who} ` : ''}${ger || 'the request'}${at}.`;
44
48
  }
@@ -1,8 +1,11 @@
1
1
  // QUOXON-APPROVALS: pure helpers for /approvals rows, filters, detail and /approve|/deny argument parsing.
2
2
  import { describeInboxItem, approvalSourceLabel, isPrivileged } from './events.js';
3
+ import { startsBackgroundWork, BACKGROUND_NOTE } from './backgroundWork.js';
3
4
  import { commandOf, hostsOf, whereText, whoText, whyText, excerptOf, accessOf, clipVisible } from './approvalFacts.js';
4
5
 
5
6
  // quox ask: a decision request is an inbox item of kind question with payload.type 'decision'.
7
+ /** The ONE rule for "a yes needs the step-up PIN": approval_context.risk high or a high_risk flag. `quox approvals approve` and the walkthrough both use it. */
8
+ export const needsPin = (it) => it?.payload?.approval_context?.risk === 'high' || it?.payload?.high_risk === true;
6
9
  export const isDecision = (it) => it?.payload?.type === 'decision' && Array.isArray(it.payload.options);
7
10
  /** Who stands behind a recommendation: an explicit recommended_by, else the asking session or agent, else Quox. Never invented when there is no recommendation. */
8
11
  export const recommenderOf = (p) => String(p.recommended_by || p.session || p.agent || 'Quox');
@@ -134,13 +137,30 @@ export function contentRows(it) {
134
137
  ].filter(r => r[1]);
135
138
  }
136
139
 
140
+ export const INFERRED_WHY = '(guessed from the command, not stated by the caller)';
141
+ /** The exact command or tool input in full (never clipped), broken inside a token that cannot fit, one block under the label. */
142
+ function commandRows(label, text, cols) {
143
+ const lead = ' ' + label.padEnd(8); const pad = ' '.repeat(lead.length);
144
+ const room = cols ? Math.max(20, cols - lead.length - 1) : 0;
145
+ const out = [];
146
+ for (const raw of String(text).split('\n')) {
147
+ if (!room) { out.push(raw); continue; }
148
+ let l = raw; if (!l.length) { out.push(''); continue; }
149
+ while (l.length > room) { let cut = l.lastIndexOf(' ', room); if (cut < room / 2) cut = room; out.push(l.slice(0, cut)); l = l.slice(cut).replace(/^ /, ''); }
150
+ out.push(l);
151
+ }
152
+ return out.map((l, k) => (k ? pad : lead) + l);
153
+ }
154
+
137
155
  /** A plain approval (not a quox ask) shaped as a y/n card so the walkthrough can show it. The original id stays, so /approve N routes by the same list. */
138
156
  export function approvalAsWalkItem(it) {
139
157
  if (isDecision(it)) return it;
140
158
  const p = it?.payload || {}; const c = p.approval_context || {};
141
159
  const dw = distinctWhat(it); const d = describeInboxItem(it);
142
160
  const body = contentRows(it).filter(r => r[0] !== 'summary').map(r => `${r[0]}: ${r[1]}`).join('\n');
143
- const why = [whyText(it) || c.why || p.reason || '', body].filter(Boolean).join('\n');
161
+ const inferred = c.reason && typeof c.reason === 'object' && c.reason.source === 'inferred' && c.reason.text;
162
+ const whyHead = inferred ? `${INFERRED_WHY}\n${decodeEntities(c.reason.text)}` : (whyText(it) || c.why || p.reason || '');
163
+ const why = [whyHead, body].filter(Boolean).join('\n');
144
164
  return { ...it, __approval: true, title: dw || d.title || it.title || it.id,
145
165
  payload: { ...p, type: 'decision', session: requesterNames(it)[0] || approvalSourceLabel(it), why, high_risk: c.risk === 'high' || p.high_risk === true,
146
166
  options: [{ key: 'y', label: 'Approve' }, { key: 'n', label: 'Deny' }] } };
@@ -161,8 +181,9 @@ export function approvalRow(it, n, { cols = 100, now = Date.now() } = {}) {
161
181
  const on = count > 1 ? ` on ${count} ${/ssh|exec|host|shell/i.test(String(d.tool || it?.payload?.toolName || '')) ? 'hosts' : 'targets'} (${list.slice(0, 2).join(', ')}, +${count - 2} more)` : (count === 1 ? ` on ${list[0]}` : '');
162
182
  const one = cmd.text.replace(/\s*\n\s*/g, ' ; ');
163
183
  const head = ` [${n}] ${ageText(d.at, now).padStart(3)} ${String(who).slice(0, 18).padEnd(18)} ${d.tool || 'run'} `;
164
- const room = Math.max(24, cols - head.length - on.length - risk.length - priv.length - 2);
165
- return `${head}\`${clipVisible(one, room, n)}\`${on}${risk}${priv}`;
184
+ const bg = startsBackgroundWork(cmd.text) ? ' · runs in background' : '';
185
+ const room = Math.max(24, cols - head.length - on.length - risk.length - priv.length - bg.length - 2);
186
+ return `${head}\`${clipVisible(one, room, n)}\`${on}${risk}${priv}${bg}`;
166
187
  }
167
188
  const line = ` [${n}] ${ageText(d.at, now).padStart(3)} ${String(who).slice(0, 18).padEnd(18)} ${what}${risk}${priv}`;
168
189
  return line.length > cols ? line.slice(0, Math.max(10, cols - 1)) + '…' : line;
@@ -290,20 +311,37 @@ export function hang(label, text, cols) {
290
311
  if (cur || !out.length) out.push(cur);
291
312
  return out.map((l, k) => (k ? pad : lead) + l);
292
313
  }
314
+ /** Word-wrap `text` to `width`, breaking a token longer than the width inside it, so no line is ever wider than `width`. */
315
+ function wrapWords(text, width) {
316
+ const w = Math.max(1, width); const out = []; let cur = '';
317
+ for (let tok of String(text).split(' ')) {
318
+ while (tok.length > w) { // a token that can never fit: break it, never let it outdent or overflow
319
+ if (cur) { out.push(cur); cur = ''; }
320
+ out.push(tok.slice(0, w)); tok = tok.slice(w);
321
+ }
322
+ if (cur && cur.length + 1 + tok.length > w) { out.push(cur); cur = tok; } else cur = cur ? cur + ' ' + tok : tok;
323
+ }
324
+ if (cur || !out.length) out.push(cur);
325
+ return out;
326
+ }
327
+ /** A "Files (3): - a - b - c" that arrived with its line breaks flattened becomes a header line plus one "- item" line each. */
328
+ function unflattenList(text) {
329
+ const t = String(text ?? '');
330
+ if (t.includes('\n')) return t;
331
+ const m = /^(.*?\(\d+\):)\s+- (.+)$/.exec(t.trim());
332
+ return m ? [m[1], ...m[2].split(/\s+- /).map(x => '- ' + x)].join('\n') : t;
333
+ }
293
334
  /** Like hang, but keeps the text's own line breaks: each line (a "- " list item too) starts a fresh line under the label, wrapped items indented past the dash. */
294
335
  export function hangBlock(label, text, cols) {
295
- const segs = String(text ?? '').split('\n').map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean);
336
+ const segs = unflattenList(text).split('\n').map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean);
296
337
  if (segs.length <= 1) return hang(label, segs[0] || '', cols);
297
338
  const lead = ' ' + label.padEnd(8); const pad = ' '.repeat(lead.length);
298
339
  const room = Math.max(20, (cols || 0) - lead.length - 1); const out = [];
299
340
  for (const seg of segs) {
300
341
  if (!cols) { out.push(seg); continue; }
301
- const ind = /^[-*] /.test(seg) ? ' ' : '';
302
- let cur = '';
303
- for (const w of seg.split(' ')) {
304
- if (cur && cur.length + 1 + w.length > room) { out.push(cur); cur = ind + w; } else cur = cur ? cur + ' ' + w : w;
305
- }
306
- if (cur) out.push(cur);
342
+ const item = /^[-*] /.test(seg);
343
+ const lines = item ? wrapWords(seg.slice(2), room - 2).map((l, k) => (k ? ' ' : seg.slice(0, 2)) + l) : wrapWords(seg, room);
344
+ out.push(...lines);
307
345
  }
308
346
  return out.map((l, k) => (k ? pad : lead) + l);
309
347
  }
@@ -317,6 +355,20 @@ export const isLoosening = (it) => { const p = it?.payload || {}; return p.loose
317
355
  export const NO_REASON = 'No reason given: ask the session, or say no.';
318
356
  export const NO_JUSTIFICATION = 'No plain justification given: ask the session, or say no.';
319
357
  const justificationOf = (p) => String(p.justification || p.plain_justification || '').replace(/\s+/g, ' ').trim();
358
+ /** ADVICE for a test-edit (loosening) card: only facts the card carries (payload.diff_stats), never the asker's own recommendation, which has its own star line. */
359
+ export const NO_INDEPENDENT_VIEW = 'No independent view: judge the JUSTIFY text and the diff (/diff or the files listed).';
360
+ /** The `quox ask loosening` verb titles its card "Approve test edits: <paths>", which isLoosening does not match. */
361
+ const TEST_EDIT_RE = /approve\s+(?:loosening|test)\b/i;
362
+ function looseAdvice(p) {
363
+ const d = p.diff_stats || p.diff; if (!d || typeof d !== 'object') return NO_INDEPENDENT_VIEW;
364
+ const rm = Number(d.assertions_removed), ad = Number(d.assertions_added), fc = Number(d.files_changed);
365
+ const bits = [];
366
+ if (Number.isFinite(rm) && Number.isFinite(ad)) bits.push(rm === 0 ? `nothing removed, ${ad} added` : `${rm} assertion${rm === 1 ? '' : 's'} removed, ${ad} added`);
367
+ else if (Number.isFinite(rm)) bits.push(`${rm} assertion${rm === 1 ? '' : 's'} removed`);
368
+ if (d.new_test) bits.unshift('a new test');
369
+ if (Number.isFinite(fc)) bits.push(`${fc} file${fc === 1 ? '' : 's'} changed`);
370
+ return bits.length ? `From the diff: ${bits.join(', ')}. Judge the JUSTIFY text against that.` : NO_INDEPENDENT_VIEW;
371
+ }
320
372
  export function walkCard(it, i, total, { cols = 0 } = {}) {
321
373
  const p = it.payload; const who = p.session || p.agent || 'A session';
322
374
  const loose = isLoosening(it); const just = justificationOf(p); // JUSTIFY shows whenever the asker gave one, loosening title or not
@@ -326,10 +378,12 @@ export function walkCard(it, i, total, { cols = 0 } = {}) {
326
378
  const dflt = bareLoose ? (p.options.find(o => o.key === 'n') || null) : (p.options.find(o => o.key === p.default) || null);
327
379
  const why = decodeEntities(String(p.why || '')).split('\n').map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n');
328
380
  const what = yn
329
- ? `Yes lets ${who} go ahead with this; no stops it.${p.high_risk ? ' High risk: a yes needs your PIN.' : ''}`
381
+ ? `Yes lets ${who} go ahead with this; no stops it.${it.__approval && commandOf(it)?.kind === 'command' && startsBackgroundWork(commandOf(it).text) ? ` This ${BACKGROUND_NOTE}.` : ''}${p.high_risk ? ' High risk: a yes needs your PIN.' : ''}`
330
382
  : `${who} needs you to choose how to settle this.${p.high_risk ? ' High risk: a yes needs your PIN.' : ''}`;
331
383
  const advice = bareLoose
332
384
  ? `${yn ? 'I would say no' : 'Do not approve this yet'}: this makes a check easier to pass and nothing explains why that is safe.${dflt ? ` Safe default: ${optWord(dflt)} (${dflt.key}).` : ''}`
385
+ : rec && (loose || TEST_EDIT_RE.test(String(it.title || '')))
386
+ ? looseAdvice(p)
333
387
  : rec
334
388
  ? `I would ${yn ? (rec.key === 'y' ? 'say yes' : 'say no') : `go with ${optWord(rec)} (${rec.key})`}. The asker recommended it.`
335
389
  : `${who} gave no recommendation, and I will not guess one.${dflt ? ` If nobody answers it would take ${optWord(dflt)} (${dflt.key}).` : ''}`;
@@ -339,10 +393,14 @@ export function walkCard(it, i, total, { cols = 0 } = {}) {
339
393
  { key: keyClash('s') ? 'skip' : 's', label: 'Skip for now', defer: true },
340
394
  { key: keyClash('?') ? 'ask' : '?', label: 'Ask about it', ask: true },
341
395
  ];
396
+ const cmdFact = it.__approval ? commandOf(it) : null; // the same facts the single approval card shows (approvalFacts), here in full
397
+ const whereFact = it.__approval ? whereText(it, { all: true }) : null;
342
398
  const lines = [
343
399
  `${p.__approval ? 'Approval' : 'Decision'} ${i} of ${total} · ${who} asks`,
344
400
  ...hang('', cleanTitle(it), cols).map(l => l.slice(8)),
345
401
  ...hang('WHAT', what, cols),
402
+ ...(cmdFact ? commandRows(cmdFact.kind === 'input' ? 'INPUT' : 'COMMAND', cmdFact.text, cols) : []),
403
+ ...(whereFact ? hang('WHERE', whereFact, cols) : []),
346
404
  ...(why ? hangBlock('WHY', why, cols) : []),
347
405
  ...(loose || just ? hang('JUSTIFY', just || NO_JUSTIFICATION, cols) : []),
348
406
  ...(!why && !just && !loose ? hang('WHY', NO_REASON, cols) : []),
@@ -0,0 +1,94 @@
1
+ // APPROVALS-AUTO (owner 2026-11-06: "just make approvals pop up, i am tired of opening them"): pure rules for opening a
2
+ // newly arrived approval or decision BY ITSELF as the /approvals walkthrough card. It only ever OPENS a card; it never
3
+ // answers one. Keystrokes are never stolen: a draft in the box, typing in the last TYPING_PAUSE_MS, a streaming turn or
4
+ // another ask on screen holds it, and it opens at the next idle point. A walkthrough already open simply gains the card.
5
+ import { matchesAgent } from './approvalsView.js';
6
+
7
+ export const TYPING_PAUSE_MS = 2000;
8
+ /** How long after a "N more arrived" notice a bare y/n still belongs to the card, not the chat. */
9
+ export const STRAY_WINDOW_MS = 30000;
10
+
11
+ /** The /approvals auto setting: ON unless the profile explicitly says false. */
12
+ export const autoOn = (settings) => !(settings && settings.approvalsAuto === false);
13
+
14
+ const keyOf = (orgId, id) => `${orgId || ''}|${id}`;
15
+
16
+ /** @returns {{ enabled: boolean, seen: Set<string>, seeded: Set<string>, queue: object[] }} */
17
+ export function createAutoOpen({ enabled = true } = {}) {
18
+ return { enabled, seen: new Set(), seeded: new Set(), queue: [] };
19
+ }
20
+
21
+ /**
22
+ * One poll of open items for one org (orgId null = the window's own org). The first read of each org only seeds what was
23
+ * already waiting (nothing pops at launch). New ids queue once; ids in skipIds (shown inline as this conversation's card,
24
+ * or answered here) never queue; a queued item the server no longer lists leaves the queue. While switched off, arrivals
25
+ * are marked seen so turning it on never replays them.
26
+ * Cross-org items carry __orgId and __orgName. seed overrides the first-read rule (false: queue even on a first read).
27
+ */
28
+ export function noteArrivals(st, items, { skipIds = null, orgId = null, orgName = null, seed = null } = {}) {
29
+ const list = (Array.isArray(items) ? items : []).filter((x) => x && x.id);
30
+ const org = orgId || '';
31
+ const first = seed === null ? !st.seeded.has(org) : Boolean(seed); // seed:false = an org first read after launch: what it has is new
32
+ st.seeded.add(org);
33
+ const live = new Set(list.map((x) => keyOf(orgId, x.id)));
34
+ st.queue = st.queue.filter((x) => (x.__orgId || '') !== org || live.has(keyOf(orgId, x.id)));
35
+ const added = [];
36
+ for (const it of list) {
37
+ const k = keyOf(orgId, it.id);
38
+ if (st.seen.has(k)) continue;
39
+ st.seen.add(k);
40
+ if (first || !st.enabled || (skipIds && skipIds.has(it.id))) continue;
41
+ const item = orgId ? { ...it, __orgId: orgId, __orgName: orgName || orgId } : it;
42
+ st.queue.push(item); added.push(item);
43
+ }
44
+ return { added };
45
+ }
46
+
47
+ /**
48
+ * Whether to open now: 'open' | 'join' (a walk is on screen: append, its own order shows it) | 'wait-draft' |
49
+ * 'wait-typing' | 'wait-turn' | 'wait-ask' | null (nothing queued, or switched off).
50
+ */
51
+ export function openDecision(st, { draft = '', lastKeyAt = 0, now = Date.now(), streaming = false, walkOpen = false, askOpen = false } = {}) {
52
+ if (!st.enabled || !st.queue.length) return null;
53
+ if (walkOpen) return 'join';
54
+ if (String(draft || '') !== '') return 'wait-draft';
55
+ if (now - (lastKeyAt || 0) < TYPING_PAUSE_MS) return 'wait-typing';
56
+ if (streaming) return 'wait-turn';
57
+ if (askOpen) return 'wait-ask';
58
+ return 'open';
59
+ }
60
+
61
+ /** Hands the queued items over once. */
62
+ export function takeQueue(st) { const q = st.queue; st.queue = []; return q; }
63
+
64
+ /**
65
+ * A submitted line that is a bare yes/no while a card is queued (or just after a "more arrived" notice) belongs to that
66
+ * card: the caller opens the card and keeps the key for it. Never chat.
67
+ */
68
+ export function strayAnswer(text, { queued = 0, walkOpen = false, noticeAt = 0, now = Date.now() } = {}) {
69
+ if (walkOpen) return false;
70
+ if (!/^(y|n|yes|no)$/i.test(String(text ?? '').trim())) return false;
71
+ return queued > 0 || (noticeAt > 0 && now - noticeAt < STRAY_WINDOW_MS);
72
+ }
73
+
74
+ export const APPROVALS_OPTIONS = 'help, walkthrough, N, mine, agents, old, <agent name>, auto on|off';
75
+
76
+ /** /approvals arguments -> { kind: list|help|walkthrough|detail|filter|auto|unknown }. An unknown word says so, never lists. */
77
+ export function parseApprovalsArg(arg, { items = [] } = {}) {
78
+ const t = String(arg ?? '').trim();
79
+ if (!t) return { kind: 'list' };
80
+ if (/^help$/i.test(t)) return { kind: 'help' };
81
+ if (/^walkthrough$/i.test(t)) return { kind: 'walkthrough' };
82
+ if (/^\d+$/.test(t)) return { kind: 'detail', n: Number(t) };
83
+ const m = /^auto(?:\s+(\S+))?$/i.exec(t);
84
+ if (m) {
85
+ if (!m[1]) return { kind: 'auto', set: null };
86
+ if (/^on$/i.test(m[1])) return { kind: 'auto', set: true };
87
+ if (/^off$/i.test(m[1])) return { kind: 'auto', set: false };
88
+ return { kind: 'unknown', word: t };
89
+ }
90
+ const k = t.toLowerCase();
91
+ if (k === 'mine' || k === 'agents' || k === 'old') return { kind: 'filter', f: k };
92
+ if ((items || []).some((it) => matchesAgent(it, t))) return { kind: 'filter', f: t };
93
+ return { kind: 'unknown', word: t };
94
+ }
@@ -0,0 +1,59 @@
1
+ // Does a shell command start detached work? Then the approver's "it ran fine" only covers the launch. Pure: text in, boolean out.
2
+ // Quote-aware: '&' inside quotes, '&&', redirects (2>&1, >&2, &>f), '|&', a backslash-escaped '\&' and '# comments' are not background.
3
+
4
+ export const BACKGROUND_NOTE = 'starts background work: the result you see is only the launch, not the outcome';
5
+
6
+ const WRAPPERS = new Set(['sudo', 'env', 'command', 'exec', 'time', 'nice', 'ionice', 'doas', 'stdbuf', 'timeout']);
7
+ const DETACHERS = new Set(['nohup', 'setsid', 'disown', 'at', 'batch']);
8
+
9
+ /** One command's words (quotes removed) -> true when its command word detaches. */
10
+ function segmentDetaches(words) {
11
+ let i = 0;
12
+ while (i < words.length) {
13
+ const w = words[i];
14
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) { i++; continue; } // FOO=1 cmd
15
+ if (WRAPPERS.has(w)) { i++; while (i < words.length && /^-/.test(words[i])) i++; continue; }
16
+ break;
17
+ }
18
+ const cmd = (words[i] || '').replace(/^.*\//, '');
19
+ const rest = words.slice(i + 1);
20
+ if (DETACHERS.has(cmd)) return true;
21
+ if (cmd === 'systemd-run') return !rest.some(a => /^(--wait|--pipe|--pty|--scope|--shell|-P|-t|-S)$/.test(a)); // these wait for the unit; plain systemd-run returns at once
22
+ if (cmd === 'screen') return rest.some(a => /^-[A-Za-z]*d[A-Za-z]*m?[A-Za-z]*$/.test(a) && /^-[A-Za-z]+$/.test(a) && (/d/.test(a)) && (/m/.test(a) || rest.includes('-m'))); // screen -d -m / -dm / -dmS
23
+ if (cmd === 'tmux') return /^(new|new-session)$/.test(rest[0] || '') && rest.slice(1).some(a => /^-[A-Za-z]*d[A-Za-z]*$/.test(a));
24
+ if (cmd === 'docker') {
25
+ const sub = rest.filter(a => !/^-/.test(a));
26
+ if (sub[0] === 'run') return rest.some(a => a === '--detach' || /^-[A-Za-z]*d[A-Za-z]*$/.test(a));
27
+ if (sub[0] === 'compose' && rest.includes('up')) return rest.some(a => a === '--detach' || /^-[A-Za-z]*d[A-Za-z]*$/.test(a));
28
+ return false;
29
+ }
30
+ if (cmd === 'docker-compose') return rest[0] === 'up' && rest.some(a => a === '--detach' || /^-[A-Za-z]*d[A-Za-z]*$/.test(a));
31
+ if (cmd === 'systemctl') return rest.includes('--no-block') && rest.some(a => /^(start|restart|reload|try-restart|reload-or-restart|stop|isolate)$/.test(a));
32
+ return false;
33
+ }
34
+
35
+ export function startsBackgroundWork(text) {
36
+ const s = typeof text === 'string' ? text : '';
37
+ if (!s) return false;
38
+ let words = []; let cur = null; let q = null; let found = false;
39
+ const endWord = () => { if (cur !== null) { words.push(cur); cur = null; } };
40
+ const endSeg = () => { endWord(); if (words.length && segmentDetaches(words)) found = true; words = []; };
41
+ for (let i = 0; i < s.length && !found; i++) {
42
+ const ch = s[i];
43
+ if (q) { if (ch === q) q = null; else if (q === '"' && ch === '\\' && i + 1 < s.length) { cur += s[++i]; } else cur += ch; continue; }
44
+ if (ch === '\\') { cur = (cur ?? '') + (s[++i] ?? ''); continue; }
45
+ if (ch === "'" || ch === '"') { q = ch; cur = cur ?? ''; continue; }
46
+ if (ch === '#' && cur === null) { while (i < s.length && s[i] !== '\n') i++; endSeg(); continue; }
47
+ if (/\s/.test(ch)) { if (ch === '\n') endSeg(); else endWord(); continue; }
48
+ if (ch === '&') {
49
+ if (s[i + 1] === '&') { endSeg(); i++; continue; }
50
+ const prev = s[i - 1];
51
+ if (s[i + 1] === '>' || prev === '>' || prev === '<' || prev === '|') { cur = (cur ?? '') + ch; continue; } // &>f, 2>&1, <&3, |&
52
+ endSeg(); found = true; break; // a lone '&' detaches the command before it
53
+ }
54
+ if (ch === ';' || ch === '|' || ch === '(' || ch === ')' || ch === '{' || ch === '}') { endSeg(); continue; }
55
+ cur = (cur ?? '') + ch;
56
+ }
57
+ if (!found) endSeg();
58
+ return found;
59
+ }
@@ -187,7 +187,7 @@ export function runBang(plan, { cwd = process.cwd(), env = process.env, timeoutM
187
187
  /** The "exit N · 1.2s" footer, plus the plain-words reasons. */
188
188
  export function footer(r, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
189
189
  const secs = `${(r.ms / 1000).toFixed(1)}s`;
190
- if (r.timedOut) return `timed out after ${Math.round(timeoutMs / 1000)}s and was stopped · ${secs} (QUOX_BANG_TIMEOUT_MS changes the limit)`;
190
+ if (r.timedOut) return `timed out after ${Math.round(timeoutMs / 1000)}s · stopped; anything it already started may have completed. Run long commands in a terminal, or set QUOX_BANG_TIMEOUT_MS · ${secs}`;
191
191
  if (r.killed) return `stopped (Ctrl+C) · ${secs}`;
192
192
  if (r.signal) return `killed by ${r.signal} · ${secs}`;
193
193
  return `exit ${r.code} · ${secs}`;
@@ -45,6 +45,11 @@ export function offerFor(capabilityId, res) {
45
45
  }
46
46
  }
47
47
 
48
+ /** -> display names of every requires_setup capability, in server order. */
49
+ export function needsSetupNames(caps) {
50
+ return (Array.isArray(caps) ? caps : []).filter(c => c.availability === 'requires_setup').map(capabilityName);
51
+ }
52
+
48
53
  /** -> [[text, tone?]] for /capabilities. */
49
54
  export function capabilityLines(res) {
50
55
  if (!res?.ok || !Array.isArray(res.capabilities)) {
@@ -59,7 +64,7 @@ export function capabilityLines(res) {
59
64
  if (!installed.length) out.push([' nothing is ready yet']);
60
65
  const setup = of('requires_setup');
61
66
  if (setup.length) {
62
- out.push(['Needs setup:']);
67
+ out.push([`Needs setup (${setup.length}): ${needsSetupNames(caps).join(', ')}`]);
63
68
  for (const c of setup) out.push([`◐ ${capabilityName(c)} ${c.reason || 'needs setup'}${c.setup ? ` · ${c.setup}` : ''}`, 'warn']);
64
69
  }
65
70
  const unk = caps.filter(statusUnreadable);
@@ -70,7 +75,7 @@ export function capabilityLines(res) {
70
75
  const off = of('unavailable').filter(c => !statusUnreadable(c));
71
76
  if (off.length) {
72
77
  out.push(['Not available here:']);
73
- for (const c of off) out.push([`○ ${capabilityName(c)} ${c.reason || 'unavailable'}${c.setup ? ` · ${c.setup}` : ''}`]);
78
+ for (const c of off) out.push([`○ ${capabilityName(c)} ${c.reason === 'No signal to check' ? 'not checked: nothing here reports on it yet' : (c.reason || 'unavailable')}${c.setup ? ` · ${c.setup}` : ''}`]);
74
79
  }
75
80
  const planned = of('planned');
76
81
  if (planned.length) {
@@ -8,6 +8,7 @@ export const SHORT_MAX = 54;
8
8
  export const SHORT = {
9
9
  walkthrough: 'a guided pass: what it does, why, my advice',
10
10
  inbox: 'your Quox inbox: latest items, read, open',
11
+ mail: 'your triaged email, read only: latest, or N to read',
11
12
  approvals: 'the questions waiting for you, one at a time',
12
13
  peek: 'look up an envelope, item, run or receipt by id',
13
14
  aside: 'a read-only side question, kept out of the chat',
@@ -17,6 +18,7 @@ export const SHORT = {
17
18
  answer: 'answer a question a session asked you',
18
19
  authenticate: 'enter your PIN once, good for 10 minutes',
19
20
  override: 'override one blocked action (PIN, witnessed)',
21
+ 'verify-email': 'mark a user email verified (admin, PIN, audited)',
20
22
  skills: 'list your org\'s governed skills, or show one',
21
23
  skill: 'ask Quox to run a skill (approvals still apply)',
22
24
  rules: 'which rules win: QUOX.md, dev pack, server policy',
@@ -50,6 +52,8 @@ export const SHORT = {
50
52
  demo: 'play the simulated demo; nothing real runs',
51
53
  feedback: 'tell the Quox team something (you see it first)',
52
54
  gripe: 'file a GitHub issue; /gripe! files at once',
55
+ gripes: 'gripes waiting to be filed, and recent ones',
56
+ outbox: 'messages waiting for QuoxCORE to answer',
53
57
  sessions: 'everything live in your org, and where it runs',
54
58
  active: 'who is doing what, one line per worker',
55
59
  dashboard: 'open the web dashboard already signed in',
@@ -97,6 +101,9 @@ export const SHORT = {
97
101
  org: 'list your orgs, switch to one, or go back',
98
102
  r: 'reply to whoever last messaged you (also /reply)',
99
103
  poke: 'wake a session with an urgent page (also /wake)',
104
+ donotdisturb: 'sleep means sleep: collect quietly, read at morning',
105
+ morningreport: 'what came in while do not disturb was on',
106
+ callmeforapprovals: 'phone me when a high-risk approval sits unanswered',
100
107
  afk: 'tell sessions you are away; approvals still queue',
101
108
  back: 'mark yourself present and show what you missed',
102
109
  observe: 'post a note to the session watching your window',