quoxcli 0.5.0 → 0.6.1

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 (130) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/lib/api/endpoints/admin.js +21 -0
  3. package/lib/api/endpoints/calendarq.js +8 -0
  4. package/lib/api/endpoints/governance.js +7 -0
  5. package/lib/api/endpoints/inboxq.js +39 -0
  6. package/lib/api/endpoints/index.js +4 -0
  7. package/lib/api/endpoints/lens.js +34 -0
  8. package/lib/api/endpoints/lucid.js +18 -0
  9. package/lib/api/endpoints/mcp.js +35 -0
  10. package/lib/api/endpoints/mon.js +6 -7
  11. package/lib/api/endpoints/notesq.js +2 -0
  12. package/lib/api/endpoints/quoxplan.js +51 -0
  13. package/lib/api/endpoints/v1.js +57 -0
  14. package/lib/commands/actions.js +51 -13
  15. package/lib/commands/admin.js +86 -0
  16. package/lib/commands/approvals.js +5 -1
  17. package/lib/commands/calendar.js +27 -0
  18. package/lib/commands/chat.js +1 -1
  19. package/lib/commands/contacts.js +2 -0
  20. package/lib/commands/fleet.js +164 -0
  21. package/lib/commands/fleetRecipe.js +174 -0
  22. package/lib/commands/lens.js +268 -0
  23. package/lib/commands/lucid.js +6 -0
  24. package/lib/commands/lucidGoals.js +423 -0
  25. package/lib/commands/mail.js +287 -7
  26. package/lib/commands/mcp.js +190 -0
  27. package/lib/commands/mon.js +14 -0
  28. package/lib/commands/monitoring.js +73 -1
  29. package/lib/commands/notesSeal.js +380 -22
  30. package/lib/commands/offload.js +51 -10
  31. package/lib/commands/plan.js +507 -54
  32. package/lib/commands/research.js +78 -0
  33. package/lib/commands/settings.js +7 -1
  34. package/lib/commands/volt.js +6 -1
  35. package/lib/commands/wazuh.js +74 -9
  36. package/lib/commands/wazuhIntake.js +55 -6
  37. package/lib/core/commandRegistrars.js +2 -0
  38. package/lib/core/config.js +14 -1
  39. package/lib/core/offload.js +83 -0
  40. package/lib/core/output.js +29 -4
  41. package/lib/core/selfUpdate.js +2 -0
  42. package/lib/core/version.js +15 -2
  43. package/lib/dragon/select.js +2 -2
  44. package/lib/quoxon/api.js +4 -0
  45. package/lib/quoxon/app.js +258 -49
  46. package/lib/quoxon/approvalsView.js +3 -1
  47. package/lib/quoxon/ask.js +4 -2
  48. package/lib/quoxon/bang.js +7 -1
  49. package/lib/quoxon/commandShort.js +1 -1
  50. package/lib/quoxon/commands.js +5 -5
  51. package/lib/quoxon/delegationDragon.js +49 -0
  52. package/lib/quoxon/dnd.js +47 -0
  53. package/lib/quoxon/dragonCompanion.js +24 -12
  54. package/lib/quoxon/events.js +12 -1
  55. package/lib/quoxon/extensions.js +2 -0
  56. package/lib/quoxon/foreignText.js +49 -0
  57. package/lib/quoxon/inboxView.js +5 -5
  58. package/lib/quoxon/launcher.js +2 -1
  59. package/lib/quoxon/loopCard.js +2 -1
  60. package/lib/quoxon/main.js +8 -2
  61. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  62. package/lib/quoxon/onboarding/view.js +1 -1
  63. package/lib/quoxon/render.js +5 -2
  64. package/lib/quoxon/resend.js +14 -0
  65. package/lib/quoxon/rows.js +3 -1
  66. package/lib/quoxon/secretShape.js +2 -0
  67. package/lib/quoxon/slash/broadcast.js +30 -6
  68. package/lib/quoxon/slash/broadcastRun.js +12 -6
  69. package/lib/quoxon/slash/cliLeaves.json +54 -0
  70. package/lib/quoxon/slash/dashboard.js +2 -0
  71. package/lib/quoxon/slash/listView.js +2 -1
  72. package/lib/quoxon/slash/palette.js +41 -0
  73. package/lib/quoxon/slash/paletteController.js +6 -2
  74. package/lib/quoxon/slash/restart.js +2 -0
  75. package/lib/quoxon/slotChat.js +7 -3
  76. package/lib/quoxon/slotRun.js +2 -0
  77. package/lib/quoxon/webPty.js +15 -0
  78. package/lib/quoxon/whatsnew.jsonl +7 -0
  79. package/lib/render/planBrief.js +614 -0
  80. package/lib/toolOutputShape.js +6 -3
  81. package/package.json +1 -1
  82. package/vendor/dragonlab/SOURCE +3 -3
  83. package/vendor/dragonlab/design/architect/v2/architect-master.svg +67 -0
  84. package/vendor/dragonlab/design/architect/v2/character.mjs +83 -0
  85. package/vendor/dragonlab/design/architect/v2/review.mjs +35 -0
  86. package/vendor/dragonlab/design/architect/v2/watch.mjs +56 -0
  87. package/vendor/dragonlab/design/archivist/v2/archivist-master.svg +77 -0
  88. package/vendor/dragonlab/design/archivist/v3/character.mjs +56 -0
  89. package/vendor/dragonlab/design/archivist/v4/character.mjs +46 -0
  90. package/vendor/dragonlab/design/archivist/v4/review.mjs +35 -0
  91. package/vendor/dragonlab/design/archivist/v4/watch.mjs +56 -0
  92. package/vendor/dragonlab/design/argus/v2/character.mjs +69 -0
  93. package/vendor/dragonlab/design/argus/v3/argus-master.svg +68 -0
  94. package/vendor/dragonlab/design/argus/v3/character.mjs +63 -0
  95. package/vendor/dragonlab/design/argus/v3/review.mjs +35 -0
  96. package/vendor/dragonlab/design/argus/v3/watch.mjs +56 -0
  97. package/vendor/dragonlab/design/daedalus/v2/character.mjs +49 -0
  98. package/vendor/dragonlab/design/daedalus/v3/daedalus-master.svg +65 -0
  99. package/vendor/dragonlab/design/daedalus/v4/character.mjs +47 -0
  100. package/vendor/dragonlab/design/daedalus/v5/character.mjs +66 -0
  101. package/vendor/dragonlab/design/daedalus/v6/character.mjs +32 -0
  102. package/vendor/dragonlab/design/daedalus/v6/review.mjs +35 -0
  103. package/vendor/dragonlab/design/daedalus/v6/watch.mjs +56 -0
  104. package/vendor/dragonlab/design/janus/v1/janus-master.svg +70 -0
  105. package/vendor/dragonlab/design/janus/v2/character.mjs +56 -0
  106. package/vendor/dragonlab/design/janus/v3/character.mjs +7 -0
  107. package/vendor/dragonlab/design/janus/v4/character.mjs +150 -0
  108. package/vendor/dragonlab/design/janus/v5/character.mjs +55 -0
  109. package/vendor/dragonlab/design/janus/v5/review.mjs +35 -0
  110. package/vendor/dragonlab/design/janus/v5/watch.mjs +56 -0
  111. package/vendor/dragonlab/design/roady/v1/roady-master.svg +67 -0
  112. package/vendor/dragonlab/design/roady/v2/character.mjs +69 -0
  113. package/vendor/dragonlab/design/roady/v3/character.mjs +91 -0
  114. package/vendor/dragonlab/design/roady/v3/review.mjs +35 -0
  115. package/vendor/dragonlab/design/roady/v3/watch.mjs +56 -0
  116. package/vendor/dragonlab/design/weaver/v3/weaver-master.svg +57 -0
  117. package/vendor/dragonlab/design/weaver/v4/character.mjs +56 -0
  118. package/vendor/dragonlab/design/weaver/v5/character.mjs +52 -0
  119. package/vendor/dragonlab/design/weaver/v6/character.mjs +88 -0
  120. package/vendor/dragonlab/design/weaver/v6/review.mjs +35 -0
  121. package/vendor/dragonlab/design/weaver/v6/watch.mjs +56 -0
  122. package/vendor/dragonlab/integration/companions-v1/architect.mjs +2 -0
  123. package/vendor/dragonlab/integration/companions-v1/archivist.mjs +2 -0
  124. package/vendor/dragonlab/integration/companions-v1/argus.mjs +2 -0
  125. package/vendor/dragonlab/integration/companions-v1/characters.mjs +86 -0
  126. package/vendor/dragonlab/integration/companions-v1/daedalus.mjs +2 -0
  127. package/vendor/dragonlab/integration/companions-v1/janus.mjs +2 -0
  128. package/vendor/dragonlab/integration/companions-v1/roady.mjs +2 -0
  129. package/vendor/dragonlab/integration/companions-v1/view.mjs +4 -1
  130. package/vendor/dragonlab/integration/companions-v1/weaver.mjs +2 -0
@@ -14,6 +14,20 @@ export function isServerDown(e) {
14
14
  return /ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|UND_ERR_SOCKET/.test(String(code)) || /Could not connect to|fetch failed|ECONNREFUSED/i.test(msg);
15
15
  }
16
16
 
17
+ /**
18
+ * Owner 2026-11-07: a resend of the SAME turn_id that the server says it already holds (409 turn_id_reused, or
19
+ * turn_in_use while it still runs; also the in-stream error the registry race sends). The first send reached the
20
+ * server before the socket dropped, so the right move is to attach to that turn, never to show a bare failure.
21
+ */
22
+ export function isTurnIdTaken(e) {
23
+ if (!e) return false;
24
+ const words = `${e.code || ''} ${e.body?.error || ''} ${typeof e.error === 'string' ? e.error : ''} ${e.message || ''}`;
25
+ if (e.type === 'error') return /turn_id_reused|this turn_id already ran|this turn_id is already running/i.test(words);
26
+ return Number(e.status) === 409 && /turn_id_reused|turn_in_use|turn_id already ran|already running/i.test(words);
27
+ }
28
+ /** The text shown when the held turn cannot be shown either: the message is restored, and the owner told it may have run. */
29
+ export const HELD_NOT_SHOWN = 'The server already received this message before the connection dropped, but could not show what happened to it. Your message is back in the composer; it may have run, so check /resume before sending it again.';
30
+
17
31
  /** gh #1157: resume during a collector restart. Connection-class failures (refused, fetch failed, hang up, 502/503/504) are worth retrying; 401/404 are answers. */
18
32
  export function isResumeRetryable(e) {
19
33
  if (!e) return false;
@@ -22,6 +22,7 @@ import { homedir } from 'node:os';
22
22
  import { join, dirname } from 'node:path';
23
23
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
24
24
  import { cellWidth } from './cells.js';
25
+ import { isWebPty, SHELL_REFUSED } from './webPty.js';
25
26
 
26
27
  export const MAX_ROWS = 3;
27
28
  /** Lines drawn above the status bar (a row can bring several with `lines`); the stored-row cap stays MAX_ROWS. */
@@ -566,8 +567,9 @@ export async function plainFetch(url, { timeoutMs = URL_TIMEOUT_MS, fetchImpl =
566
567
  }
567
568
 
568
569
  /** Run a shell command via the user's shell: timeout, output cap. -> stdout. Throws a short reason. */
569
- export function runShell(cmd, { timeoutMs = FETCH_TIMEOUT_MS, shell = process.env.SHELL || '/bin/sh', exec = execFile } = {}) {
570
+ export function runShell(cmd, { timeoutMs = FETCH_TIMEOUT_MS, shell = process.env.SHELL || '/bin/sh', exec = execFile, env = process.env } = {}) {
570
571
  return new Promise((resolve, reject) => {
572
+ if (isWebPty(env)) return reject(new Error(SHELL_REFUSED)); // QUOXON web PTY: command rows never run on the server
571
573
  exec(shell, ['-c', cmd], { timeout: timeoutMs, maxBuffer: OUTPUT_CAP, killSignal: 'SIGKILL', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }, (err, stdout) => {
572
574
  if (!err) return resolve(String(stdout ?? ''));
573
575
  if (err.killed || err.signal === 'SIGKILL') return reject(new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`));
@@ -56,6 +56,8 @@ export function findSecrets(text, opts = {}) {
56
56
  for (const m of s.matchAll(/customer[ \t]*(?:number|no\.?|num|id)[^\d\n]{0,15}(\d{6,})(?!\d)/gi)) add('customer-number', ...grp(m, 1));
57
57
  for (const m of s.matchAll(/(?<![\d-])\d(?:[ -]?\d){12,18}(?![\d-])/g)) {
58
58
  const d = m[0].replace(/[ -]/g, '');
59
+ // a digit run glued to letters/underscore is part of a longer token (hex disk serial, hash, id), never a PAN
60
+ if (/\w/.test(s[m.index - 1] ?? '') || /\w/.test(s[m.index + m[0].length] ?? '')) continue;
59
61
  if (precise) {
60
62
  if (!CARD_FORM.test(m[0]) || /[A-Za-z_]/.test(s[m.index - 1] ?? '') || /[A-Za-z_]/.test(s[m.index + m[0].length] ?? '')) continue;
61
63
  if (/^\d{4}(?:-\d{4}){3}$/.test(m[0]) && /[0-9a-f]{4,}-$/i.test(s.slice(Math.max(0, m.index - 6), m.index))) continue;
@@ -4,7 +4,7 @@
4
4
  * fleet and org reach, bulletins and drafting are not built.
5
5
  */
6
6
  export const BROADCAST_USAGE = 'Usage: /broadcast [--slots a,b] [--priority urgent] [--ack] [--active [--dry-run]] <text>';
7
- export const PAGE_USAGE = 'Usage: /page <slot> <text>';
7
+ export const PAGE_USAGE = 'Usage: /page <slot>[,<slot>...] <text> (several slots: a,b,c or a b c)';
8
8
  const NOT_YET = new Set(['--fleet', '--org', '--voice', '--at']);
9
9
 
10
10
  /**
@@ -44,11 +44,35 @@ export function activeLine({ reached = 0, skipped = 0, dryRun = false } = {}) {
44
44
  return `\u{1F4CD} QUOXSLOTS \u2502 broadcast to active \u2502 ${reached} ${dryRun ? 'would reach' : 'reached'} \u00B7 ${skipped} skipped dormant \u2714`;
45
45
  }
46
46
 
47
- /** `/page <slot> <text>` -> urgent, ack-required, one recipient. */
48
- export function parsePageArgs(arg) {
49
- const m = /^(\S+)\s+([\s\S]*\S[\s\S]*)$/.exec(String(arg || '').trim());
50
- if (!m || m[1].startsWith('--')) return { ok: false, message: PAGE_USAGE };
51
- return { ok: true, text: m[2].trim(), slots: [m[1].toLowerCase()], priority: 'urgent', ack: true };
47
+ /**
48
+ * TUI-WAKE-MULTI (owner 2026-11-08): the leading slot names of a /page or /wake line, then the message.
49
+ * The first name is always a target. A name joined by a tight comma (seer,quoxon) is always a target, known or not,
50
+ * so an unknown one is reported per slot by the server. A name after a space, or after ", ", is a target only when
51
+ * it is in `known` (the org's slot names); the first word that is not starts the message, kept exactly as typed.
52
+ * With no `known` list only the tight comma list is targets. -> { slots: [lowercase, deduped], text } | null.
53
+ */
54
+ export function splitTargets(arg, known = null) {
55
+ const set = known ? new Set([...known].map((x) => String(x).toLowerCase())) : null;
56
+ const slots = []; let rest = String(arg || '').trim(); let tight = true;
57
+ for (;;) {
58
+ const m = /^\s*([^\s,]+)/.exec(rest);
59
+ if (!m) break;
60
+ const name = m[1].toLowerCase();
61
+ if (!slots.length && name.startsWith('--')) return null;
62
+ if (!tight && !(set && set.has(name))) break;
63
+ slots.push(name); rest = rest.slice(m[0].length);
64
+ const c = /^\s*,(\s*)/.exec(rest);
65
+ tight = Boolean(c && !c[1]);
66
+ if (c) rest = rest.slice(c[0].length);
67
+ }
68
+ return slots.length ? { slots: [...new Set(slots)], text: rest.trim() } : null;
69
+ }
70
+
71
+ /** `/page <slot>[,<slot>...| <known slot>...] <text>` -> urgent, ack-required, every named recipient. */
72
+ export function parsePageArgs(arg, known = null) {
73
+ const t = splitTargets(arg, known);
74
+ if (!t || !t.text) return { ok: false, message: PAGE_USAGE };
75
+ return { ok: true, text: t.text, slots: t.slots, priority: 'urgent', ack: true };
52
76
  }
53
77
 
54
78
  const plural = (n) => (n === 1 ? 'session' : 'sessions');
@@ -3,7 +3,7 @@
3
3
  * One server call fans out (POST /api/v1/slots/broadcast); with --ack the sender then sees the delivery line
4
4
  * update as recipients acknowledge. The poll is display only: the broadcast and its acks live on the server.
5
5
  */
6
- import { parseBroadcastArgs, parsePageArgs, parseAckArgs, parseStatusArgs, broadcastSummary, broadcastCard, BROADCAST_USAGE, activeLine } from './broadcast.js';
6
+ import { parseBroadcastArgs, parsePageArgs, splitTargets, parseAckArgs, parseStatusArgs, broadcastSummary, broadcastCard, BROADCAST_USAGE, activeLine } from './broadcast.js';
7
7
 
8
8
  const roomC = (r) => ({ sent: 0, sending: 0, failed: 0, ...(r?.counts?.room || {}) });
9
9
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -20,7 +20,7 @@ export async function runBroadcast({ api, kind, arg, from, say, card = null, isC
20
20
  const st = api.slotBroadcastStatus ? await api.slotBroadcastStatus(st0.id) : { ok: false, message: 'not available in this mode' };
21
21
  return st.ok ? show(st.data) : say(`Broadcast status failed: ${st.message}`, 'warn');
22
22
  }
23
- const parsed = kind === 'page' ? parsePageArgs(arg) : parseBroadcastArgs(arg);
23
+ const parsed = kind === 'page' ? parsePageArgs(arg, await slotNames(api)) : parseBroadcastArgs(arg);
24
24
  if (!parsed.ok) return say(parsed.message, parsed.message === BROADCAST_USAGE ? 'plain' : 'warn');
25
25
  const r = await api.slotBroadcast({ text: parsed.text, slots: parsed.slots, priority: parsed.priority, ack: parsed.ack, active: parsed.active, ...(parsed.dryRun ? { dryRun: true } : {}), from });
26
26
  if (!r.ok) return say(`${kind === 'page' ? 'Page' : 'Broadcast'} failed: ${r.message}`, 'warn');
@@ -72,13 +72,19 @@ export async function runBroadcast({ api, kind, arg, from, say, card = null, isC
72
72
  }
73
73
 
74
74
  /**
75
- * FORCE-WAKE: `/poke|/wake|/slap|/nudge <slot> [text]`. `--force` is accepted anywhere as a no-op (every human
75
+ * FORCE-WAKE: `/poke|/wake|/slap|/nudge <slot>[,<slot>...] [text]` (several slots: see splitTargets). `--force` is accepted anywhere as a no-op (every human
76
76
  * wake already sets an unsent draft aside and puts it back) and is never page text. null = no slot given.
77
77
  */
78
- export function parsePokeArgs(restArg) {
78
+ export function parsePokeArgs(restArg, known = null) {
79
79
  const s = String(restArg || '').replace(/(^|\s)--force(?=\s|$)/g, '$1').trim();
80
- const m = /^(\S+)(?:\s+([\s\S]+))?$/.exec(s);
81
- return m ? { slot: m[1], text: m[2] ? m[2].trim() : null } : null;
80
+ const t = splitTargets(s, known); // TUI-WAKE-MULTI: `slot` is the comma-joined target list, /page splits it again
81
+ return t ? { slot: t.slots.join(','), text: t.text || null } : null;
82
+ }
83
+
84
+ /** The org's slot names for splitTargets, or null when they cannot be read (then only a comma list is targets). */
85
+ export async function slotNames(api) {
86
+ if (!api?.slots) return null;
87
+ try { const rows = await api.slots(); return Array.isArray(rows) ? rows.map((r) => r?.name).filter(Boolean) : null; } catch { return null; }
82
88
  }
83
89
 
84
90
  /**
@@ -23,6 +23,9 @@
23
23
  "admin retention cleanup":"write",
24
24
  "admin retention config":"write",
25
25
  "admin retention stats":"read",
26
+ "admin scratch-orgs list":"read",
27
+ "admin scratch-orgs mark":"never",
28
+ "admin scratch-orgs unmark":"never",
26
29
  "admin service list":"read",
27
30
  "admin stats":"read",
28
31
  "admin users verify-email":"write",
@@ -142,6 +145,7 @@
142
145
  "build status":"read",
143
146
  "build targets":"read",
144
147
  "calendar agenda":"read",
148
+ "calendar connections add":"never",
145
149
  "calendar coverage":"read",
146
150
  "calendar layers":"read",
147
151
  "calendar propose":"write",
@@ -338,7 +342,15 @@
338
342
  "fleet gpu":"read",
339
343
  "fleet host-update":"write",
340
344
  "fleet hosts":"read",
345
+ "fleet hw events":"read",
346
+ "fleet hw identify":"write",
347
+ "fleet hw refresh":"read",
341
348
  "fleet ping":"write",
349
+ "fleet recipe add":"write",
350
+ "fleet recipe list":"read",
351
+ "fleet recipe remove":"write",
352
+ "fleet recipe run":"write",
353
+ "fleet recipe show":"read",
342
354
  "fleet rehome-agent":"write",
343
355
  "fleet status":"read",
344
356
  "fleet summary":"read",
@@ -430,6 +442,10 @@
430
442
  "kuma status-incident publish":"write",
431
443
  "kuma status-incident resolve":"write",
432
444
  "kuma uptime":"write",
445
+ "lens apply":"write",
446
+ "lens list":"read",
447
+ "lens panel":"write",
448
+ "lens show":"read",
433
449
  "licence catalogue-sign":"never",
434
450
  "licence refresh":"write",
435
451
  "licence reissue":"never",
@@ -458,12 +474,27 @@
458
474
  "lucid area":"read",
459
475
  "lucid ask":"read",
460
476
  "lucid blind":"write",
477
+ "lucid brief":"read",
461
478
  "lucid deck":"never",
462
479
  "lucid do":"write",
463
480
  "lucid feeds":"read",
464
481
  "lucid forget":"write",
482
+ "lucid goals add":"write",
483
+ "lucid goals archive":"write",
484
+ "lucid goals change":"write",
485
+ "lucid goals confirm":"write",
486
+ "lucid goals erase":"never",
487
+ "lucid goals export":"write",
488
+ "lucid goals history":"read",
489
+ "lucid goals link":"write",
490
+ "lucid goals restore":"never",
491
+ "lucid goals show":"read",
465
492
  "lucid herald set":"write",
466
493
  "lucid herald test":"write",
494
+ "lucid insights accept":"write",
495
+ "lucid insights reject":"write",
496
+ "lucid insights show":"read",
497
+ "lucid insights status":"read",
467
498
  "lucid journal":"read",
468
499
  "lucid lessons":"read",
469
500
  "lucid replay":"read",
@@ -476,9 +507,12 @@
476
507
  "lucid why":"read",
477
508
  "mail account pin":"write",
478
509
  "mail accounts":"read",
510
+ "mail block":"write",
511
+ "mail blocked":"read",
479
512
  "mail connect":"never",
480
513
  "mail disconnect":"write",
481
514
  "mail import":"never",
515
+ "mail junk":"write",
482
516
  "mail list":"read",
483
517
  "mail moves":"read",
484
518
  "mail proposals":"read",
@@ -487,12 +521,18 @@
487
521
  "mail resolve":"write",
488
522
  "mail sync":"write",
489
523
  "mail sync-status":"write",
524
+ "mail trust":"write",
525
+ "mail unblock":"write",
490
526
  "mail undo":"write",
491
527
  "mail watch active":"write",
492
528
  "mail watch add":"write",
493
529
  "mail watch list":"read",
494
530
  "mail watch rm":"write",
495
531
  "mail zones":"read",
532
+ "mcp bundle":"never",
533
+ "mcp token create":"never",
534
+ "mcp token list":"read",
535
+ "mcp token revoke":"write",
496
536
  "memory core":"read",
497
537
  "memory create":"write",
498
538
  "memory delete":"write",
@@ -546,8 +586,10 @@
546
586
  "mon wazuh triage":"read",
547
587
  "mon wazuh vulns":"read",
548
588
  "monitoring health":"read",
589
+ "monitoring incidents claim":"write",
549
590
  "monitoring incidents get":"read",
550
591
  "monitoring incidents list":"read",
592
+ "monitoring incidents status":"write",
551
593
  "motion":"write",
552
594
  "notes actions":"write",
553
595
  "notes append":"write",
@@ -578,11 +620,13 @@
578
620
  "notes schedule resume":"write",
579
621
  "notes schedule rm":"write",
580
622
  "notes scorecard":"write",
623
+ "notes seal convert":"never",
581
624
  "notes seal edit":"never",
582
625
  "notes seal init":"write",
583
626
  "notes seal new":"write",
584
627
  "notes seal open":"never",
585
628
  "notes seal print":"write",
629
+ "notes seal purge":"never",
586
630
  "notes seal status":"read",
587
631
  "notes search":"read",
588
632
  "notes settings list":"read",
@@ -606,6 +650,7 @@
606
650
  "objective list":"read",
607
651
  "objective retry":"write",
608
652
  "objective start":"write",
653
+ "offload leases":"read",
609
654
  "offload release":"write",
610
655
  "offload status":"read",
611
656
  "onboard":"write",
@@ -627,15 +672,22 @@
627
672
  "org update":"write",
628
673
  "peek":"read",
629
674
  "plan add":"write",
675
+ "plan brief":"read",
630
676
  "plan completed":"read",
631
677
  "plan focus":"read",
678
+ "plan followup add":"write",
679
+ "plan followup done":"write",
680
+ "plan followup status":"write",
681
+ "plan followups":"read",
632
682
  "plan list":"read",
633
683
  "plan move":"write",
634
684
  "plan note":"write",
635
685
  "plan phase":"write",
636
686
  "plan plan":"write",
637
687
  "plan rename":"write",
688
+ "plan roadmap archive":"write",
638
689
  "plan roadmap create":"write",
690
+ "plan roadmap unarchive":"write",
639
691
  "plan roadmaps":"read",
640
692
  "plan search":"read",
641
693
  "plan show":"read",
@@ -643,6 +695,7 @@
643
695
  "plan status":"write",
644
696
  "plan timeline":"read",
645
697
  "plan update":"write",
698
+ "plan watch":"never",
646
699
  "plugin assign add":"write",
647
700
  "plugin assign list":"read",
648
701
  "plugin assign remove":"write",
@@ -725,6 +778,7 @@
725
778
  "quoxchat widgets":"read",
726
779
  "reach agent":"read",
727
780
  "reach unused":"read",
781
+ "research ask":"write",
728
782
  "research domains add":"write",
729
783
  "research domains list":"read",
730
784
  "research domains remove":"write",
@@ -10,6 +10,7 @@
10
10
  * on the dashboard home instead; nothing breaks.
11
11
  */
12
12
  import { spawn as nodeSpawn } from 'node:child_process';
13
+ import { isWebPty } from '../webPty.js';
13
14
 
14
15
  /** Page name -> real route in src/config/routeRegistry.js (approvals = the HITL /inbox, inbox = InboxQ mail). */
15
16
  export const PAGES = {
@@ -40,6 +41,7 @@ export function openerFor(platform = process.platform) {
40
41
 
41
42
  /** -> { opened: true } | { opened: false, reason: 'ssh' | 'no-opener' | 'failed' }. Never throws, never uses a shell. */
42
43
  export function openInBrowser(url, { env = process.env, platform = process.platform, spawn = nodeSpawn } = {}) {
44
+ if (isWebPty(env)) return Promise.resolve({ opened: false, reason: 'no-opener' }); // QUOXON web PTY: the link is printed, nothing starts on the server
43
45
  if (isRemoteSession(env)) return Promise.resolve({ opened: false, reason: 'ssh' });
44
46
  const op = openerFor(platform);
45
47
  if (!op) return Promise.resolve({ opened: false, reason: 'no-opener' });
@@ -9,7 +9,7 @@ import { c, clip } from '../theme.js';
9
9
 
10
10
  const h = React.createElement;
11
11
 
12
- export function ListView({ title = null, rows = [], sel = 0, cols = 80, hint = null, empty = null, more = 0 }) {
12
+ export function ListView({ title = null, rows = [], sel = 0, cols = 80, hint = null, empty = null, more = 0, notes = [] }) {
13
13
  // The key column grows with the window (22 cells minimum, 32 at most) and always leaves a one-cell gap, so a name that fits is shown whole;
14
14
  // one that does not gets the ellipsis from truncate-end, never a silent cut.
15
15
  const keyW = Math.min(Math.max(22, Math.min(32, Math.floor(cols / 3))), Math.max(8, ...rows.map(r => [...String(r.key || '')].length)) + 1);
@@ -26,5 +26,6 @@ export function ListView({ title = null, rows = [], sel = 0, cols = 80, hint = n
26
26
  h(Text, { color: c(r.dim ? 'muted' : 'text'), wrap: 'truncate-end' }, clip(String(body || ''), textW)));
27
27
  }),
28
28
  more > 0 ? h(Text, { color: c('muted') }, ` … ${more} more, keep typing to narrow`) : null,
29
+ ...(notes || []).map((n, i) => h(Text, { key: `note:${i}`, color: c('muted'), wrap: 'truncate-end' }, ` ${clip(n, cols - 4)}`)), // never selectable: no marker, not in rows
29
30
  hint ? h(Text, { color: c('dim') }, ` ${clip(hint, cols - 4)}`) : null);
30
31
  }
@@ -109,6 +109,23 @@ export function filterPalette(items, query) {
109
109
  return scored.sort((a, b) => a.best - b.best || a.idx - b.idx).map(x => x.it);
110
110
  }
111
111
 
112
+ /**
113
+ * A match too loose to pre-highlight: not a prefix of the name or an alias, and not a description word that starts
114
+ * with a 2+ letter query. "/y" -> /hyperion (a letter inside the name) is weak; "/one" -> /approvals ("one at a time")
115
+ * is not. A weak top row is shown without the marker until the user moves the selection.
116
+ */
117
+ const isSubsequence = (q, s) => { let i = 0; for (const ch of s) if (ch === q[i]) i++; return i === q.length; };
118
+
119
+ export function weakMatch(query, it) {
120
+ const q = String(query ?? '').toLowerCase();
121
+ if (!q || !it) return false;
122
+ if ([it.id, ...(it.aliases || []).map(a => a.slice(1))].some(n => String(n).toLowerCase().startsWith(q))) return false;
123
+ // A 3+ letter abbreviation of the name ("/aprvl" -> /approvals) is deliberate, not a stray letter inside it.
124
+ if (q.length >= 3 && isSubsequence(q, String(it.id).toLowerCase())) return false;
125
+ const long = String(it.longDesc || it.desc || '').toLowerCase();
126
+ return !(q.length >= 2 && new RegExp('(^|[^a-z0-9])' + q.replace(/[^a-z0-9]/g, '\\$&')).test(long));
127
+ }
128
+
112
129
  /** Bulk or destructive commands: never offered (so never pre-highlighted) by a loose query (owner 2026-10-26: Enter on "/" approved everything). */
113
130
  export const GUARDED_IDS = new Set(['yestoall', 'approve', 'deny']);
114
131
 
@@ -123,6 +140,30 @@ export function guardBulk(list, query) {
123
140
  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
141
  }
125
142
 
143
+ /**
144
+ * The guarded commands a loose query hid, so the palette can say why they are missing instead of leaving a weak
145
+ * fuzzy hit on top (owner 2026-11-05: "/y" showed /hyperion). Only names that START with the query count, and only
146
+ * for a non-empty query (a bare "/" stays quiet). `reach` is the shortest input that makes it the unique match.
147
+ * These are notes, never rows: the guard still decides what can be selected.
148
+ */
149
+ export function guardedHints(items, query) {
150
+ const q = String(query ?? '').toLowerCase();
151
+ if (!q) return [];
152
+ const shown = (p) => guardBulk(filterPalette(items, p), p).map(i => i.id);
153
+ const now = shown(q);
154
+ const out = [];
155
+ for (const it of items) {
156
+ if (!GUARDED_IDS.has(it.id) || it.planned || now.includes(it.id) || !it.id.startsWith(q)) continue;
157
+ let reach = it.name;
158
+ for (let n = Math.max(3, q.length + 1); n < it.id.length; n++) {
159
+ const p = it.id.slice(0, n);
160
+ if (shown(p).includes(it.id)) { reach = '/' + p; break; }
161
+ }
162
+ out.push({ name: it.name, reach });
163
+ }
164
+ return out;
165
+ }
166
+
126
167
  /** What Tab puts in the draft: the name, plus a space when the command takes arguments. */
127
168
  export function completion(item) {
128
169
  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, guardBulk, completion, paletteView, PALETTE_MAX } from './palette.js';
8
+ import { paletteQuery, paletteItems, filterPalette, guardBulk, guardedHints, weakMatch, 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
 
@@ -79,6 +79,8 @@ export function createPalette({
79
79
  if (q !== st.q) { st.q = q; st.sel = 0; st.picked = false; }
80
80
  return q === null ? [] : guardBulk(filterPalette(paletteItems(ctx()), q), q);
81
81
  };
82
+ /** The top row is only a weak match (weakMatch) and the user has not moved the selection: it is not highlighted. */
83
+ const weakTop = (list) => !st.picked && list.length > 0 && weakMatch(st.q, list[0]);
82
84
  const isOpen = () => {
83
85
  const d = getDraft();
84
86
  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)
@@ -162,9 +164,11 @@ export function createPalette({
162
164
  }
163
165
  const list = rows();
164
166
  const v = paletteView(list, st.sel, PALETTE_MAX);
167
+ const q = paletteQuery(getDraft());
168
+ const notes = guardedHints(paletteItems(ctx()), q).map(g => `${g.name} is held back so a stray Enter cannot run it: type ${g.reach} to reach it`);
165
169
  return {
166
170
  rows: v.rows.map(it => ({ key: it.name, text: (it.aliases.length ? `(also ${it.aliases.join(', ')}) ` : '') + it.desc, dim: Boolean(it.disabled), note: it.disabled || null })),
167
- sel: v.sel, more: v.more,
171
+ sel: weakTop(list) ? -1 : v.sel, more: v.more, notes,
168
172
  empty: 'No command matches. Enter sends it anyway; /help lists everything.',
169
173
  hint: '↑↓ select · Tab complete · Enter run · Esc close'
170
174
  };
@@ -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)), '..', '..', '..');
@@ -205,6 +206,7 @@ export function restartPlan({ argv, conversationId, profile = null, name = null,
205
206
  * this process, which stays resident until the successor exits. The launcher path (launcher.js) does not.
206
207
  */
207
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
208
210
  const plan = restartPlan({ argv, conversationId, profile, name, slot, attach, org, mode, declined, env, bin });
209
211
  restore(); // the child owns the terminal now
210
212
  const r = spawn(plan.cmd, plan.args, { stdio: 'inherit', env: plan.env });
@@ -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) : '';
@@ -127,7 +131,7 @@ export function withSenderSlot(rows, m) {
127
131
 
128
132
  /** A slot message as a chat line: { glyph, who, time, text, file }. */
129
133
  export function slotLine(m) {
130
- 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
131
135
  const f = parseFileMessage(m?.text);
132
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;
133
137
  // SLOTS-REPLY: the slot is the address, so it leads; the session/display name follows in brackets. A sender
@@ -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 */ } };
@@ -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';
@@ -168,3 +168,10 @@
168
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
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
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."}
177
+ {"id": "quoxon-2026-11-10-dragons-quiet-and-four-new", "date": "2026-11-10T16:00:00Z", "since": "0.4.0", "sha": "4a4a9b326", "title": "Dragons stay quiet, and four new companions", "text": "A companion dragon only shows a caption when something real happens: an approval waiting, a failed step, a status check that found a problem. Arriving, idling and working show no label. Janus has the original look back, and Archivist, Architect, Roady and Weaver now have their own dragons.", "try": "/attach agent:janus"}