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
@@ -25,6 +25,7 @@
25
25
  "admin retention stats":"read",
26
26
  "admin service list":"read",
27
27
  "admin stats":"read",
28
+ "admin users verify-email":"write",
28
29
  "aee conversations":"read",
29
30
  "aee get":"read",
30
31
  "aee query":"read",
@@ -34,6 +35,7 @@
34
35
  "agent attach":"never",
35
36
  "agent attach-heartbeat":"never",
36
37
  "agent attach-status":"read",
38
+ "agent attaches":"write",
37
39
  "agent bind-slot":"write",
38
40
  "agent create":"write",
39
41
  "agent deactivate":"write",
@@ -53,11 +55,16 @@
53
55
  "agent tool revoke":"write",
54
56
  "agent unbind-slot":"write",
55
57
  "agent update":"write",
58
+ "approvals approve":"write",
59
+ "approvals call-me":"write",
60
+ "approvals deny":"write",
56
61
  "approvals grants create":"write",
57
62
  "approvals grants list":"read",
58
63
  "approvals grants revoke":"write",
64
+ "approvals list":"read",
59
65
  "approvals result":"read",
60
66
  "approvals walkthrough":"never",
67
+ "approvals withdraw":"write",
61
68
  "aside":"never",
62
69
  "ask loosening":"write",
63
70
  "ask status":"read",
@@ -147,12 +154,16 @@
147
154
  "chat-status":"read",
148
155
  "claude":"never",
149
156
  "completion":"never",
157
+ "compliance jobs list":"read",
158
+ "compliance reports list":"read",
159
+ "compliance reports show":"read",
150
160
  "config get":"read",
151
161
  "config profile add":"write",
152
162
  "config profile list":"read",
153
163
  "config profile remove":"write",
154
164
  "config profile use":"write",
155
165
  "config set":"write",
166
+ "connect":"write",
156
167
  "consent grant":"never",
157
168
  "consent history":"read",
158
169
  "consent list":"read",
@@ -374,6 +385,7 @@
374
385
  "inbox list":"read",
375
386
  "inbox respond":"write",
376
387
  "inbox stats":"read",
388
+ "inbox unread":"write",
377
389
  "inbox walkthrough":"never",
378
390
  "instagram accounts":"read",
379
391
  "instagram comments":"read",
@@ -409,6 +421,7 @@
409
421
  "kuma monitors pause":"write",
410
422
  "kuma monitors remove":"write",
411
423
  "kuma monitors resume":"write",
424
+ "kuma monitors wire-alerts":"write",
412
425
  "kuma refresh":"write",
413
426
  "kuma sla list":"read",
414
427
  "kuma sla verify":"read",
@@ -428,6 +441,7 @@
428
441
  "linkedin org":"write",
429
442
  "linkedin post":"write",
430
443
  "linkedin posts":"read",
444
+ "linkedin whoami":"read",
431
445
  "llm status":"read",
432
446
  "llm-policy get":"read",
433
447
  "llm-policy set":"write",
@@ -460,6 +474,7 @@
460
474
  "lucid teach":"write",
461
475
  "lucid told":"read",
462
476
  "lucid why":"read",
477
+ "mail account pin":"write",
463
478
  "mail accounts":"read",
464
479
  "mail connect":"never",
465
480
  "mail disconnect":"write",
@@ -471,10 +486,12 @@
471
486
  "mail report":"write",
472
487
  "mail resolve":"write",
473
488
  "mail sync":"write",
489
+ "mail sync-status":"write",
474
490
  "mail undo":"write",
475
491
  "mail watch active":"write",
476
492
  "mail watch add":"write",
477
493
  "mail watch list":"read",
494
+ "mail watch rm":"write",
478
495
  "mail zones":"read",
479
496
  "memory core":"read",
480
497
  "memory create":"write",
@@ -488,6 +505,7 @@
488
505
  "memory reinstate":"write",
489
506
  "memory search":"read",
490
507
  "memory stats":"read",
508
+ "memory sync":"never",
491
509
  "memory timeline":"read",
492
510
  "memory unpin":"write",
493
511
  "memory update":"write",
@@ -515,12 +533,17 @@
515
533
  "mon docker stats":"read",
516
534
  "mon metrics":"read",
517
535
  "mon wazuh active-response":"write",
518
- "mon wazuh agents":"read",
536
+ "mon wazuh agents get":"read",
519
537
  "mon wazuh alerts":"read",
538
+ "mon wazuh coverage":"read",
520
539
  "mon wazuh deploy-agent":"write",
521
540
  "mon wazuh deploy-bulk":"write",
522
541
  "mon wazuh deploy-history":"read",
542
+ "mon wazuh fim":"read",
543
+ "mon wazuh rootcheck":"read",
544
+ "mon wazuh sca":"read",
523
545
  "mon wazuh status":"read",
546
+ "mon wazuh triage":"read",
524
547
  "mon wazuh vulns":"read",
525
548
  "monitoring health":"read",
526
549
  "monitoring incidents get":"read",
@@ -555,6 +578,12 @@
555
578
  "notes schedule resume":"write",
556
579
  "notes schedule rm":"write",
557
580
  "notes scorecard":"write",
581
+ "notes seal edit":"never",
582
+ "notes seal init":"write",
583
+ "notes seal new":"write",
584
+ "notes seal open":"never",
585
+ "notes seal print":"write",
586
+ "notes seal status":"read",
558
587
  "notes search":"read",
559
588
  "notes settings list":"read",
560
589
  "notes settings set":"write",
@@ -772,6 +801,7 @@
772
801
  "server diag":"write",
773
802
  "server doctor":"read",
774
803
  "server go":"never",
804
+ "server install":"never",
775
805
  "server list":"read",
776
806
  "server mark":"write",
777
807
  "server roles list":"read",
@@ -881,8 +911,10 @@
881
911
  "tool exec":"write",
882
912
  "tool get":"read",
883
913
  "tool install":"write",
914
+ "tool installed":"read",
884
915
  "tool list":"read",
885
916
  "tool publish":"write",
917
+ "tool uninstall":"write",
886
918
  "tool update":"write",
887
919
  "trace compare":"read",
888
920
  "trace comparison create":"write",
@@ -982,6 +1014,35 @@
982
1014
  "volt verify":"read",
983
1015
  "watch fleet":"read",
984
1016
  "watch services":"read",
1017
+ "wazuh active-response":"write",
1018
+ "wazuh agents get":"read",
1019
+ "wazuh alerts":"read",
1020
+ "wazuh connect":"never",
1021
+ "wazuh coverage":"read",
1022
+ "wazuh deploy":"write",
1023
+ "wazuh deploy-bulk":"write",
1024
+ "wazuh deploy-history":"read",
1025
+ "wazuh fim":"read",
1026
+ "wazuh hooks create":"never",
1027
+ "wazuh hooks delete":"write",
1028
+ "wazuh hooks install":"never",
1029
+ "wazuh hooks list":"read",
1030
+ "wazuh hooks rotate":"never",
1031
+ "wazuh incidents close":"write",
1032
+ "wazuh incidents diagnose":"write",
1033
+ "wazuh incidents escalate":"write",
1034
+ "wazuh incidents show":"read",
1035
+ "wazuh indexer install-credential":"write",
1036
+ "wazuh remove":"write",
1037
+ "wazuh report generate":"write",
1038
+ "wazuh report list":"read",
1039
+ "wazuh report show":"read",
1040
+ "wazuh report verify":"read",
1041
+ "wazuh rootcheck":"read",
1042
+ "wazuh sca":"read",
1043
+ "wazuh status":"read",
1044
+ "wazuh triage":"read",
1045
+ "wazuh vulns":"read",
985
1046
  "webhook create":"write",
986
1047
  "webhook delete":"write",
987
1048
  "webhook list":"read",
@@ -1015,6 +1076,7 @@
1015
1076
  "x status":"read",
1016
1077
  "x thread":"write",
1017
1078
  "x timeline":"write",
1079
+ "x whoami":"write",
1018
1080
  "youtube analytics":"write",
1019
1081
  "youtube channel":"write",
1020
1082
  "youtube comment":"write",
@@ -122,7 +122,7 @@ export function createHatWalk({ api, say, bump = () => {} }) {
122
122
  if (!m.hat) { say(`No hat matches "${arg}". Your own hats: ${hatChoices(mine) || 'none yet'}.`, 'warn'); return null; }
123
123
  if (!isCustom(m.hat)) {
124
124
  say(verb === 'edit'
125
- ? `${nameOf(m.hat)} is a built-in hat, so it is not edited here. It can be reset, or used as a starting point with /hat new.`
125
+ ? `${nameOf(m.hat)} is a built-in hat, so it is not edited here. Reset it, or start from it with /hat new.`
126
126
  : `${nameOf(m.hat)} is a built-in hat and cannot be deleted. It can be reset, or used as a starting point with /hat new.`, 'warn');
127
127
  return null;
128
128
  }
@@ -16,6 +16,10 @@ export function paletteQuery(draft) {
16
16
  return t.slice(1).toLowerCase();
17
17
  }
18
18
 
19
+ const TRANSIENT = /not running|not reachable|unreachable|could not reach|cannot reach|connection (refused|reset)|timed? ?out|econn|enotfound|fetch failed|service unavailable|\b50[234]\b/i;
20
+ /** Does this capability reason read as a failed dynamic check rather than a standing fact about the command? */
21
+ export const transientReason = (r) => TRANSIENT.test(String(r || ''));
22
+
19
23
  /**
20
24
  * Why a command cannot run in this session, or null. `api` is the session's server wrapper (a method
21
25
  * missing means this mode has no route for it); `caps` is GET /api/v1/capabilities when the server has
@@ -23,7 +27,10 @@ export function paletteQuery(draft) {
23
27
  */
24
28
  export function unavailableReason(entry, { api = {}, caps = null, blocked = null } = {}) {
25
29
  const cap = caps ? caps[entry.cap || entry.id] : null;
26
- if (cap && cap.available === false) return cap.reason || 'not available on this server';
30
+ // A reason that is really a failed probe ("Auth service not running or not reachable...") is an error string, not a
31
+ // property of the command: it never becomes the palette note. The row keeps its static description; running the
32
+ // command reports the real failure.
33
+ if (cap && cap.available === false && !transientReason(cap.reason)) return cap.reason || 'not available on this server';
27
34
  for (const m of entry.needs || []) if (typeof api[m] !== 'function') return 'not available in this mode';
28
35
  if (blocked && entry.server) return '(sign in)';
29
36
  return null;
@@ -102,6 +109,20 @@ export function filterPalette(items, query) {
102
109
  return scored.sort((a, b) => a.best - b.best || a.idx - b.idx).map(x => x.it);
103
110
  }
104
111
 
112
+ /** Bulk or destructive commands: never offered (so never pre-highlighted) by a loose query (owner 2026-10-26: Enter on "/" approved everything). */
113
+ export const GUARDED_IDS = new Set(['yestoall', 'approve', 'deny']);
114
+
115
+ /**
116
+ * Drops GUARDED_IDS rows unless the query is an exact name, or a 3+ letter prefix of the name that no unguarded command
117
+ * shares. So they are reachable only by typing enough of the name to make them the unique match.
118
+ */
119
+ export function guardBulk(list, query) {
120
+ const q = String(query ?? '').toLowerCase();
121
+ const names = (it) => [it.id, ...it.aliases.map(a => a.slice(1))].map(n => n.toLowerCase());
122
+ const shared = list.some(o => !GUARDED_IDS.has(o.id) && names(o).some(n => q && n.startsWith(q)));
123
+ return list.filter(it => !GUARDED_IDS.has(it.id) || names(it).includes(q) || (q.length >= 3 && !shared && names(it).some(n => n.startsWith(q))));
124
+ }
125
+
105
126
  /** What Tab puts in the draft: the name, plus a space when the command takes arguments. */
106
127
  export function completion(item) {
107
128
  const takesArgs = /^\S+\s+\S/.test(String(item.usage).trim());
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { findEntry } from '../commands.js';
7
7
  import { signedOutNotice } from '../sessionDead.js';
8
- import { paletteQuery, paletteItems, filterPalette, completion, paletteView, PALETTE_MAX } from './palette.js';
8
+ import { paletteQuery, paletteItems, filterPalette, guardBulk, completion, paletteView, PALETTE_MAX } from './palette.js';
9
9
  import { argContext, levelFor, staticItems, rankArgs, applyArg } from './args.js';
10
10
  import { mentionContext, rankMentions, applyMention } from '../mentions.js';
11
11
 
@@ -21,7 +21,7 @@ export function createPalette({
21
21
  getDraft, setDraft, run, say, ctx = () => ({}), source = null, refresh = () => {}, now = Date.now,
22
22
  mentions = null // MENTIONS: { cache (mentions.js createMentionCache), local: () => receipt items }
23
23
  }) {
24
- const st = { sel: 0, q: null, dismissed: null, picked: false, aq: null, mq: null };
24
+ const st = { hold: null, sel: 0, q: null, dismissed: null, picked: false, aq: null, mq: null };
25
25
  const cache = new Map(); // 'src:param' -> { at, ttl, items, pending }
26
26
  /** Cached items for an async source; fetches when missing or stale. Never throws or blocks. */
27
27
  const fetchItems = (src, param) => {
@@ -77,10 +77,14 @@ export function createPalette({
77
77
  const rows = () => {
78
78
  const q = paletteQuery(getDraft());
79
79
  if (q !== st.q) { st.q = q; st.sel = 0; st.picked = false; }
80
- return q === null ? [] : filterPalette(paletteItems(ctx()), q);
80
+ return q === null ? [] : guardBulk(filterPalette(paletteItems(ctx()), q), q);
81
81
  };
82
82
  const isOpen = () => {
83
83
  const d = getDraft();
84
+ if (st.hold !== null) { // a command was just submitted: closed until the draft is genuinely edited (owner 2026-10-27: a stale palette ran /sleep)
85
+ if (d === st.hold || d === '') return false;
86
+ st.hold = null;
87
+ }
84
88
  if (st.dismissed !== null && st.dismissed !== d) st.dismissed = null;
85
89
  if (paletteQuery(d) === null) return argOpen() !== null || mentionOpen() !== null;
86
90
  return st.dismissed !== d;
@@ -165,5 +169,7 @@ export function createPalette({
165
169
  hint: '↑↓ select · Tab complete · Enter run · Esc close'
166
170
  };
167
171
  }
168
- return { isOpen, onKey, view, state: st };
172
+ /** The app calls this when Enter submits a command line: the palette closes and that Enter activates nothing. */
173
+ const submitted = (draft = getDraft()) => { st.hold = String(draft ?? ''); st.sel = 0; st.picked = false; };
174
+ return { isOpen, onKey, view, submitted, state: st };
169
175
  }
@@ -170,6 +170,8 @@ export const RESTART_NOTE_ENV = 'QUOX_RESTART_NOTE';
170
170
  export const RESTART_ORG_ENV = 'QUOX_RESTART_ORG';
171
171
  /** Env var carrying the window's /mode (PLAN MODE: only 'plan' is carried; ask is the default) to the restarted successor. */
172
172
  export const RESTART_MODE_ENV = 'QUOX_RESTART_MODE';
173
+ /** Env var carrying the slot this window declined to reattach to ("n" at the startup ask), so a restart does not re-ask. */
174
+ export const RESTART_DECLINED_ENV = 'QUOX_RESTART_DECLINED';
173
175
 
174
176
  /** Terminal modes the client turns on (bracketed paste, focus reporting, hidden cursor): all off before the hand-over. */
175
177
  export const TERMINAL_RESTORE = '\x1b[?2004l\x1b[?1004l\x1b[?25h\x1b[?1006l\x1b[?1002l\x1b[?1000l'; // bracketed paste, focus, cursor; mouse tracking off (QUOXON-MOUSE)
@@ -184,13 +186,14 @@ export function restoreTerminal({ stdin = process.stdin, stdout = process.stdout
184
186
  * What a restart runs: { cmd, args, env }. The installed quox (or this tree), the same arguments with --resume
185
187
  * for this conversation, and the one-shot hand-over env (held slot, attach, org, plan mode) for the successor.
186
188
  */
187
- export function restartPlan({ argv, conversationId, profile = null, name = null, slot = null, attach = null, org = null, mode = null, env = process.env, bin = findOnPath('quox', env) }) {
189
+ export function restartPlan({ argv, conversationId, profile = null, name = null, slot = null, attach = null, org = null, mode = null, declined = null, env = process.env, bin = findOnPath('quox', env) }) {
188
190
  const args = restartArgs(argv, conversationId, { profile, name });
189
191
  let childEnv = profile && !argv.includes('--demo') ? { ...env, QUOX_PROFILE: profile } : env; // belt and braces: same profile even if -p were lost
190
192
  if (slot) childEnv = { ...childEnv, [RESTART_SLOT_ENV]: slot }; // the held slot rides along; the new client takes it over before this one is gone
191
193
  if (attach) childEnv = { ...childEnv, [RESTART_ATTACH_ENV]: attach }; // so does the attached agent or slot
192
194
  if (org) childEnv = { ...childEnv, [RESTART_ORG_ENV]: org }; // and the window's org (never the profile's file)
193
195
  if (mode === 'plan') childEnv = { ...childEnv, [RESTART_MODE_ENV]: 'plan' }; // and the window's /mode (never the profile)
196
+ if (declined) childEnv = { ...childEnv, [RESTART_DECLINED_ENV]: declined }; // and a declined reattach (never re-asked after a restart)
194
197
  const [cmd, lead] = restartTarget(env, bin);
195
198
  return { cmd, args: [...lead, ...args], env: childEnv };
196
199
  }
@@ -201,15 +204,15 @@ export function restartPlan({ argv, conversationId, profile = null, name = null,
201
204
  * Only used when there is no launcher (QUOX_NO_LAUNCHER=1, or no handoff dir): it NESTS the successor under
202
205
  * this process, which stays resident until the successor exits. The launcher path (launcher.js) does not.
203
206
  */
204
- export function execRestart({ argv, conversationId, profile = null, name = null, slot = null, attach = null, org = null, mode = null, env = process.env, spawn = spawnSync, bin = findOnPath('quox', env), restore = restoreTerminal }) {
205
- const plan = restartPlan({ argv, conversationId, profile, name, slot, attach, org, mode, env, bin });
207
+ export function execRestart({ argv, conversationId, profile = null, name = null, slot = null, attach = null, org = null, mode = null, declined = null, env = process.env, spawn = spawnSync, bin = findOnPath('quox', env), restore = restoreTerminal }) {
208
+ const plan = restartPlan({ argv, conversationId, profile, name, slot, attach, org, mode, declined, env, bin });
206
209
  restore(); // the child owns the terminal now
207
210
  const r = spawn(plan.cmd, plan.args, { stdio: 'inherit', env: plan.env });
208
211
  if (r.error) return { error: `${r.error.code || 'failed'}: ${r.error.message}`, bin: plan.cmd };
209
212
  return { code: r.status ?? (r.signal ? 128 + (os.constants.signals[r.signal] || 0) : 1), bin: plan.cmd };
210
213
  }
211
214
 
212
- export const SERVER_WAIT_EVERY_MS = 2000; // poll the collector /health this often while a restart waits for it
215
+ export const SERVER_WAIT_EVERY_MS = 10000; // poll the collector /health this often while a restart waits for it
213
216
  export const SERVER_WAIT_MAX_MS = 3 * 60 * 1000; // ...for up to this long, then carry on as before
214
217
 
215
218
  /** Poll `probe` (-> { ok } | truthy/falsy, may throw) until it answers ok. -> true when healthy, false on timeout or cancel. */
@@ -211,7 +211,7 @@ export function createStepUp({ api, agentLogin = false, onAgentLogin = () => {},
211
211
  if (!card) return;
212
212
  if (res?.step_up_token) {
213
213
  const until = now() + WINDOW_MS;
214
- for (const s of res.scopes || card.scopes) tokens.set(s, { token: res.step_up_token, until });
214
+ if (!card.fresh) for (const s of res.scopes || card.scopes) tokens.set(s, { token: res.step_up_token, until }); // a fresh (one-shot) token is handed to the caller only, never kept
215
215
  if (card.window) presentUntil = until;
216
216
  card.phase = 'ok'; card.until = until; card.now = now(); refresh(); emit({ type: 'stepup.result', ok: true });
217
217
  await sleep(RESULT_HOLD_MS);
@@ -245,16 +245,16 @@ export function createStepUp({ api, agentLogin = false, onAgentLogin = () => {},
245
245
  view() { return card ? { ...card, filled: box.count(), tries, now: now() } : null; },
246
246
  /**
247
247
  * Ask for a person. Resolves { ok, token?, reason? } and never throws. Reuses a live token for the scope.
248
- * opts: { scope: 'approve'|'override', scopes?, orgId?, subject?, window?: true (the /authenticate window) }
248
+ * opts: { scope: 'approve'|'override', scopes?, orgId?, subject?, window?: true (the /authenticate window), fresh?: true (always ask for the PIN, never reuse a live token, never keep the one issued: for one-shot strict tokens) }
249
249
  */
250
- request({ scope = 'approve', scopes = null, orgId = null, subject = {}, window: asWindow = false, title = null } = {}) {
250
+ request({ scope = 'approve', scopes = null, orgId = null, subject = {}, window: asWindow = false, title = null, fresh = false } = {}) {
251
251
  if (typeof agentLogin === 'function' ? agentLogin() : agentLogin) { note(`This window is an agent login: ${NEEDS_PERSON}.`, 'warn'); onAgentLogin(); return Promise.resolve({ ok: false, reason: 'agent_login' }); }
252
- if (!asWindow && live(scope)) return Promise.resolve({ ok: true, token: live(scope).token, scopes: [scope], reused: true });
252
+ if (!asWindow && !fresh && live(scope)) return Promise.resolve({ ok: true, token: live(scope).token, scopes: [scope], reused: true });
253
253
  if (card) return Promise.resolve({ ok: false, reason: 'busy' });
254
254
  if (lockedUntil > now()) { note(`⛔ LOCKED · PIN entry is locked for ${mmss(lockedUntil - now())}. Use your phone or a human session.`, 'error'); return Promise.resolve({ ok: false, reason: 'locked' }); }
255
255
  tries = MAX_TRIES;
256
256
  box.clear();
257
- card = { phase: 'input', subject, title, scope, scopes: scopes || (asWindow ? ['approve', 'override'] : [scope]), orgId, window: asWindow, error: null, offset: 0 };
257
+ card = { phase: 'input', subject, title, scope, scopes: scopes || (asWindow ? ['approve', 'override'] : [scope]), orgId, window: asWindow, fresh, error: null, offset: 0 };
258
258
  refresh();
259
259
  return new Promise((r) => { resolveCard = r; });
260
260
  },
@@ -8,6 +8,8 @@
8
8
  * witnessed server-side. Honest about block types no gate honours yet.
9
9
  */
10
10
  import { mmss, NEEDS_PERSON } from './stepUp.js';
11
+ import { STEP_UP_HEADER } from '../../core/stepUpPrompt.js';
12
+ import { renderAnnounce } from '../../core/announce.js';
11
13
 
12
14
  export const BLOCK_TYPES = ['safety', 'budget', 'aocl', 'ward'];
13
15
  export const MIN_REASON = 8;
@@ -43,8 +45,79 @@ export function declinedText(reason) {
43
45
  }
44
46
  }
45
47
 
46
- export function createStepUpCmds({ api, stepUp, say, lastBlock = () => null, orgOf = () => null }) {
48
+ export const verifyEmailUsage = '/verify-email <email> --reason "<why>" [--user-id <id>] (instance admin; a person confirms with a PIN; audited)';
49
+ const PIN_SHAPED = /(^|\D)\d{6}(\D|$)/;
50
+ // eslint-disable-next-line no-control-regex
51
+ const ANSI = /\x1b\[[0-9;]*m/g;
52
+
53
+ /** Split on spaces, honouring "double" and 'single' quotes. */
54
+ function words(s) {
55
+ const out = []; let cur = ''; let q = null; let has = false;
56
+ for (const ch of String(s || '')) {
57
+ if (q) { if (ch === q) q = null; else cur += ch; continue; }
58
+ if (ch === '"' || ch === "'") { q = ch; has = true; continue; }
59
+ if (/\s/.test(ch)) { if (has || cur) out.push(cur); cur = ''; has = false; continue; }
60
+ cur += ch; has = true;
61
+ }
62
+ if (has || cur) out.push(cur);
63
+ return out;
64
+ }
65
+
66
+ /** -> { ok:true, email, reason, userId } | { ok:false, error }. Never accepts a PIN-shaped argument. */
67
+ export function parseVerifyEmail(args) {
68
+ const raw = String(args || '');
69
+ if (PIN_SHAPED.test(raw)) return { ok: false, error: 'That looks like a PIN. Never type your PIN after a command: it is asked for in a private card.' };
70
+ const w = words(raw);
71
+ let email = null; let reason = null; let userId = null;
72
+ for (let i = 0; i < w.length; i += 1) {
73
+ const t = w[i];
74
+ if (t === '--reason' || t.startsWith('--reason=')) {
75
+ const parts = t.startsWith('--reason=') ? [t.slice(9)] : [];
76
+ while (i + 1 < w.length && !w[i + 1].startsWith('--')) { i += 1; parts.push(w[i]); }
77
+ reason = parts.join(' ').trim();
78
+ } else if (t === '--user-id' || t.startsWith('--user-id=')) {
79
+ userId = t.startsWith('--user-id=') ? t.slice(10) : (w[i + 1] && !w[i + 1].startsWith('--') ? w[++i] : '');
80
+ } else if (!email && !t.startsWith('--')) email = t;
81
+ else return { ok: false, error: `Unexpected "${t}". Usage: ${verifyEmailUsage}` };
82
+ }
83
+ if (!email) return { ok: false, error: `Give the email address. Usage: ${verifyEmailUsage}` };
84
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { ok: false, error: `"${email}" is not an email address. Usage: ${verifyEmailUsage}` };
85
+ if (!reason) return { ok: false, error: `--reason is required: it is recorded in the audit log. Usage: ${verifyEmailUsage}` };
86
+ return { ok: true, email, reason, userId: userId || null };
87
+ }
88
+
89
+ export function createStepUpCmds({ api, stepUp, say, lastBlock = () => null, orgOf = () => null, confirm = null }) {
47
90
  return {
91
+ /** The composer line after the /verify-email preview: true when consumed as the y/n answer. */
92
+ answer: (text) => Boolean(confirm?.answer(text)),
93
+ pending: () => Boolean(confirm?.pending()),
94
+ touch: () => confirm?.touch(),
95
+ /**
96
+ * /verify-email: the TUI front of `quox admin users verify-email`. Same endpoint, same scope ('override'); the
97
+ * server (instance admin, human caller, one-shot step-up token, reason) is the only gate. Preview + y/N first,
98
+ * then the hidden PIN card, always fresh: a live /authenticate token is never reused or spent here.
99
+ */
100
+ async verifyEmail(args) {
101
+ const p = parseVerifyEmail(args);
102
+ if (!p.ok) return say(p.error, 'warn');
103
+ if (!confirm) return say('/verify-email is not available in this mode.', 'warn');
104
+ const verb = `admin verify-email ${p.email}`;
105
+ const glyph = (opts) => renderAnnounce('quox', verb, opts).replace(ANSI, '');
106
+ say(`This will mark ${p.email} as email-verified WITHOUT a mailbox check${p.userId ? ` (only if it belongs to user ${p.userId})` : ''}. Reason (recorded in the audit log under your name): ${p.reason}`, 'warn');
107
+ say('A person must confirm with the step-up PIN next, asked in a private card. Instance admins only.');
108
+ return confirm.ask([], async () => {
109
+ const r = await stepUp.request({ scope: 'override', fresh: true, orgId: orgOf(), title: 'VERIFYING AN EMAIL NEEDS A PERSON', subject: { line1: `verify ${p.email}` } });
110
+ if (!r.ok) return say(`Not verified: ${declinedText(r.reason)}.`, 'warn');
111
+ try {
112
+ const data = await api.verifyEmail({ email: p.email, reason: p.reason, userId: p.userId, stepUp: { [STEP_UP_HEADER]: r.token } });
113
+ say(glyph({ result: `verified · user ${data.user_id}` }), 'ok');
114
+ say(`Email ${data.email} is now verified. Reason recorded in the audit log.`, 'ok');
115
+ } catch (e) {
116
+ say(glyph({ result: String(e?.body?.message || e?.body?.error || e?.message || e).replace(/\s+/g, ' ').trim(), status: 'fail' }), 'error');
117
+ }
118
+ return undefined;
119
+ }, { prompt: 'Go ahead and ask for your PIN?' });
120
+ },
48
121
  async authenticate() {
49
122
  const r = await stepUp.request({ window: true, orgId: orgOf(), subject: { line1: 'confirm a person is at this keyboard' } });
50
123
  if (!r.ok) return say(`Not authenticated: ${declinedText(r.reason)}.`, 'warn');
@@ -0,0 +1,35 @@
1
+ /**
2
+ * INSTANT-FEEDBACK (owner 2026-10-26: "no spinner... I want a whirly thing like the npm install cursor"): one shared
3
+ * helper for every async slash command. The dispatcher wraps the handler; if it is still pending after `firstMs`
4
+ * (under 100 ms) a `⠋ <verb>…` line appears and turns at `frameMs`; it goes away when the handler settles (the result
5
+ * line or the error replaces it), when the handler prints its first line (`stop`), or after `maxMs`.
6
+ */
7
+ export const SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
8
+
9
+ /** `/inbox read all` -> `inbox`; the verb is the command word without its slash. */
10
+ export const spinnerVerb = (line) => String(line || '').trim().split(/\s+/)[0].replace(/^\/+/, '') || 'working';
11
+ export const spinnerText = (verb, i = 0) => `${SPIN_FRAMES[i % SPIN_FRAMES.length]} ${verb}…`;
12
+
13
+ /**
14
+ * Run `fn` (returns a value or a promise). show(text) paints a frame, hide() clears it. register(stop) hands the caller
15
+ * a way to cancel the spinner early. Always resolves/rejects exactly as fn does.
16
+ */
17
+ export async function runWithSpinner(fn, { verb, show, hide, register, firstMs = 80, frameMs = 80, maxMs = 60000 } = {}) {
18
+ let frame = 0, shown = false, done = false, first = null, iv = null, cap = null;
19
+ const stop = () => {
20
+ done = true;
21
+ if (first) { clearTimeout(first); first = null; }
22
+ if (iv) { clearInterval(iv); iv = null; }
23
+ if (cap) { clearTimeout(cap); cap = null; }
24
+ if (shown) { shown = false; hide?.(); }
25
+ };
26
+ first = setTimeout(() => {
27
+ first = null;
28
+ if (done) return;
29
+ shown = true; show?.(spinnerText(verb, frame++));
30
+ iv = setInterval(() => { if (!done) show?.(spinnerText(verb, frame++)); }, frameMs);
31
+ cap = setTimeout(stop, maxMs);
32
+ }, firstMs);
33
+ register?.(stop);
34
+ try { return await fn(); } finally { stop(); }
35
+ }
@@ -0,0 +1,8 @@
1
+ // Plain-language slot attach (owner 2026-10-28): "attach to hitl slot" is a request for /slot hitl, so the window
2
+ // OFFERS that command (y/N) instead of sending the sentence to the model. Only these two phrasings match.
3
+ const ATTACH = /^\s*(?:attach\s+(?:me\s+)?to\s+(?:the\s+)?|attach\s+(?:the\s+)?|join\s+(?:the\s+)?)([A-Za-z0-9][A-Za-z0-9._-]{0,63})\s+slot\s*[.!]?\s*$/i;
4
+ export function parseSlotAttach(text) {
5
+ if (typeof text !== 'string' || text.includes('\n') || text.startsWith('/')) return null;
6
+ const m = ATTACH.exec(text);
7
+ return m ? m[1] : null;
8
+ }
@@ -116,6 +116,15 @@ export function slotOfHolder(rows, name) {
116
116
  return r ? r.name : null;
117
117
  }
118
118
 
119
+ /** REPLY-ADDRESS: a message with no from_slot gets one when its sender holds a slot (by held session name, else by the slot's own name). Returns m itself when nothing applies. */
120
+ export function withSenderSlot(rows, m) {
121
+ if (!m || m.from_slot) return m;
122
+ const n = String(m.from || '').replace(/\s*\(.*\)\s*$/, '').trim().toLowerCase();
123
+ const held = (Array.isArray(rows) ? rows : []).filter(x => x?.name && x.state !== 'free');
124
+ const slot = slotOfHolder(held, m.from) || held.find(x => String(x.name).toLowerCase() === n)?.name || null;
125
+ return slot ? { ...m, from_slot: slot } : m;
126
+ }
127
+
119
128
  /** A slot message as a chat line: { glyph, who, time, text, file }. */
120
129
  export function slotLine(m) {
121
130
  m = m && typeof m.text === 'string' ? { ...m, text: decodeEntities(m.text) } : m;
@@ -298,3 +307,11 @@ export function presenceLine(slot, presence, agoText) {
298
307
  const seen = presence.last_checked_in_at ? `last checked in ${agoText(presence.last_checked_in_at)}` : 'no check-in on record';
299
308
  return { text: `✔ delivered to ${slot} · not listening right now (${seen}); it sees this when it next checks`, tone: 'ok', listening: false };
300
309
  }
310
+
311
+ const DEPLOY_WINDOW_MS = 10 * 60 * 1000;
312
+ /** The "reattach is slow" line. During a deploy (restart signal seen, or the same URL answered within ~10 min) the stale-URL/env advice is wrong, so it says so calmly; a URL that never answered keeps the full diagnostic. */
313
+ export function reattachSlowLine({ slot, message, restartSeenAt = 0, lastGoodAt = 0, now = Date.now() }) {
314
+ const recent = (t) => Number(t) > 0 && now - Number(t) <= DEPLOY_WINDOW_MS;
315
+ if (recent(restartSeenAt) || recent(lastGoodAt)) return `slot ${slot}: QuoxCORE is restarting (a deploy); reconnecting, nothing is lost.`;
316
+ return `slot ${slot}: reattaching after the restart is slow (${message}); retrying.`;
317
+ }
@@ -153,7 +153,7 @@ function wrapRuns(runs, width, role, prefix) {
153
153
  let t = a.t;
154
154
  if (w + sep + len(t) > room && cur.length) newRow();
155
155
  else if (sep) { add({ t: ' ', role, bold: undefined }); w += 1; }
156
- while (len(t) > room) { const g = graphemes(t); add({ t: g.slice(0, room - w).join(''), role: a.role, bold: a.bold }); t = g.slice(room - w).join(''); newRow(); }
156
+ while (len(t) > room) { const g = graphemes(t).map(x => x.text); add({ t: g.slice(0, room - w).join(''), role: a.role, bold: a.bold }); t = g.slice(room - w).join(''); newRow(); }
157
157
  add({ t, role: a.role, bold: a.bold }); w += len(t);
158
158
  gap = false;
159
159
  }