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
@@ -11,6 +11,7 @@ import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync
11
11
  import { delimiter, dirname, join, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import os from 'node:os';
14
+ import { isWebPty, SHELL_REFUSED } from '../webPty.js';
14
15
 
15
16
  /** The CLI tree this code runs from (…/cli). */
16
17
  export const RUNNING_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
@@ -170,6 +171,8 @@ export const RESTART_NOTE_ENV = 'QUOX_RESTART_NOTE';
170
171
  export const RESTART_ORG_ENV = 'QUOX_RESTART_ORG';
171
172
  /** Env var carrying the window's /mode (PLAN MODE: only 'plan' is carried; ask is the default) to the restarted successor. */
172
173
  export const RESTART_MODE_ENV = 'QUOX_RESTART_MODE';
174
+ /** Env var carrying the slot this window declined to reattach to ("n" at the startup ask), so a restart does not re-ask. */
175
+ export const RESTART_DECLINED_ENV = 'QUOX_RESTART_DECLINED';
173
176
 
174
177
  /** Terminal modes the client turns on (bracketed paste, focus reporting, hidden cursor): all off before the hand-over. */
175
178
  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 +187,14 @@ export function restoreTerminal({ stdin = process.stdin, stdout = process.stdout
184
187
  * What a restart runs: { cmd, args, env }. The installed quox (or this tree), the same arguments with --resume
185
188
  * for this conversation, and the one-shot hand-over env (held slot, attach, org, plan mode) for the successor.
186
189
  */
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) }) {
190
+ 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
191
  const args = restartArgs(argv, conversationId, { profile, name });
189
192
  let childEnv = profile && !argv.includes('--demo') ? { ...env, QUOX_PROFILE: profile } : env; // belt and braces: same profile even if -p were lost
190
193
  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
194
  if (attach) childEnv = { ...childEnv, [RESTART_ATTACH_ENV]: attach }; // so does the attached agent or slot
192
195
  if (org) childEnv = { ...childEnv, [RESTART_ORG_ENV]: org }; // and the window's org (never the profile's file)
193
196
  if (mode === 'plan') childEnv = { ...childEnv, [RESTART_MODE_ENV]: 'plan' }; // and the window's /mode (never the profile)
197
+ if (declined) childEnv = { ...childEnv, [RESTART_DECLINED_ENV]: declined }; // and a declined reattach (never re-asked after a restart)
194
198
  const [cmd, lead] = restartTarget(env, bin);
195
199
  return { cmd, args: [...lead, ...args], env: childEnv };
196
200
  }
@@ -201,15 +205,16 @@ export function restartPlan({ argv, conversationId, profile = null, name = null,
201
205
  * Only used when there is no launcher (QUOX_NO_LAUNCHER=1, or no handoff dir): it NESTS the successor under
202
206
  * this process, which stays resident until the successor exits. The launcher path (launcher.js) does not.
203
207
  */
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 });
208
+ 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 }) {
209
+ if (isWebPty(env)) return { error: SHELL_REFUSED, bin: null }; // QUOXON web PTY: reconnect the page instead of re-exec
210
+ const plan = restartPlan({ argv, conversationId, profile, name, slot, attach, org, mode, declined, env, bin });
206
211
  restore(); // the child owns the terminal now
207
212
  const r = spawn(plan.cmd, plan.args, { stdio: 'inherit', env: plan.env });
208
213
  if (r.error) return { error: `${r.error.code || 'failed'}: ${r.error.message}`, bin: plan.cmd };
209
214
  return { code: r.status ?? (r.signal ? 128 + (os.constants.signals[r.signal] || 0) : 1), bin: plan.cmd };
210
215
  }
211
216
 
212
- export const SERVER_WAIT_EVERY_MS = 2000; // poll the collector /health this often while a restart waits for it
217
+ export const SERVER_WAIT_EVERY_MS = 10000; // poll the collector /health this often while a restart waits for it
213
218
  export const SERVER_WAIT_MAX_MS = 3 * 60 * 1000; // ...for up to this long, then carry on as before
214
219
 
215
220
  /** 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
+ }
@@ -12,6 +12,8 @@ import { join, dirname, basename, resolve, isAbsolute } from 'node:path';
12
12
  import { scrub } from './transcript.js';
13
13
  import { looksLikeKey } from './connect.js';
14
14
  import { addressLabel } from './session.js';
15
+ import { isWebPty, FILES_REFUSED } from './webPty.js';
16
+ import { stripForeignAnsi } from './foreignText.js';
15
17
 
16
18
  // ---- outbound ------------------------------------------------------------------------------------
17
19
  const AT_RE = /^@([A-Za-z0-9][A-Za-z0-9-]{0,31}):?[ \t]+(\S[\s\S]*)$/;
@@ -73,7 +75,8 @@ export function redactSecrets(text) {
73
75
  * Read a local file for /send. Text up to FILE_CAP goes inline; larger or binary files are refused with the cap
74
76
  * (slots have no attachment store). -> { ok, name, path, body, redacted, hits, size } | { ok:false, message }.
75
77
  */
76
- export function prepareFile(p, { cwd = process.cwd() } = {}) {
78
+ export function prepareFile(p, { cwd = process.cwd(), env = process.env } = {}) {
79
+ if (isWebPty(env)) return { ok: false, message: FILES_REFUSED }; // QUOXON web PTY: no file on the server is ever read for a browser user
77
80
  const raw = String(p || '').trim().replace(/^['"]|['"]$/g, '');
78
81
  if (!raw) return { ok: false, message: 'Usage: /send <slot> <file>' };
79
82
  const full = isAbsolute(raw) ? raw : resolve(cwd, raw.replace(/^~(?=\/)/, homedir()));
@@ -89,7 +92,8 @@ export function prepareFile(p, { cwd = process.cwd() } = {}) {
89
92
  }
90
93
 
91
94
  /** Tab completion for /send's path argument: entries of the typed directory, directories with a trailing slash. */
92
- export function pathItems(partial, { cwd = process.cwd(), max = 200 } = {}) {
95
+ export function pathItems(partial, { cwd = process.cwd(), max = 200, env = process.env } = {}) {
96
+ if (isWebPty(env)) return []; // QUOXON web PTY: nothing on the server is listed
93
97
  const p = String(partial || '');
94
98
  const slash = p.lastIndexOf('/');
95
99
  const dirPart = slash >= 0 ? p.slice(0, slash + 1) : '';
@@ -116,9 +120,18 @@ export function slotOfHolder(rows, name) {
116
120
  return r ? r.name : null;
117
121
  }
118
122
 
123
+ /** 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. */
124
+ export function withSenderSlot(rows, m) {
125
+ if (!m || m.from_slot) return m;
126
+ const n = String(m.from || '').replace(/\s*\(.*\)\s*$/, '').trim().toLowerCase();
127
+ const held = (Array.isArray(rows) ? rows : []).filter(x => x?.name && x.state !== 'free');
128
+ const slot = slotOfHolder(held, m.from) || held.find(x => String(x.name).toLowerCase() === n)?.name || null;
129
+ return slot ? { ...m, from_slot: slot } : m;
130
+ }
131
+
119
132
  /** A slot message as a chat line: { glyph, who, time, text, file }. */
120
133
  export function slotLine(m) {
121
- m = m && typeof m.text === 'string' ? { ...m, text: decodeEntities(m.text) } : m;
134
+ m = m && typeof m.text === 'string' ? { ...m, text: stripForeignAnsi(decodeEntities(m.text)) } : m; // another session's colour codes never show raw
122
135
  const f = parseFileMessage(m?.text);
123
136
  const file = f ? { name: f.name, meta: f.meta, body: f.body, lines: f.body.split('\n').length, preview: f.body.split('\n').slice(0, 3) } : null;
124
137
  // SLOTS-REPLY: the slot is the address, so it leads; the session/display name follows in brackets. A sender
@@ -298,3 +311,11 @@ export function presenceLine(slot, presence, agoText) {
298
311
  const seen = presence.last_checked_in_at ? `last checked in ${agoText(presence.last_checked_in_at)}` : 'no check-in on record';
299
312
  return { text: `✔ delivered to ${slot} · not listening right now (${seen}); it sees this when it next checks`, tone: 'ok', listening: false };
300
313
  }
314
+
315
+ const DEPLOY_WINDOW_MS = 10 * 60 * 1000;
316
+ /** 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. */
317
+ export function reattachSlowLine({ slot, message, restartSeenAt = 0, lastGoodAt = 0, now = Date.now() }) {
318
+ const recent = (t) => Number(t) > 0 && now - Number(t) <= DEPLOY_WINDOW_MS;
319
+ if (recent(restartSeenAt) || recent(lastGoodAt)) return `slot ${slot}: QuoxCORE is restarting (a deploy); reconnecting, nothing is lost.`;
320
+ return `slot ${slot}: reattaching after the restart is slow (${message}); retrying.`;
321
+ }
@@ -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
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * SLOT-OUTBOX: a /say or @slot message that could not be sent because QuoxCORE did not answer (fetch failed,
3
+ * ECONNREFUSED, socket error, timeout, 5xx) is kept on disk and sent when it answers again, never lost.
4
+ * Local only (~/.quox/slot-outbox.json, mode 0600, per OS user; each entry also carries the profile it was typed in,
5
+ * and a window only ever sends its own profile's entries, so a message never changes org).
6
+ *
7
+ * Never for a 4xx (auth, permission, no such slot): that is the user's to fix, so the text stays in the prompt.
8
+ *
9
+ * ONE-SEND RULE (the slot-say endpoint has no idempotency key, so the client must be conservative):
10
+ * 1. An entry is flipped to `sending` and persisted BEFORE the request goes out.
11
+ * 2. Success removes it (it is never sent again).
12
+ * 3. A clean failure (the server did not answer) puts it back to `queued`.
13
+ * 4. An entry still `sending` when the file is next read by a process that is not flushing is `unsure`: the window
14
+ * died mid-request, so the message may have been delivered. `unsure` entries are never auto-sent; /outbox shows
15
+ * them and `/outbox send` (explicit) is the only thing that will try them.
16
+ * Residual risk, stated: a timeout where the server did receive the message but the reply was lost can duplicate it
17
+ * once on flush. Duplicate over lost is the chosen side for a message the owner typed and saw fail.
18
+ */
19
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { join, dirname } from 'node:path';
22
+ import { randomBytes } from 'node:crypto';
23
+
24
+ export const OUTBOX_CAP = 50;
25
+ export const defaultOutboxPath = (home = homedir()) => join(home, '.quox', 'slot-outbox.json');
26
+
27
+ /** A send result worth queueing: no HTTP status (fetch failed, refused, socket, timeout) or a 5xx. Never a 4xx, ambiguity, or a licence refusal. */
28
+ export function isConnectivityFailure(r) {
29
+ if (!r || r.ok || r.ambiguous || r.unlicensed) return false;
30
+ if (r.status == null) return true;
31
+ return Number(r.status) >= 500;
32
+ }
33
+
34
+ export function slotOutbox(path = defaultOutboxPath(), { now = () => Date.now() } = {}) {
35
+ const read = () => {
36
+ try { const d = JSON.parse(readFileSync(path, 'utf8')); return Array.isArray(d?.items) ? d : { items: [] }; }
37
+ catch { return { items: [] }; }
38
+ };
39
+ const write = (d) => {
40
+ try {
41
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
42
+ const tmp = `${path}.${process.pid}.tmp`;
43
+ writeFileSync(tmp, JSON.stringify(d), { mode: 0o600 });
44
+ renameSync(tmp, path);
45
+ return true;
46
+ } catch { return false; }
47
+ };
48
+ let flushing = false; // this process is mid-flush: its own `sending` entry is live, not crashed
49
+ const settle = (d) => { // a `sending` entry nobody in this process is sending is a crashed send; persist that verdict
50
+ if (flushing) return d;
51
+ let changed = false;
52
+ for (const it of d.items) if (it.status === 'sending') { it.status = 'unsure'; changed = true; }
53
+ if (changed) write(d);
54
+ return d;
55
+ };
56
+ const mine = (it, profile) => profile == null || it.profile == null || it.profile === profile;
57
+ return {
58
+ /** Entries in send order (oldest first), optionally only this profile's. */
59
+ list: ({ profile = null } = {}) => settle(read()).items.filter(i => mine(i, profile)),
60
+ /** -> { ok:true, item, count } | { ok:false, full:true, count }. The cap is on entries not yet sent. */
61
+ enqueue({ slot, text, profile = null }) {
62
+ const d = settle(read());
63
+ if (d.items.length >= OUTBOX_CAP) return { ok: false, full: true, count: d.items.length };
64
+ const item = { id: `ob_${now().toString(36)}_${randomBytes(3).toString('hex')}`, slot, text: String(text), profile, created_at: new Date(now()).toISOString(), status: 'queued', tries: 0 };
65
+ d.items.push(item);
66
+ if (!write(d)) return { ok: false, unsaved: true, count: d.items.length };
67
+ return { ok: true, item, count: d.items.filter(i => mine(i, profile)).length };
68
+ },
69
+ /** Remove the Nth entry (1-based, as /outbox lists this profile's). -> the dropped item, or null. */
70
+ drop(n, { profile = null } = {}) {
71
+ const d = settle(read());
72
+ const visible = d.items.filter(i => mine(i, profile));
73
+ const it = visible[Number(n) - 1];
74
+ if (!it) return null;
75
+ d.items = d.items.filter(i => i.id !== it.id);
76
+ write(d);
77
+ return it;
78
+ },
79
+ /**
80
+ * Send what is queued, in order, once. send(item) -> { ok:true } | { ok:false, retryable }. A retryable failure stops
81
+ * the flush (the server is still down; order is kept). A non-retryable one marks that entry `failed` and moves on.
82
+ * `includeUnsure` (explicit /outbox send only) also tries entries that may already have been delivered.
83
+ * -> { sent:[items], failed:[items {error}], pending:n, busy? }.
84
+ */
85
+ async flush(send, { profile = null, includeUnsure = false } = {}) {
86
+ if (flushing) return { sent: [], failed: [], pending: 0, busy: true };
87
+ flushing = true;
88
+ const sent = []; const failed = [];
89
+ try {
90
+ const ids = settle(read()).items.filter(i => mine(i, profile) && (i.status === 'queued' || (includeUnsure && i.status === 'unsure'))).map(i => i.id);
91
+ for (const id of ids) {
92
+ const d = read();
93
+ const it = d.items.find(i => i.id === id);
94
+ if (!it || !(it.status === 'queued' || it.status === 'unsure')) continue; // dropped or sent meanwhile
95
+ it.status = 'sending'; it.tries = (it.tries || 0) + 1; write(d); // persisted BEFORE the request
96
+ let r;
97
+ try { r = await send(it); } catch (e) { r = { ok: false, retryable: true, error: e?.message }; }
98
+ const after = read();
99
+ const cur = after.items.find(i => i.id === id);
100
+ if (r?.ok) { after.items = after.items.filter(i => i.id !== id); write(after); sent.push(it); continue; }
101
+ if (cur) { cur.status = r?.retryable === false ? 'failed' : 'queued'; if (r?.error) cur.error = String(r.error); write(after); }
102
+ if (r?.retryable === false) { failed.push({ ...it, error: r.error || 'refused' }); continue; }
103
+ break; // still not answering: keep the rest, in order, for the next recovery
104
+ }
105
+ } finally { flushing = false; }
106
+ return { sent, failed, pending: settle(read()).items.filter(i => mine(i, profile) && i.status === 'queued').length };
107
+ },
108
+ };
109
+ }
110
+
111
+ export const queuedLine = (slot, count) => `⏸ queued for ${slot}: will send when QuoxCORE answers (${count} queued) · /outbox`;
112
+ export const sentLine = (slot) => `✔ sent queued message to ${slot}`;
113
+ export const fullLine = (cap = OUTBOX_CAP) => `Outbox is full (${cap} queued). Not queued: your text is back in the prompt. /outbox shows them, /outbox drop N removes one, /outbox send retries now.`;
@@ -21,6 +21,7 @@ import { codeRows } from './slotFormat.js';
21
21
  import { graphemes } from './textBuffer.js';
22
22
  import { TERMINAL_RESTORE } from './slash/restart.js';
23
23
  import { PASTE_ON } from './keys.js';
24
+ import { isWebPty, SHELL_REFUSED } from './webPty.js';
24
25
 
25
26
  export const RUN_TTL_MS = 10 * 60 * 1000;
26
27
  export const RUN_MAX_LEN = 2000;
@@ -105,6 +106,7 @@ export function runAskRows(req, width) {
105
106
  * over it. -> { code, signal, ms } | { error, ms }.
106
107
  */
107
108
  export function handOverRun({ cmd, file = null, args = null, env = process.env, cwd = process.cwd(), stdout = null, setRawMode = null, spawn = spawnSync, shell, now = Date.now, resume = '' }) {
109
+ if (isWebPty(env)) return { error: SHELL_REFUSED, ms: 0 }; // QUOXON web PTY: never hand a browser terminal a local program
108
110
  const tty = Boolean(stdout?.isTTY);
109
111
  const rows = tty ? Math.max(0, Number(stdout.frameRows) || 0) : 0;
110
112
  const put = (s) => { try { stdout.write(Buffer.from(s)); } catch { /* closed */ } };
@@ -8,7 +8,7 @@
8
8
  import { findCommandEntry } from './commands.js';
9
9
 
10
10
  // Commands that need the human's own typing: never offered, never run from a model suggestion.
11
- export const HUMAN_TYPED = new Set(['approve', 'yestoall', 'deny', 'authorise', 'authorize', 'authenticate', 'override', 'restart', 'quit', 'exit', 'login']);
11
+ export const HUMAN_TYPED = new Set(['approve', 'yestoall', 'deny', 'authorise', 'authorize', 'authenticate', 'override', 'verify-email', 'restart', 'quit', 'exit', 'login']);
12
12
  // Pure views, no effect: may run without asking when explicit, and only with no arguments.
13
13
  export const READ_ONLY_VIEWS = new Set(['approvals', 'mind', 'which', 'context', 'rooms', 'active', 'slots']);
14
14
 
@@ -48,8 +48,19 @@ export function inkStdout(out, { tap = null } = {}) {
48
48
  if (cb) out.write('', cb); // nothing to send: still a barrier behind the earlier writes
49
49
  return true;
50
50
  } : null;
51
- return new Proxy(out, {
51
+ // Resize: Ink repaints its last committed tree at the new width the moment 'resize' fires, which wraps every 78-wide rule for a
52
+ // beat (owner 2026-10-27: top rule and model label wrapped after 79 -> 58). Listeners for 'resize' run RESIZE_SETTLE_MS late, so
53
+ // the app (which flags its own listener `resizeFirst`) commits the new column count first and Ink paints the right width once.
54
+ const lateOf = new WeakMap();
55
+ const lateListener = (fn) => {
56
+ if (fn.resizeFirst) return fn;
57
+ if (!lateOf.has(fn)) lateOf.set(fn, (...a) => { const t = setTimeout(() => fn(...a), RESIZE_SETTLE_MS); t.unref?.(); });
58
+ return lateOf.get(fn);
59
+ };
60
+ const proxy = new Proxy(out, {
52
61
  get(target, prop) {
62
+ if ((prop === 'on' || prop === 'addListener' || prop === 'once') && typeof target[prop] === 'function') return (ev, fn, ...r) => { target[prop](ev, ev === 'resize' && typeof fn === 'function' ? lateListener(fn) : fn, ...r); return proxy; };
63
+ if ((prop === 'off' || prop === 'removeListener') && typeof target[prop] === 'function') return (ev, fn, ...r) => { target[prop](ev, ev === 'resize' && typeof fn === 'function' ? lateListener(fn) : fn, ...r); return proxy; };
53
64
  if (prop === 'write' && write) return write;
54
65
  if (prop === 'rows') return Infinity;
55
66
  if (prop === 'termRows') return target.rows;
@@ -61,7 +72,20 @@ export function inkStdout(out, { tap = null } = {}) {
61
72
  },
62
73
  set(target, prop, value) { return Reflect.set(target, prop, value, target); }
63
74
  });
75
+ return proxy;
76
+ }
77
+ const RESIZE_SETTLE_MS = 40;
78
+
79
+ /**
80
+ * The width to print history at, read from the terminal NOW. The `cols` state lags a resize (the listener attaches after mount,
81
+ * and the commit is async), and a <Static> item printed at the stale width is wider than the new terminal, so the terminal
82
+ * breaks it mid-word. Never wider than the state either (a pinned `columns` prop wins when it is narrower).
83
+ */
84
+ export function liveCols(stdout, stateCols) {
85
+ const n = Number(stdout?.columns);
86
+ return Number.isFinite(n) && n > 0 ? Math.min(stateCols, Math.max(MIN_STATIC_COLS, n - 1)) : stateCols;
64
87
  }
88
+ const MIN_STATIC_COLS = 20;
65
89
 
66
90
  /** Real terminal height, or null when unknown (not a TTY, tests). */
67
91
  export function termRows(stdout) {
@@ -11,6 +11,7 @@ import { stripMarks } from './palette.js';
11
11
  import { textGate } from './events.js';
12
12
  import { join } from 'node:path';
13
13
  import { homedir } from 'node:os';
14
+ import { redactSecrets } from './secretShape.js';
14
15
 
15
16
  export const MAX_FIELD = 4000;
16
17
  export const SILENT_GAP_MS = 10000;
@@ -33,7 +34,7 @@ const SCRUBS = [
33
34
  export function scrub(s) {
34
35
  let out = String(s);
35
36
  for (const [re, rep] of SCRUBS) out = out.replace(re, rep);
36
- return out;
37
+ return redactSecrets(out); // SECRET-GUARD: bank/PIN/card shapes, after the key patterns
37
38
  }
38
39
 
39
40
  /** Strings are scrubbed and truncated; non-strings become a scrubbed JSON string (never a raw object). */
@@ -0,0 +1,15 @@
1
+ /**
2
+ * QUOXON web PTY: the dashboard /cli page runs this app in a browser terminal whose process lives on the
3
+ * collector host. The collector marks that PTY with QUOX_WEB_PTY=1, and every path that would start a local
4
+ * program (a `!` command, a terminal hand-off, $EDITOR, a command row, a restart re-exec, a URL opener, the
5
+ * docker offer, an extension import) checks isWebPty() and refuses. A browser user gets the conversation,
6
+ * never a shell on the server.
7
+ */
8
+ export const WEB_PTY_ENV = 'QUOX_WEB_PTY';
9
+ export const SHELL_REFUSED = 'shell commands are not available in the browser terminal';
10
+ export const EXTENSIONS_REFUSED = 'extensions are not available in the browser terminal';
11
+
12
+ export function isWebPty(env = process.env) {
13
+ return env?.[WEB_PTY_ENV] === '1';
14
+ }
15
+ export const FILES_REFUSED = 'local files are not available in the browser terminal';
@@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
7
  import { join } from 'node:path';
8
8
 
9
- export const DOCS_URL = 'https://cli.quox.ai/docs/start/'; // #1100: CLI docs "Start here" (cli.quox.ai/docs/ itself has no index page)
9
+ export const DOCS_URL = 'https://quox.ai/cli/'; // #1100: CLI docs start page (cli.quox.ai retired 2026-10-26, moved to quox.ai/cli)
10
10
  export const TERMS_URL = 'https://quox.ai/terms';
11
11
  export const PRIVACY_URL = 'https://quox.ai/privacy';
12
12
  export const LICENCE = 'Quox Community Source Licence 1.0 (source-available)';
@@ -109,7 +109,7 @@ export function listWhatsNew(arg = '', entries = ENTRIES) {
109
109
  // Startup preference: quoxon.whatsNewStartup = 'on' | 'off' (default on). Both live in the synced profile settings.
110
110
  export const startupEnabled = (settings) => String(settings?.whatsNewStartup || 'on').toLowerCase() !== 'off';
111
111
 
112
- const activeCommands = (registry) => registry.filter(e => e.kind === 'command' && e.status === 'active');
112
+ const activeCommands = (registry) => registry.filter(e => e.kind === 'command' && e.status === 'active' && !e.dyn);
113
113
 
114
114
  /**
115
115
  * Commands the user has not been shown, newest first (registry is oldest first, so reversed). With no record yet
@@ -132,3 +132,45 @@
132
132
  {"id": "loop-card-polish", "date": "2026-10-26T13:00:00Z", "since": "0.3.1", "title": "The loop card keeps its own count", "text": "The card now counts the loop's own turns (turn 2 of 5, not the message count), shows the whole runway, fills a receipts bar as each one is witnessed, and says a milestone once. With an empty composer, press Down to reach its keys. A finished loop calls its dragon.", "try": "/loop list"}
133
133
  {"id": "org-picker", "date": "2026-10-26T16:30:00Z", "since": "0.3.1", "title": "Switch org in one keystroke", "text": "Type /org for a list of your orgs, arrow to one and press Enter: this window moves there at once and the status bar follows. Press d instead to also make it your default. /org hampshire switches straight away when only one org matches, and quox org switch with no argument gives a numbered list.", "try": "/org"}
134
134
  {"id":"quoxcli-0-4-0","date":"2026-10-26T18:00:00Z","since":"0.4.0","title":"quoxon extensions, an inbox bar, history and mouse editing","text":"Add and manage extensions with quox ext add, quox ext remove and quox ext list, or /ext in the window. /inbox and a notification bar show what is waiting. Up and Ctrl+R search your history. /mouse turns on optional mouse editing. Plan approval cards now list their steps, and live loop cards no longer freeze the window.","try":"/ext","shell":"quox ext list"}
135
+ {"id": "dragons-vulcan-quoxperticus", "date": "2026-10-27T09:00:00Z", "since": "0.3.1", "title": "Two new dragons: Vulcan and Quoxperticus", "text": "Two new dragons: Vulcan, the forge dragon, and Quoxperticus, the fleet conductor. Attach either agent and the dragon greets you, listens while you work, waits with you at approvals and shows when something needs attention.", "try": "/attach vulcan"}
136
+ {"id": "quoxon-2026-10-27-honest-window", "date": "2026-10-27T12:30:00Z", "since": "0.4.0", "title": "A calmer, more honest window", "text": "When your model hits a usage limit, the window offers a working one (y/N). Auto-restart waits at least 30 minutes between restarts and only when you are idle; inside that time a new version shows a quiet line and Enter restarts. The status bar no longer says nothing is waiting while items are open or a source can't be read. /say and /observe reach your slots in your other orgs (name@org picks one). /approvals opens faster, and a refused loop shows why instead of fake progress.", "try": "/approvals"}
137
+ {"id": "quoxon-2026-10-27-delete-confirm", "date": "2026-10-27T16:30:00Z", "since": "0.4.0", "title": "Deletes need the name typed", "text": "An approval that deletes something (delete_*, remove_*, purge_*, destroy_*, drop_*, wipe_*) now leads with DELETES and the object, and a single y no longer approves it: y asks you to type the name, Esc or n goes back, n on the card still denies. Bulk approve (/yestoall, /approve all) skips deletes and tells you how many are left to confirm one by one.", "try": "/approvals"}
138
+ {"id": "quoxon-2026-10-28-approval-burst", "date": "2026-10-28T00:30:00Z", "since": "0.4.0", "title": "A burst of approvals is one card", "text": "When one agent asks to run the same tool several times within 5 minutes (say nine wire_alerts), you get ONE card: 'VIGIL wants to run wire_alerts on 9 items'. y approves all, n denies all, p walks them one by one, Tab puts it away. Deletes and risky asks are never in the batch; they keep their own card and confirm. The inbox arrival line collapses the same way.", "try": "/approvals"}
139
+ {"id": "quoxon-2026-10-28-burst-across-polls", "date": "2026-10-28T04:00:00Z", "since": "0.4.0", "title": "Pack installs on many hosts are one card", "text": "DevSuite pack installs filed a few seconds apart for different hosts used to arrive as one card each. They now merge into ONE card (y approves all, n denies all, p picks), and the card says who asked in plain words ('DevSuite (skill-pack sync)') instead of an internal key. Restart, deploy and other disruptive asks still never batch.", "try": "/approvals"}
140
+ {"id": "quoxon-2026-10-28-afk-quiet-cards", "date": "2026-10-28T06:45:00Z", "since": "0.4.0", "title": "Away means quiet", "text": "After /afk or /sleep, new approvals no longer open a card or arm y/n (a stray key cannot answer one). They are only counted: \"⏸ N approvals waiting · /back to review\". /back, or any message you type, opens them as before. Nothing is ever answered for you."}
141
+ {"id": "quoxon-2026-10-28-agent-profile-offer", "date": "2026-10-28T15:00:00Z", "since": "0.4.0", "title": "Opened as an agent? Quox offers your own login", "text": "Starting quox on an agent profile while you have a signed-in human profile now asks once (y/N) to restart as you. Typing \"attach to hitl slot\" offers /slot hitl instead of going to the model."}
142
+ {"id": "quoxon-2026-10-28-secret-paste-guard", "date": "2026-10-28T17:00:00Z", "since": "0.4.0", "title": "Pasted credentials are caught", "text": "A message that looks like bank details, a PIN, a password, a card number or an API key now asks \"Don't send it?\" (y keeps it out, n sends anyway). Nothing is sent or saved on y, and history and transcripts store [REDACTED:kind] in place of the value either way."}
143
+ {"id": "quoxon-2026-10-28-do-not-disturb", "date": "2026-10-28T18:00:00Z", "since": "0.4.0", "title": "Sleep means sleep: /donotdisturb and /morningreport", "text": "/donotdisturb (or /dnd, optionally until 07:30) collects everything quietly: no notices, approval cards, bells or dragon. The status line shows \"🌙 do not disturb · N waiting · /morningreport\". /morningreport groups what came in (incidents first, repeats as ×N) and offers to turn it off. /afk and /sleep imply it, saying you are going to sleep offers it, and a restarted window restores it."}
144
+ {"id": "quoxon-2026-10-29-provider-test-json", "date": "2026-10-29T12:00:00Z", "since": "0.4.0", "title": "quox provider test --json says why", "text": "quox provider test --json now prints every provider's result (ok, error, reason code) and a summary, instead of nothing, and still exits 1 when one fails."}
145
+ {"id": "quoxon-2026-10-29-connect-server-install", "date": "2026-10-29T13:00:00Z", "since": "0.4.0", "title": "quox connect and quox server install", "text": "quox connect links this machine to a Quox server. quox server install sets up a new server on this machine: checks, download, start, owner account and a one-time sign-in link. Try quox server install --dry-run first. The sign-in link is not printed into piped or --json output unless you pass --print-login-link.", "shell": "quox server install --dry-run", "sha": "34a547724"}
146
+ {"id": "quoxon-2026-10-29-morningreport-fallback", "date": "2026-10-29T14:00:00Z", "since": "0.4.0", "title": "/morningreport shows the digest when nothing was held", "text": "With do not disturb off, /morningreport now says nothing was held and shows the same While you were away digest as /away, so you no longer need a second command.", "sha": "c4f2a3c90"}
147
+ {"id": "quoxon-2026-10-29-never-answered-offer", "date": "2026-10-29T15:00:00Z", "since": "0.4.0", "title": "A server that never answered now offers to connect elsewhere", "text": "If this machine has never reached the server a profile points at, Quox now asks whether to connect to a different one instead of waiting on a restart that is not happening. Say y, type the address, and it checks the server, shows its certificate and signs you in."}
148
+ {"id": "quoxon-2026-10-29-approval-outcome", "date": "2026-10-29T16:00:00Z", "since": "0.4.0", "title": "You now see whether an approved action worked", "text": "After you approve a request from another session, Quox follows up with one line saying whether it finished or failed and why. The failed-to-execute notice now carries the approval id, so quox inbox show <id> opens it."}
149
+ {"id": "quoxon-2026-10-29-deck-lazy-offer", "date": "2026-10-29T17:00:00Z", "since": "0.4.0", "title": "/deck now offers to install what it needs", "text": "If quoxdeck is missing, or its audio headers (libasound2-dev) are, /deck on asks once whether to install them, shows the exact commands, and runs them with your own sudo prompt. Say n and it prints the commands to run later."}
150
+ {"id":"quoxon-2026-10-29-connect-no-terminal","date":"2026-10-29T08:50:36Z","since":"0.4.0","title":"quox connect can run with no terminal","text":"quox connect now takes --email, --password-stdin and --fingerprint <sha256>, so a script or chat can connect and sign in with no prompts. quox server status shows the certificate fingerprint to compare, a certificate Quox does not trust now says so in plain words with the fix, and the same email on a different server no longer trips the same-user warning."}
151
+ {"id":"quoxcli-2026-10-29-tls-trust-hint","date":"2026-10-29T09:26:52Z","since":"0.4.0","title":"A certificate Quox does not trust now says so on login","text":"If a server uses a certificate this machine does not trust yet, quox login now says so in plain words and prints the exact quox connect command to trust it (it shows the fingerprint and asks y/N; --trust-cert or --fingerprint for no terminal), instead of just \"fetch failed\"."}
152
+ {"id":"quoxon-2026-10-29-gripe-queue","date":"2026-10-29T09:46:49Z","since":"0.4.0","title":"/gripe no longer loses a gripe when Quox is slow","text":"If /gripe! times out or the server errors, the gripe is saved on your machine and filed when Quox answers again, without making a duplicate issue if the first try had actually gone through. /gripes shows what is waiting and what was filed."}
153
+ {"id":"quoxon-2026-10-29-gripe-paste-lines","date":"2026-10-29T10:10:00Z","since":"0.4.0","title":"/gripe keeps the lines of what you paste","text":"A multi-line paste into /gripe now reaches the issue with its lines intact instead of one run-on line, and the confirm says how many more lines will be filed."}
154
+ {"id":"quoxon-2026-10-29-advice-independent","date":"2026-10-29T11:00:00Z","since":"0.4.0","title":"Test-edit approvals no longer echo the asker as advice","text":"On a test-edit approval card, ADVICE now cites the diff when it is known, or says there is no independent view, instead of repeating the asker's own recommendation; the starred recommended-by line stays."}
155
+ {"id":"quoxon-2026-10-29-resume-no-duplicate","date":"2026-10-29T12:00:00Z","since":"0.4.0","title":"quox claude --resume no longer starts a second copy","text":"Resuming a named session now finds its tmux session whatever the capitals and attaches to it, and refuses with the pid and the attach command when that conversation is already running anywhere. --force starts a second copy on purpose."}
156
+ {"id": "quoxon-2026-10-29-sleep-one-step", "date": "2026-10-29T09:00:00Z", "since": "0.4.0", "title": "/sleep is one step", "text": "/sleep (or /afk going to bed) sets you asleep with do not disturb on in one command. A bare /dnd now only ever turns it on; /dnd off ends it. Plain /afk offers do not disturb with one y/N."}
157
+ {"id": "quoxon-2026-10-29-roster-sessions", "date": "2026-10-29T17:30:00Z", "since": "0.4.0", "title": "/roster shows attached agents and duplicate sessions", "text": "/roster now lists the live agent attaches in your org and every claude --resume process on this box, grouped by name. A conversation running more than once is flagged with each pid. The same attach list is quox agent attaches."}
158
+ {"id": "quoxon-2026-10-29-announce-everything", "date": "2026-10-29T20:00:00Z", "since": "0.4.0", "title": "Quox announces what it does", "text": "Memory saves, approvals, slot messages, connect and vault create now print one Quox line saying what happened, success or failure, and the chat transcript shows the same line for what a turn did (a saved memory, an approval, a delegation). Silent under --json and --quiet.", "shell": "quox memory create --type fact --content hello"}
159
+ {"id": "quoxon-2026-10-29-walkthrough-command", "date": "2026-10-29T22:30:00Z", "since": "0.4.0", "title": "The walkthrough shows the command", "text": "Each approval card in the walkthrough now shows the exact command in full, never cut off, and the host it runs on. When the reason was guessed from the command rather than stated by the asker, it is labelled so.", "try": "/walkthrough"}
160
+ {"id": "quoxon-2026-10-29-chat-quox-lines", "date": "2026-10-29T22:31:00Z", "since": "0.4.0", "title": "quox chat prints what Quox did", "text": "quox chat, streaming or --no-stream, now prints one Quox line on stderr for what a turn did (a saved memory, an approval, a delegation). Silent under --json and --quiet.", "shell": "quox chat --no-stream \"remember that the proof tag is QA-1036\""}
161
+ {"id": "quoxon-2026-10-30-inbox-unread-quiet-noreply", "date": "2026-10-30T07:00:00Z", "since": "0.4.0", "title": "Undo a read, quieter no-reply, honest timeouts", "text": "/inbox unread N puts an item back as unread. The no reply from a slot notice is dim and skipped when that slot spoke in the last 10 minutes. An approved action whose result timed out is titled no result in time (it may still have run) instead of FAILED.", "try": "/inbox unread 1"}
162
+ {"id": "quoxon-2026-10-30-reattach-deploy-line", "date": "2026-10-30T08:00:00Z", "since": "0.4.0", "title": "A slot reattach during a deploy says so", "text": "When QuoxCORE is being redeployed, a slow slot reattach now says it is restarting and reconnecting, instead of suggesting a stale URL or QUOX_COLLECTOR_URL. A URL that never answered still gets the full diagnostic.", "try": "/slot"}
163
+ {"id":"quoxon-2026-10-30-inbox-unread-cli","date":"2026-10-30T08:27:43Z","since":"0.4.0","title":"Mark unread from a shell, read items whole","text":"quox inbox unread <id> undoes a mark-as-read from a shell, and /inbox unread all flips back every read item. /inbox N now shows the full body of every item (it missed the stored body before), the sender and subject for mail, and the actions that apply.","try":"/inbox 1","shell":"quox inbox unread <id>"}
164
+ {"id":"quoxon-2026-10-30-presence-owner-level","date":"2026-10-30T08:56:21Z","since":"0.4.0","title":"Good morning waits for you; /back agrees across windows","text":"LUCID no longer says good morning at 08:00 while you are asleep or away: it waits until you are back. If another window already marked you present, /back reports that same away span instead of its own.","try":"/back"}
165
+ {"id":"quoxon-2026-10-30-tui-mail","date":"2026-10-30T09:30:00Z","since":"0.4.0","title":"Read your email in the terminal: /mail","text":"/mail lists your newest triaged email (sender, subject, age, zone) and /mail N opens one with its body excerpt and why it was triaged that way. Read only: nothing is sent, moved or deleted. With no mailbox connected it names the connect command. /inbox now points at /mail.","try":"/mail"}
166
+ {"id":"quoxon-2026-10-30-dnd-survives-restart","date":"2026-10-30T10:40:24Z","since":"0.4.0","title":"Do not disturb now survives restarts","text":"Do not disturb is saved on the server for you, not just in one window, so an auto-restart no longer drops it, even when the server is slow to answer at that moment: the window keeps retrying until it can read it.","try":"/dnd"}
167
+ {"id":"quoxon-2026-10-30-reply-address","date":"2026-10-30T11:00:00Z","since":"0.4.0","title":"Messages from slot holders now show their reply address","text":"A message from a session that holds a slot no longer says (no reply address): the TUI looks up the slot the sender holds, so Tab or /r replies to it. It says so only when the sender truly holds no slot."}
168
+ {"id":"quoxon-2026-10-30-approval-row-background","date":"2026-10-30T12:00:00Z","since":"0.4.0","title":"Approval rows flag background work","text":"/approvals rows now say when a command runs in the background, including screen, tmux and docker detached runs."}
169
+ {"id":"quoxon-2026-10-30-confirm-target-once","date":"2026-10-30T22:00:00Z","since":"0.4.0","title":"Approval confirmations name the hosts once","text":"The line after you approve a multi-host command no longer repeats the host list twice."}
170
+ {"id":"quoxon-2026-10-30-away-retry","date":"2026-10-30T22:10:00Z","since":"0.4.0","title":"While you were away retries a slow source","text":"If a source times out right after sign-in, the digest asks it once more before it says it cannot see it."}
171
+ {"id":"quoxon-2026-11-06-approval-popup-dedupe","date":"2026-11-06T17:40:00Z","since":"0.4.0","title":"An approval you answered does not pop up again","text":"Approving or denying on the inline prompt now stops the same approval opening a second later as a new walkthrough card."}
172
+ {"id":"quoxon-2026-11-07-resend-held-turn","date":"2026-11-07T12:40:00Z","since":"0.4.0","title":"A message sent during a restart is not lost","text":"If the server already received your message before the connection dropped, Quox now shows that answer instead of failing, and if it cannot, your message comes back to the composer."}
173
+ {"id":"quoxon-2026-11-07-actions-list-fits","date":"2026-11-07T12:30:00Z","since":"0.4.0","title":"quox actions list fits your screen","text":"The list now fits the terminal or the Quox pane: short ids (done, move, get and delete accept them), long titles end in an ellipsis instead of wrapping, and the Due column only shows when an item has a due date. --json keeps the full ids."}
174
+ {"id":"quoxon-2026-11-07-offload-lease-warn","date":"2026-11-07T13:20:00Z","since":"0.4.0","title":"Offload warns before a worker lease runs out","shell":"quox offload leases","text":"quox offload status and the new quox offload leases list every offload worker with EXPIRED, EXPIRING or OK, and every offload command warns when a lease ends within 12 hours, naming the command a human runs to renew it."}
175
+ {"id":"quoxon-2026-11-07-select-dot-whole-result","date":"2026-11-07T13:50:00Z","since":"0.4.0","title":"quox tool exec --select . returns the whole result","text":"A dot means the whole result, as in jq, so --select . (and count(.)) no longer fails with \"bad path\". Every other --select path works as before."}
176
+ {"id":"quoxon-2026-11-08-walk-revalidate","date":"2026-11-08T04:55:00Z","since":"0.4.0","title":"A stopped walkthrough only lists what is still waiting","text":"When a walkthrough stops, the TUI asks the server again before listing what is still waiting, so questions answered from another window drop off the list. A held list is re-checked the same way before its first card. If the server cannot be reached, the list says it may be out of date."}