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
@@ -356,6 +356,8 @@ export const NO_REASON = 'No reason given: ask the session, or say no.';
356
356
  export const NO_JUSTIFICATION = 'No plain justification given: ask the session, or say no.';
357
357
  const justificationOf = (p) => String(p.justification || p.plain_justification || '').replace(/\s+/g, ' ').trim();
358
358
  /** ADVICE for a test-edit (loosening) card: only facts the card carries (payload.diff_stats), never the asker's own recommendation, which has its own star line. */
359
+ /** ADVICE for an ordinary decision whose asker starred an option: the star line already shows the asker's pick, so ADVICE must not echo it back as if it were an independent view (owner 2026-11-08: "it's a bad reason"). */
360
+ export const NO_INDEPENDENT_VIEW_ASK = 'No independent view: the star marks the asker\'s own pick. Judge the WHY, or press ? to ask about it.';
359
361
  export const NO_INDEPENDENT_VIEW = 'No independent view: judge the JUSTIFY text and the diff (/diff or the files listed).';
360
362
  /** The `quox ask loosening` verb titles its card "Approve test edits: <paths>", which isLoosening does not match. */
361
363
  const TEST_EDIT_RE = /approve\s+(?:loosening|test)\b/i;
@@ -385,7 +387,7 @@ export function walkCard(it, i, total, { cols = 0 } = {}) {
385
387
  : rec && (loose || TEST_EDIT_RE.test(String(it.title || '')))
386
388
  ? looseAdvice(p)
387
389
  : rec
388
- ? `I would ${yn ? (rec.key === 'y' ? 'say yes' : 'say no') : `go with ${optWord(rec)} (${rec.key})`}. The asker recommended it.`
390
+ ? NO_INDEPENDENT_VIEW_ASK
389
391
  : `${who} gave no recommendation, and I will not guess one.${dflt ? ` If nobody answers it would take ${optWord(dflt)} (${dflt.key}).` : ''}`;
390
392
  const keyClash = (k) => p.options.some(o => o.key === k);
391
393
  const choices = [
package/lib/quoxon/ask.js CHANGED
@@ -86,7 +86,7 @@ export function askClock(at = Date.now()) {
86
86
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
87
87
  }
88
88
 
89
- /** The one line an ask leaves behind. outcome.kind: yes | no | run | skipped (outcome.cmd) | expired | closed. */
89
+ /** The one line an ask leaves behind. outcome.kind: yes | no | run | skipped (outcome.cmd) | expired | elsewhere (outcome.choice, outcome.at) | closed. */
90
90
  export function settledAskLine(question, outcome, at = Date.now()) {
91
91
  const q = String(question || '').trim();
92
92
  const t = askClock(at);
@@ -97,11 +97,13 @@ export function settledAskLine(question, outcome, at = Date.now()) {
97
97
  case 'skipped': return `✔ ${q} skipped, you ran ${String(outcome.cmd || 'a command').split(/\s/)[0]} (${t})`;
98
98
  case 'dismissed': return `↷ ${q} offer dismissed (${t})`;
99
99
  case 'expired': return `⌛ ${q} expired, not answered (${t})`;
100
+ // TUI-CARD-SYNC (owner 2026-11-08): answered in another window; the time is when it was answered there, never this settle
101
+ case 'elsewhere': return `✔ ${q} answered elsewhere (${[outcome.choice || null, Number.isFinite(outcome.at) ? askClock(outcome.at) : null].filter(Boolean).join(', ') || 'by another window'})`;
100
102
  default: return `↷ ${q} no longer open (${t})`;
101
103
  }
102
104
  }
103
105
  /** The note tone a settled line is shown in (never dim: no grey text). */
104
- export const settledAskTone = (kind) => (kind === 'yes' || kind === 'run' ? 'ok' : 'plain');
106
+ export const settledAskTone = (kind) => (kind === 'yes' || kind === 'run' || kind === 'elsewhere' ? 'ok' : 'plain');
105
107
 
106
108
  /** What a submitted line does to an open ask: { kind } for an answer, or null (c / free text keeps it open). */
107
109
  export function askOutcomeOf(text, { runWord = false } = {}) {
@@ -12,6 +12,7 @@
12
12
  * is handed the terminal, the same way a slot run request is.
13
13
  */
14
14
  import { spawn } from 'node:child_process';
15
+ import { isWebPty, SHELL_REFUSED } from './webPty.js';
15
16
  import { fileURLToPath } from 'node:url';
16
17
  import { dirname, join } from 'node:path';
17
18
 
@@ -133,7 +134,12 @@ const STDIN_HINT = /stdin|end of file|\bEOF\b|password|passphrase|\[y\/n\]|\(y\/
133
134
  * result: { code, signal, ms, timedOut, killed, lines, truncated, text, stdinNote }
134
135
  * onData(text) is called with accepted output only (capped at MAX_LINES / MAX_BYTES); `text` is the same capped capture.
135
136
  */
136
- export function runBang(plan, { cwd = process.cwd(), env = process.env, timeoutMs, onData = () => {}, graceMs = KILL_GRACE_MS, now = Date.now, shell } = {}) {
137
+ export function runBang(plan, { cwd = process.cwd(), env = process.env, timeoutMs, onData = () => {}, graceMs = KILL_GRACE_MS, now = Date.now, shell, columns } = {}) {
138
+ if (isWebPty(env)) { // QUOXON web PTY: no local process from a browser terminal
139
+ return { kill: () => {}, done: Promise.resolve({ code: 126, signal: null, ms: 0, timedOut: false, killed: false, lines: 1, truncated: false, text: `${SHELL_REFUSED}\n` }) };
140
+ }
141
+ // The child writes to a pipe, so it cannot see the pane width; COLUMNS tells it (tables fit the TUI, owner 2026-11-07).
142
+ if (Number(columns) > 0) env = { ...env, COLUMNS: String(Math.floor(Number(columns))) };
137
143
  const tmo = Number(timeoutMs ?? env.QUOX_BANG_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS;
138
144
  const t0 = now();
139
145
  let file, args;
@@ -64,7 +64,7 @@ export const SHORT = {
64
64
  leave: 'leave a slot room you joined',
65
65
  rooms: 'rooms this window has joined, with unread counts',
66
66
  broadcast: 'tell every held slot in this org at once',
67
- page: 'urgent message to one slot, with acknowledgement',
67
+ page: 'urgent message to one or more slots, acked',
68
68
  ack: 'acknowledge a broadcast',
69
69
  send: 'send a local text file into a slot (64 KB cap)',
70
70
  attach: 'send your turns to a specific agent',
@@ -63,7 +63,7 @@ const ARGS = {
63
63
  broadcast: [{ after: [], choices: [{ value: '--slots', desc: 'only these slots, comma separated' }, { value: '--priority', desc: 'urgent shows red' }, { value: '--ack', desc: 'ask recipients to /ack' }, { value: '--active', desc: 'only sessions listening now; dormant are skipped' }, { value: '--dry-run', desc: 'with --active: list who would be reached, send nothing' }] }],
64
64
  send: [{ after: [], src: 'slots' }, { after: ['*'], src: 'files', of: 'partial' }],
65
65
  dragon: [
66
- { after: [], choices: ['explain', 'ask', 'sleep', 'fly', 'hero', 'fire', 'classic', { value: 'companion', desc: 'the dragon beside an attached Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan or Quoxperticus' }] },
66
+ { after: [], choices: ['explain', 'ask', 'sleep', 'fly', 'hero', 'fire', 'classic', { value: 'companion', desc: 'the dragon beside an attached Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus, Janus, Archivist, Architect, Roady or Weaver' }] },
67
67
  { after: ['companion'], choices: [{ value: 'on', desc: 'on attach and key moments, then leaves' }, { value: 'greeting', desc: 'only on attach, then leaves' }, { value: 'off', desc: 'never on its own (/dragon shows it)' }, { value: 'status', desc: 'what is set and whether the art loads' }] }
68
68
  ],
69
69
  ext: [
@@ -71,7 +71,7 @@ const ARGS = {
71
71
  { after: ['on'], src: 'ext.listed' }, { after: ['off'], src: 'ext.loaded' }, { after: ['tab'], src: 'ext.panels' },
72
72
  { after: ['set'], src: 'ext.loaded' }, { after: ['settings'], src: 'ext.loaded' }, { after: ['set', '*'], src: 'ext.keys', of: 1 }
73
73
  ],
74
- inbox: [{ after: [], choices: [{ value: 'read', desc: 'read N | all: mark items read' }, { value: 'open', desc: 'open N: take an approval to /approvals' }, { value: 'latest', desc: 'the latest 10' }, { value: 'mute', desc: 'mute <from> [1h|8h|1d]: no arrival lines from that source (approvals never muted)' }, { value: 'unmute', desc: 'unmute <from>' }, { value: 'mutes', desc: 'muted sources and when they expire' }] }, { after: ['mute'], src: 'inbox.sources' }, { after: ['unmute'], src: 'inbox.mutes' }],
74
+ inbox: [{ after: [], choices: [{ value: 'show', desc: 'show N: the whole item (same as /inbox N)' }, { value: 'read', desc: 'read N | all: mark items read (does not show them)' }, { value: 'open', desc: 'open N: an approval goes to /approvals, any other item is shown' }, { value: 'latest', desc: 'the latest 10' }, { value: 'mute', desc: 'mute <from> [1h|8h|1d]: no arrival lines from that source (approvals never muted)' }, { value: 'unmute', desc: 'unmute <from>' }, { value: 'mutes', desc: 'muted sources and when they expire' }] }, { after: ['mute'], src: 'inbox.sources' }, { after: ['unmute'], src: 'inbox.mutes' }],
75
75
  tips: [{ after: [], choices: topicList().map(t => ({ value: t.topic, desc: `${t.count} tip${t.count === 1 ? "" : "s"}` })) }],
76
76
  whatsnew: [{ after: [], choices: [{ value: 'all', desc: 'every command and entry, regardless of seen' }, { value: 'on', desc: 'show the card at startup when something is new' }, { value: 'off', desc: 'stop the startup card' }] }],
77
77
  'tip-settings': [{ after: [], choices: [
@@ -133,7 +133,7 @@ export const REGISTRY = [
133
133
  // Approvals
134
134
  { id: 'approvals', kind: 'command', group: 'Approvals', usage: '/approvals [mine|agents|old|<agent>|N|auto on|off]', desc: 'the questions waiting for you, one at a time', server: true, cap: 'approvals', status: 'active',
135
135
  detail: 'New approvals and questions open by themselves as the one-at-a-time card (after your draft, typing pause or turn; never mid-typing, never answered for you), including ones in your other orgs, which are answered there without switching. /approvals auto off keeps them as the ⏸ line; the setting is saved on this profile.' },
136
- { id: 'inbox', kind: 'command', new: true, group: 'Approvals', usage: '/inbox [N | read N|N-M|all | open N | mute <from> [1h|8h|1d] | unmute <from> | mutes]', desc: 'your Quox inbox: the latest 10 as a table, N shows one in full, read marks read, open takes an approval to /approvals; mute silences a source\'s arrival lines (default 8h, approvals never muted), repeats of the same title collapse to (×N)', needs: ['inboxItems'], cap: 'approvals', status: 'active', args: ARGS.inbox },
136
+ { id: 'inbox', kind: 'command', new: true, group: 'Approvals', usage: '/inbox [N | show N | read N|N-M|all | open N | mute <from> [1h|8h|1d] | unmute <from> | mutes]', desc: 'your Quox inbox: the latest 10 as a table, N (or show N, N show) shows one in full, read marks read without showing, open takes an approval to /approvals and shows any other item; mute silences a source\'s arrival lines (default 8h, approvals never muted), repeats of the same title collapse to (×N)', needs: ['inboxItems'], cap: 'approvals', status: 'active', args: ARGS.inbox },
137
137
  { id: 'mail', kind: 'command', new: true, group: 'Approvals', usage: '/mail [N | focus|fyi|auto|screened]', desc: 'your triaged email (InboxQ), read only: the latest 10, N reads one with its triage reason', needs: ['mailMessages'], status: 'active' },
138
138
  { id: 'walkthrough', kind: 'command', new: true, group: 'Approvals', usage: '/walkthrough', desc: 'walk through what is waiting for you one question at a time: what it does, why, my advice, then your choice (same as /approvals walkthrough)', server: true, cap: 'approvals', status: 'active' },
139
139
  { id: 'yestoall', kind: 'command', new: true, group: 'Approvals', usage: '/yestoall', desc: 'approve everything waiting for you (same as /approve all; privileged ones still ask)', server: true, cap: 'approvals', status: 'active' },
@@ -252,7 +252,7 @@ export const REGISTRY = [
252
252
  { id: 'rooms', kind: 'command', new: true, group: 'Conversation', usage: '/rooms', desc: 'rooms (groups) this window has joined, with unread counts; also on the status line', needs: ['slotSay'], cap: 'slots', status: 'active', aliases: ['groups'] },
253
253
  { id: 'broadcast', kind: 'command', new: true, group: 'Conversation', usage: '/broadcast [--slots a,b] [--priority urgent] [--ack] [--active [--dry-run]] <text>', desc: 'tell every held slot in this org at once, signed as you', needs: ['slotBroadcast'], cap: 'slots', status: 'active', aliases: ['roar', 'broadcast-active'], args: ARGS.broadcast,
254
254
  detail: 'One server call delivers to every slot held in this org (never another org), or only --slots a,b. --priority urgent marks it red for recipients. --ack asks them to reply /ack <id>; you then see "Broadcast delivery: delivered N / acknowledged M / awaiting K" update. You get a framed card: how many sessions have it (stored in the slot, listening sessions see it at once) and a separate chat-rooms count (sent / sending / failed). /broadcast status <id> redraws it. Typing it is your consent. --active (or /broadcast-active) reaches only sessions listening right now (a live slot wait); dormant slots are skipped and not woken, and the card says how many. --dry-run (with --active) lists who would be reached and who is skipped, and sends nothing. Not yet: --at (schedule), --fleet, --org, --voice.' },
255
- { id: 'page', kind: 'command', new: true, group: 'Conversation', usage: '/page <slot> <text>', desc: 'urgent message to one slot, acknowledgement requested', needs: ['slotBroadcast'], cap: 'slots', status: 'active', args: ARGS.say,
255
+ { id: 'page', kind: 'command', new: true, group: 'Conversation', usage: '/page <slot>[,<slot>...] <text>', desc: 'urgent message to one or more slots (a,b,c or a b c), acknowledgement requested', needs: ['slotBroadcast'], cap: 'slots', status: 'active', args: ARGS.say,
256
256
  detail: 'Like /broadcast to a single slot with --priority urgent --ack: it shows red there, asks them to /ack, and you see when they have.' },
257
257
  { id: 'ack', kind: 'command', new: true, group: 'Conversation', usage: '/ack <broadcast id>', desc: 'acknowledge a broadcast, as the slot this window holds', needs: ['slotAck'], cap: 'slots', status: 'active',
258
258
  detail: 'The id is in the broadcast line ("ack with /ack bc_...").' },
@@ -335,7 +335,7 @@ export const REGISTRY = [
335
335
  detail: '/org lists the orgs your login belongs to. /org switch <name or id> matches loosely, asks y/N (or /org yes), then re-scopes the login (the same exchange as quox org switch) and starts a new conversation in that org; the status line shows the org. /org back (or /org -) returns to the org you were in before, with the same single y/N. A switch only succeeds for orgs you are a member of; the server decides.' },
336
336
  { id: 'statusline', kind: 'key', group: 'Context', usage: 'status line', desc: 'profile · org · agent · model · mode, under the composer', status: 'active' },
337
337
  { id: 'r', kind: 'command', new: true, group: 'Conversation', usage: '/r <text>', desc: 'reply to whoever last messaged you (slot or room); also /reply', needs: ['slotSay'], cap: 'slots', status: 'active', aliases: ['reply'] },
338
- { id: 'poke', kind: 'command', new: true, group: 'Conversation', usage: '/poke <slot or session> [text]', desc: 'wake a session: an urgent page asking it to acknowledge (also /wake, /nudge, /ping)', needs: ['slotBroadcast'], cap: 'slots', status: 'active', aliases: ['wake', 'nudge', 'ping', 'slap', 'awaken'] },
338
+ { id: 'poke', kind: 'command', new: true, group: 'Conversation', usage: '/poke <slot>[,<slot>...] [text]', desc: 'wake one or more sessions (a,b,c or a b c): an urgent page asking each to acknowledge (also /wake, /nudge, /ping)', needs: ['slotBroadcast'], cap: 'slots', status: 'active', aliases: ['wake', 'nudge', 'ping', 'slap', 'awaken'] },
339
339
  { id: 'afk', kind: 'command', new: true, group: 'Context', usage: '/afk [note]', desc: 'tell sessions you are away (they stop waiting on approvals, which still queue); /back then shows everything since, grouped', status: 'active',
340
340
  detail: 'Sets your presence on the server (per user and org): sessions saying into hitl, triage-q or this window\'s slot see "owner away since 15:54 UTC (note): approvals will queue"; `quox presence`, `quox slot list`, quox ask and agents see it too. Nothing is approved while you are away. /back or any typed message marks you present.' },
341
341
  { id: 'donotdisturb', kind: 'command', new: true, group: 'Context', usage: '/donotdisturb [on|off] [until HH:MM]', desc: 'sleep means sleep: collect arrivals quietly (no notices, cards, bells) until you ask for /morningreport; also /dnd', status: 'active', aliases: ['dnd'],
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Owner 2026-11-08: with dragons on, CommanderQ handing a turn to a specialist shows a small STATIC dragon in the chat
3
+ * stream, above the delegation row, with one line naming the hand-off. No animation and no timers: the art is drawn once
4
+ * with the row. Dragons off (the /dragon companion setting, or lite mode) changes nothing.
5
+ * The decision is stamped on the tool.started event when it arrives (markDelegationDragon) so a row printed once keeps
6
+ * its dragon, and the pure reducer never reads settings.
7
+ */
8
+ import React from 'react';
9
+ import { Box, Text } from 'ink';
10
+ import { companionMode, motionPolicy } from '../dragon/select.js';
11
+ import { c } from './theme.js';
12
+
13
+ const h = React.createElement;
14
+
15
+ /** Below this many columns the art is dropped; the hand-off line stays. */
16
+ export const DRAGON_ART_MIN_COLS = 40;
17
+
18
+ /** Original compact dragon, plain ASCII so every terminal draws it at the width it says. */
19
+ export const DELEGATION_DRAGON_ART = Object.freeze([
20
+ ' __/\\__/\\',
21
+ ' ___/ o \\~~<',
22
+ ' <___ ___/\\ \\',
23
+ ' \\_/ \\_/\\_\\'
24
+ ]);
25
+
26
+ /** Dragons on = the companion mode is on or greeting, and decoration is not off (lite or a dumb terminal). Same rule as /detach's greeting. */
27
+ export function delegationDragonOn(settings = {}, env = process.env) {
28
+ return companionMode(settings, env) !== 'off' && !motionPolicy(env).off;
29
+ }
30
+
31
+ /** A delegate_to_agent tool.started action gains ev.dragon when dragons are on. Anything else comes back as the same object. */
32
+ export function markDelegationDragon(a, on) {
33
+ if (!on || a?.type !== 'event' || a.ev?.type !== 'tool.started' || a.ev.tool !== 'delegate_to_agent') return a;
34
+ return { ...a, ev: { ...a.ev, dragon: true } };
35
+ }
36
+
37
+ /** The lines a marked delegation row draws above itself: art (when it fits), then the hand-off line. [] = none. */
38
+ export function delegationDragonLines(b, cols) {
39
+ if (!b?.dragon || !b.agentName) return [];
40
+ const art = cols >= DRAGON_ART_MIN_COLS ? DELEGATION_DRAGON_ART.map(text => ({ text, art: true })) : [];
41
+ return [...art, { text: `\u{1F409} CommanderQ hands this to ${b.agentName}`, art: false }];
42
+ }
43
+
44
+ export function DelegationDragon({ b, cols }) {
45
+ const lines = delegationDragonLines(b, cols);
46
+ if (!lines.length) return null;
47
+ return h(Box, { flexDirection: 'column', paddingLeft: 2 },
48
+ ...lines.map((l, i) => h(Text, { key: i, wrap: 'truncate-end', color: c(l.art ? 'violet' : 'text'), bold: !l.art }, l.text)));
49
+ }
package/lib/quoxon/dnd.js CHANGED
@@ -69,3 +69,50 @@ export function morningLines(c, { approvals = 0, sessions = [] } = {}) {
69
69
  if (!lines.length) lines.push(['Nothing collected while you were away.', 'ok']);
70
70
  return lines;
71
71
  }
72
+
73
+ const oneLine = (t, n = 80) => { const s = String(t || '').replace(/\s+/g, ' ').trim(); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };
74
+
75
+ /** Slot messages grouped per sender, newest sender first: [{ from, n, text (latest), ts }]. */
76
+ export function slotsBySender(slots) {
77
+ const m = new Map();
78
+ for (const s of slots || []) {
79
+ const from = String(s.from || 'slot'), ts = Number(s.ts) || 0, g = m.get(from);
80
+ if (!g) m.set(from, { from, n: 1, text: s.text, ts });
81
+ else { g.n += 1; if (ts >= g.ts) { g.text = s.text; g.ts = ts; } }
82
+ }
83
+ return [...m.values()].sort((a, b) => b.ts - a.ts);
84
+ }
85
+
86
+ /**
87
+ * The since-last-seen digest when nothing was held (do not disturb was off). Owner 2026-11-09: a morning report must
88
+ * not depend on DND. Order: approvals waiting, slot messages to you (per sender, newest first), sessions that need you,
89
+ * incidents. Each section is capped with a "+N more" pointer. [[text, tone]].
90
+ * approvals/incidents: [{ title }]; slots: [{ from, text, ts }]; sessions: [string].
91
+ */
92
+ export function sinceLines({ approvals = [], slots = [], sessions = [], incidents = [] } = {}, { maxApprovals = 3, maxSenders = 5, maxSessions = 5, maxIncidents = 3 } = {}) {
93
+ const lines = [];
94
+ const more = (n, ptr) => lines.push([` +${n} more${ptr ? ` · ${ptr}` : ''}`, 'dim']);
95
+ if (approvals.length) {
96
+ lines.push([`Approvals waiting (${approvals.length})`, 'warn']);
97
+ for (const a of approvals.slice(0, maxApprovals)) lines.push([` ${oneLine(a.title || a.summary || a.id || 'approval')}`, 'plain']);
98
+ if (approvals.length > maxApprovals) more(approvals.length - maxApprovals, '/approvals');
99
+ }
100
+ if (slots.length) {
101
+ const g = slotsBySender(slots);
102
+ lines.push([`Slot messages to you (${slots.length})`, 'plain']);
103
+ for (const s of g.slice(0, maxSenders)) lines.push([` ${s.from}${s.n > 1 ? ` ×${s.n}` : ''}: ${oneLine(s.text)}`, 'plain']);
104
+ if (g.length > maxSenders) lines.push([` +${g.length - maxSenders} more senders`, 'dim']);
105
+ }
106
+ if (sessions.length) {
107
+ lines.push([`Sessions that need you (${sessions.length})`, 'warn']);
108
+ for (const s of sessions.slice(0, maxSessions)) lines.push([` ${oneLine(s)}`, 'plain']);
109
+ if (sessions.length > maxSessions) more(sessions.length - maxSessions, '/sitrep');
110
+ }
111
+ if (incidents.length) {
112
+ lines.push([`Incidents (${incidents.length})`, 'warn']);
113
+ for (const i of incidents.slice(0, maxIncidents)) lines.push([` ${oneLine(i.title || i.kind)}`, 'plain']);
114
+ if (incidents.length > maxIncidents) more(incidents.length - maxIncidents, '/away');
115
+ }
116
+ if (!lines.length) lines.push(['Nothing is waiting on you since you were last here.', 'ok']);
117
+ return lines;
118
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Dragon companion for Quoxon (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus). The controller owns attach state and feeds REAL stream
2
+ * Dragon companion for Quoxon (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus, Janus, Archivist, Architect, Roady, Weaver). The controller owns attach state and feeds REAL stream
3
3
  * events to dragonlab's pure view (integration/companions-v1/view.mjs: the same reducer and renderer the CLI
4
4
  * uses). No art, state logic or timers are duplicated here; this file only decides, translates and lays out.
5
5
  * Decisions: docs/dragons/COMPANION-UX.md. The reducer never overwrites pending approvals or failures, but the host only SHOWS the dragon during a dwell (attach, key moment, invoke).
@@ -226,20 +226,33 @@ export function createCompanionController({ settings = () => ({}), env = process
226
226
  const mood = job?.mood === 'concerned' && !state.approvals.length ? 'concerned' : null;
227
227
  try { v = view.view(state, t, { cols, rows: big ? rows : 1, mood }); } catch { return null; }
228
228
  if (v.hidden) return null;
229
- // One caption per trigger: "Attached." only on an attach; an invoke says who is here; an approval always says so.
230
- let caption = v.caption;
231
- const summonedCaption = summonCaption;
232
- if (state.approvals.length) caption = 'Waiting for your approval.';
233
- else if (job?.caption && !state.issue) caption = job.caption; // the dragon's job: a real read (jobs.js); an approval or a recorded failure still comes first
234
- else if (state.phase === 'connecting') caption = trigger === 'attach' ? 'Attached.' : trigger === 'invoke' ? `${view.name} is here.` : v.caption;
235
- if (summonedCaption && now() < showUntil) caption = summonedCaption; // a domain moment states its own fact
236
- if (!big) return { kind: 'label', text: `${view.name}: ${caption}` };
229
+ // Owner, 2026-11-10: no caption by default. A caption only names a real event: an approval waiting, a failed step, a stream that
230
+ // ended with the outcome unknown, a job read that found something wrong, or a domain moment's own fact. Idle, arrival, generic
231
+ // work, power flourishes, mottos and calm job reads say nothing (the reducer's own captions are never shown).
232
+ const summonedCaption = summonCaption && now() < showUntil ? summonCaption : null; // a domain moment states its own fact
233
+ const caption = summonedCaption || eventCaption(state, job);
234
+ if (!big) return caption ? { kind: 'label', text: `${view.name}: ${caption}` } : null;
237
235
  if (!v.frame) { waitingFrame = true; return null; } // the first frame is still being drawn off the main thread: draw nothing rather than a gap
238
236
  return { kind: 'art', frame: v.frame, caption, name: view.name };
239
237
  },
240
238
  };
241
239
  }
242
240
 
241
+ /**
242
+ * The only captions the companion shows: each names a real host event. -> string | null. Pure.
243
+ * Allowlist, in priority order: an approval waiting, a failed step (an error, a failed tool or turn, a denied approval), a stream that
244
+ * ended without a verified outcome, a job read that found something wrong. Anything else (idle, arrival, working, thinking, power
245
+ * flourishes, mottos, a calm job read) is null.
246
+ */
247
+ export function eventCaption(state, job = null) {
248
+ if (!state) return null;
249
+ if (state.approvals?.length) return 'Waiting for your approval.';
250
+ if (state.issue || state.phase === 'error') return 'That step failed.';
251
+ if (state.phase === 'disconnected') return 'Stream ended, outcome unknown.';
252
+ if (job?.mood === 'concerned' && job.caption) return job.caption;
253
+ return null;
254
+ }
255
+
243
256
  /** "/dragon companion <arg>" -> { text, tone, save?, refresh? }. Pure. */
244
257
  export function companionCommand(arg, { settings = {}, plan = null, artProblem = null } = {}) {
245
258
  // `invoke: true` = the host also plays the attached agent's dragon for a dwell (a status check is a call for the dragon too).
@@ -249,7 +262,7 @@ export function companionCommand(arg, { settings = {}, plan = null, artProblem =
249
262
  const mode = cur || plan?.mode || 'on';
250
263
  const now = plan?.enabled ? `showing ${plan.id}` : `not showing (${plan?.why || 'no agent attached'})`;
251
264
  if (artProblem && mode !== 'off') return { text: `Dragon companion: ${mode}, but the art can't be loaded so no dragon will show (${artProblem}). /dragon companion on|greeting|off.`, tone: 'warn' };
252
- return { text: `Dragon companion: ${mode}, ${now}. /dragon companion on|greeting|off. Only Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan and Quoxperticus have dragons.`, invoke: true };
265
+ return { text: `Dragon companion: ${mode}, ${now}. /dragon companion on|greeting|off. Only Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus, Janus, Archivist, Architect, Roady and Weaver have dragons.`, invoke: true };
253
266
  }
254
267
  const m = normaliseMode(w);
255
268
  if (!m) return { text: `Usage: /dragon companion ${COMPANION_MODES.join('|')}|status`, tone: 'warn' };
@@ -270,8 +283,7 @@ export function DragonCompanionLive({ ctl, cols, rows }) {
270
283
  return h(Box, { flexDirection: 'row', marginTop: 1 },
271
284
  h(Box, { flexDirection: 'column', flexShrink: 0 }, ...snap.frame.map((r, k) => h(Text, { key: k, wrap: 'truncate' }, ...cellNodes(r, `dc${k}-`)))),
272
285
  h(Box, { flexDirection: 'column', paddingLeft: 2 },
273
- h(Text, { bold: true }, snap.name.toUpperCase()),
274
- h(Text, { wrap: 'wrap' }, snap.caption)));
286
+ ...(snap.caption ? [h(Text, { key: 'n', bold: true }, snap.name.toUpperCase()), h(Text, { key: 'c', wrap: 'wrap' }, snap.caption)] : [])));
275
287
  }
276
288
 
277
289
  /**
@@ -119,6 +119,13 @@ export function describeInboxItem(it) {
119
119
  * Qualifies when created after `since` (the turn start, else app start), or raised on behalf of this
120
120
  * user, or in this conversation. The inbox list is already scoped to the signed-in user and org.
121
121
  */
122
+ /** The contact-consent proposal on an inbox item (proposeOptin.js), or null. It has no executor: the TUI records the grant. */
123
+ export function consentOf(it) {
124
+ const p = it?.payload || {};
125
+ if (p.kind_detail !== 'contactability_optin' || !p.recipient || !p.channel) return null;
126
+ return { recipient: String(p.recipient), channel: String(p.channel), proposed: p.proposed && typeof p.proposed === 'object' ? p.proposed : {} };
127
+ }
128
+
122
129
  export function cardFromInboxItem(it, { since = 0, userId = null, conversationId = null, now = 0 } = {}) {
123
130
  if (!it || !it.id) return null;
124
131
  if (it.payload?.type === 'decision') return null; // quox ask: answered with /answer N key from /approvals, never a y/n key card
@@ -138,6 +145,7 @@ export function cardFromInboxItem(it, { since = 0, userId = null, conversationId
138
145
  const tool = d.tool || String(it.title || it.id);
139
146
  return {
140
147
  callId: `inbox:${it.id}`, inboxItemId: it.id, approvalId,
148
+ consent: consentOf(it), // a contact-consent proposal: approving records nothing until the person gives the lawful basis
141
149
  tool, target: d.target, what: d.title, askedBy: d.by, askedAt: d.at, scope: null, risk: c.risk || null,
142
150
  url: it.dashboard_url || null, shownAt: now, undescribable: !d.tool && !d.target && !it._plan,
143
151
  plan: it._plan ? { lines: planLines(it._plan.steps), loaded: it._plan.loaded !== false, orgId: it._plan.orgId || null, orgName: it._plan.orgName || null } : null, // the plan's steps + the item's own org
@@ -260,7 +268,7 @@ export function delegateInfo(ev) {
260
268
  if (ev.tool !== 'delegate_to_agent') return {};
261
269
  const id = ev.agent_id || (String(ev.summary || '').match(/\bagent_id=(\S+)/) || [])[1] || null;
262
270
  if (!id) return {};
263
- return { agentId: id, agentName: ev.agent_name || id, ...(ev.task_gist ? { taskGist: ev.task_gist } : {}) };
271
+ return { agentId: id, agentName: ev.agent_name || id, ...(ev.task_gist ? { taskGist: ev.task_gist } : {}), ...(ev.dragon === true ? { dragon: true } : {}) };
264
272
  }
265
273
 
266
274
  function upsertTool(s, ev, patch) {
@@ -473,6 +481,8 @@ function reduceInner(s, a) {
473
481
  return { ...s, approvals: s.approvals.filter(x => x.callId !== a.callId) };
474
482
  case 'approval.sent': { // the human answered from this terminal
475
483
  const rest = { ...s, inbox: (s.inbox || []).filter(x => x.id !== a.inboxItemId), seenInbox: a.inboxItemId ? { ...s.seenInbox, [a.inboxItemId]: true } : s.seenInbox, approvals: s.approvals.filter(x => x.callId !== a.callId && !(a.inboxItemId && x.inboxItemId === a.inboxItemId)), waiting: s.waiting.filter(x => x.callId !== a.callId && x.inboxItemId !== a.callId) };
484
+ if (a.notRecorded) return note(rest, a.notRecorded, 'warn'); // the server answered 2xx but did not record the yes
485
+ if (a.consent && a.action === 'approve') return note(rest, `approved, but no consent is recorded yet: ${a.consent.recipient} via ${a.consent.channel} needs the lawful basis. Type it now (one line), or n to leave it unrecorded.`, 'warn');
476
486
  if (a.batch) return rest; // /approve N prints its own one-line summary
477
487
  if (a.quiet && a.action === 'approve') return rest;
478
488
  if (a.action !== 'approve') return note(rest, approvalSentence(a, false), 'warn');
@@ -496,6 +506,7 @@ function reduceInner(s, a) {
496
506
  if (a.outcome === 'done') return note(n, `result: ${what}: ${a.text || 'completed'}`, 'ok');
497
507
  if (a.outcome === 'failed') return note(n, `${Number.isInteger(a.exit) && a.exit >= 1 ? `ran, exit ${a.exit}` : 'failed'}: ${what}: ${a.text || 'error'}`, 'warn');
498
508
  if (a.outcome === 'denied') return note(n, `denied: ${what}`, 'warn');
509
+ if (a.outcome === 'unrecorded') return note(n, `not run: ${what}. Your yes was saved in your inbox but never reached the tool runner: the server still shows the approval pending, with nobody recorded as deciding. Nothing ran. Ask the agent again for a fresh approval. Check: quox approvals result ${run && run.approvalId ? run.approvalId : '(no id)'}`, 'warn');
499
510
  // FW4: never silent and never a guess. Say what is unknown and where the authoritative answer is.
500
511
  const check = run && run.approvalId ? `quox approvals result ${run.approvalId} (or /approvals)` : '/approvals (no approval id was returned, so it cannot be tracked from here)';
501
512
  return note(n, `result not known yet: ${what}; approved, but the server has not said whether it ran. It may still be running or may have run. Check: ${check}`, 'warn');
@@ -10,6 +10,7 @@ import os from 'node:os';
10
10
  import path from 'node:path';
11
11
  import { execFileSync } from 'node:child_process';
12
12
  import { pathToFileURL, fileURLToPath } from 'node:url';
13
+ import { isWebPty, EXTENSIONS_REFUSED } from './webPty.js';
13
14
 
14
15
  export const THROTTLE_MS = 1000;
15
16
  export const RELOAD_DEBOUNCE_MS = 2000; // fs.watch burst -> one check
@@ -156,6 +157,7 @@ export function findPackageJson(entry) {
156
157
  * -> { ok:true, mod, where } | { ok:false, notFound:true, tried:[...] } | { ok:false, error }
157
158
  */
158
159
  export async function resolveExtension(name, { env = process.env, importer = defaultImporter, execPath = process.execPath, npmRoot = globalNpmRoot, exists = fs.existsSync } = {}) {
160
+ if (isWebPty(env)) return { ok: false, error: EXTENSIONS_REFUSED, tried: [] }; // QUOXON web PTY: no module loads from a browser terminal
159
161
  const tried = [];
160
162
  const ov = env?.[envKey(name)];
161
163
  if (ov) {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * QUOXON (owner 2026-11-09, "this was messy.. what even is that?"): text written by ANOTHER session (slot messages,
3
+ * broadcasts, room lines, loop observe lines) can carry terminal colour codes: a QuoxGlyph announce line printed
4
+ * with 24-bit SGR colour. Real escapes would repaint this window; escapes whose ESC byte was dropped on the way
5
+ * show as text (`1m38;2;56;189;248mG...0m 2m│0m`). Both are stripped here, never recoloured.
6
+ *
7
+ * Remnants are removed only in shapes that are never prose: a 24-bit/256 colour run (`38;2;R;G;Bm`), a run of two
8
+ * or more SGR tokens, or a lone reset/bold/dim token (`0m`, `1m`, `2m`) glued to a letter or glyph before it, or to
9
+ * a glyph or box character after it. "ran 5m ago", "10m", "2m 30s", "(2m)", "2min" stay as they are.
10
+ */
11
+
12
+ const OSC = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g;
13
+ const CSI = /(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g;
14
+ const STR = /\x1b[PX^_][^\x1b]*(?:\x1b\\)?/g;
15
+ const ESC2 = /\x1b[@-Z\\-_]?/g;
16
+
17
+ // A run of SGR-shaped tokens with their ESC (and maybe their `[`) lost.
18
+ const RUN = /\[?\d{1,3}(?:;\d{1,3})*m(?:\[?\d{1,3}(?:;\d{1,3})*m)*/g;
19
+ const TOKEN = /\[?\d{1,3}(?:;\d{1,3})*m/g;
20
+ const COLOUR = /(?:^|;)(?:38|48|58);(?:2;\d{1,3};\d{1,3};\d{1,3}|5;\d{1,3})m$/;
21
+ const LONE = /^\[?[012]m$/;
22
+ const LETTER = /\p{L}/u;
23
+ const ASCII = /[\x00-\x7f]/;
24
+
25
+ function remnant(run, before, after) {
26
+ const toks = run.match(TOKEN) || [];
27
+ if (toks.length >= 2) return true;
28
+ const t = toks[0] || '';
29
+ if (COLOUR.test(t.replace(/^\[/, ''))) return true;
30
+ if (!LONE.test(t)) return false;
31
+ // glued before: a letter ("E0m") or a non-ASCII glyph ("│0m", "✔0m"); never a space, digit or ASCII punctuation
32
+ const gluedBefore = before !== '' && (LETTER.test(before) || !ASCII.test(before)) && !/\s/.test(before);
33
+ // glued after: a non-ASCII glyph or box character ("2m│"); never a letter ("2min"), space or punctuation
34
+ const gluedAfter = after !== '' && !ASCII.test(after) && !LETTER.test(after) && !/\s/.test(after);
35
+ return gluedBefore || gluedAfter;
36
+ }
37
+
38
+ /** Text from another session, with every terminal escape and every orphaned colour-code remnant removed. */
39
+ export function stripForeignAnsi(text) {
40
+ let t = String(text ?? '');
41
+ if (!t) return t;
42
+ t = t.replace(OSC, '').replace(CSI, '').replace(STR, '').replace(ESC2, '');
43
+ if (!/\d+m/.test(t)) return t;
44
+ return t.replace(RUN, (run, off, s) => {
45
+ const before = [...s.slice(Math.max(0, off - 2), off)].pop() || '';
46
+ const after = [...s.slice(off + run.length, off + run.length + 2)][0] || '';
47
+ return remnant(run, before, after) ? '' : run;
48
+ });
49
+ }
@@ -100,7 +100,7 @@ export function inboxTable(items, now = Date.now(), cols = 100, max = 10, mutes
100
100
  out.push({ text: `${un ? '●' : ' '} ${pad(i + 1, 3)}${pad(ageOf(it.created_at ?? it.createdAt, now), 6)}${pad(fromOf(it), 14)}${pad(shown, tw)} ${un ? 'unread' : (it.status || 'read')}${mu ? ' (muted)' : ''}`, tone: un && !mu ? 'accent' : 'dim' });
101
101
  });
102
102
  const un = unreadItems(items).filter((i) => !isMuted(i, mutes, now)).length;
103
- out.push({ text: `${un} unread · /inbox N open one · /inbox read N|all · /inbox unread N|all · /inbox open N (approvals) · /inbox mute <from> [1h|8h|1d] · email is /mail`, tone: 'dim' });
103
+ out.push({ text: `${un} unread · /inbox N or /inbox show N reads one · /inbox read N|all marks · /inbox unread N|all · /inbox open N (approvals) · /inbox mute <from> [1h|8h|1d] · email is /mail`, tone: 'dim' });
104
104
  return out;
105
105
  }
106
106
 
@@ -130,7 +130,7 @@ export function parseInbox(arg) {
130
130
  const dur = m[3] === undefined ? MUTE_DEFAULT_MS : muteDuration(m[3]);
131
131
  return dur ? { kind: 'mute', source: m[2], ms: dur } : { kind: 'usage' };
132
132
  }
133
- m = /^(?:show\s+)?#?(\d+)$/.exec(a); if (m) return { kind: 'show', n: Number(m[1]) };
133
+ m = /^(?:(?:show|view)\s+)?#?(\d+)$/.exec(a); if (m) return { kind: 'show', n: Number(m[1]) };
134
134
  m = /^(open|read|unread)\s+(.+)$/.exec(a);
135
135
  if (m) {
136
136
  if ((m[1] === 'read' || m[1] === 'unread') && m[2] === 'all') return { kind: m[1], all: true };
@@ -138,16 +138,16 @@ export function parseInbox(arg) {
138
138
  if (m[1] === 'open') return ns.length === 1 ? { kind: 'open', n: ns[0] } : { kind: 'usage' };
139
139
  return one(m[1], ns);
140
140
  }
141
- m = /^(.+?)\s+(open|read|unread|show)$/.exec(a); // word order: /inbox 1 open, /inbox 5-8 read
141
+ m = /^(.+?)\s+(open|read|unread|show|view)$/.exec(a); // word order: /inbox 1 open, /inbox 5-8 read, /inbox 1 show
142
142
  if (m) {
143
143
  const ns = parseNumbers(m[1]); if (!ns) return { kind: 'usage' };
144
144
  if (m[2] === 'open') return ns.length === 1 ? { kind: 'open', n: ns[0] } : { kind: 'usage' };
145
- if (m[2] === 'show') return ns.length === 1 ? { kind: 'show', n: ns[0] } : { kind: 'usage' };
145
+ if (m[2] === 'show' || m[2] === 'view') return ns.length === 1 ? { kind: 'show', n: ns[0] } : { kind: 'usage' };
146
146
  return one(m[2], ns);
147
147
  }
148
148
  return { kind: 'usage' };
149
149
  }
150
- export const INBOX_USAGE = 'Usage: /inbox | /inbox N | /inbox read N|N-M|all | /inbox unread N|N-M|all (undo a read) | /inbox open N (or /inbox 1 open, /inbox 1 read, /inbox 1 unread) | /inbox mute <from> [1h|8h|1d] | /inbox unmute <from> | /inbox mutes (email is /mail)';
150
+ export const INBOX_USAGE = 'Usage: /inbox | /inbox N or /inbox show N or /inbox N show (the whole item) | /inbox read N|N-M|all (marks read, does not show) | /inbox unread N|N-M|all (undo a read) | /inbox open N (an approval goes to /approvals; shows any other item) | /inbox mute <from> [1h|8h|1d] | /inbox unmute <from> | /inbox mutes (email is /mail)';
151
151
 
152
152
  /** The first non-empty string among the item's own body fields and the payload's (body_md is the stored column every task/info item uses). */
153
153
  const bodyOf = (it) => {
@@ -18,6 +18,7 @@ import { spawn } from 'node:child_process';
18
18
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
19
19
  import { tmpdir, constants as osConstants } from 'node:os';
20
20
  import { join } from 'node:path';
21
+ import { isWebPty } from './webPty.js';
21
22
 
22
23
  /** The client asks its launcher for a restart with this exit code AND a handoff file (both, never one alone). */
23
24
  export const RESTART_EXIT_CODE = 75;
@@ -46,7 +47,7 @@ export function resetLauncherHandoff() { handoff = undefined; }
46
47
 
47
48
  /** Should this invocation become the launcher? Only an interactive client on a real terminal, never a launched child. */
48
49
  export function shouldLaunch({ argv = [], env = process.env, isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY) } = {}) {
49
- if (!isTTY || env[LAUNCHER_HANDOFF_ENV] || env[NO_LAUNCHER_ENV] === '1') return false;
50
+ if (!isTTY || env[LAUNCHER_HANDOFF_ENV] || env[NO_LAUNCHER_ENV] === '1' || isWebPty(env)) return false;
50
51
  const head = argv.indexOf('--') === -1 ? argv : argv.slice(0, argv.indexOf('--'));
51
52
  return !head.some(a => a === '--print' || a.startsWith('--print=') || a === '-h' || a === '--help');
52
53
  }
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { graphemes } from './textBuffer.js';
18
18
  import { cellWidth } from './cells.js';
19
+ import { stripForeignAnsi } from './foreignText.js';
19
20
 
20
21
  /** Below this width the card is one line (the full title is printed once in chat when the loop starts). */
21
22
  export const NARROW_COLS = 60;
@@ -39,7 +40,7 @@ const SPIN = ['◐', '◓', '◑', '◒'];
39
40
 
40
41
  /* ---- text helpers --------------------------------------------------------------------------- */
41
42
 
42
- const oneLine = (s) => String(s ?? '').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '').replace(/\r/g, '');
43
+ const oneLine = (s) => stripForeignAnsi(s).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '').replace(/\r/g, '');
43
44
  const width = (s) => cellWidth(s);
44
45
 
45
46
  /** Wrap at spaces; a token longer than the line is broken by cells, never dropped. Keeps blank lines. */
@@ -22,6 +22,7 @@ import { parseArgs, USAGE, noServerConfigured, mainPrint, pickProfile, chatCrede
22
22
 
23
23
  export { captureEarlyInput } from './earlyInput.js';
24
24
  import { isNeverAnswered, healthyStamp } from './neverAnswered.js';
25
+ import { isWebPty, SHELL_REFUSED } from './webPty.js';
25
26
 
26
27
  /**
27
28
  * Clear the screen before the chat client? Never right after Getting started: its finale (the one-time
@@ -40,12 +41,12 @@ export async function main(argv = process.argv.slice(2)) {
40
41
  if (opts.errors.length) { early?.release(); process.stderr.write(`quox: ${opts.errors.join('; ')}\n${USAGE}\n`); process.exitCode = 1; return; }
41
42
  const cwd = process.cwd().replace(process.env.HOME || '\0', '~');
42
43
  const { resolveCliVersion } = await import('../core/version.js');
43
- let version = '0.5.0';
44
+ let version = '0.6.1';
44
45
  try { version = resolveCliVersion?.() || version; } catch { /* keep default */ }
45
46
  if (opts.print) return mainPrint(opts);
46
47
  let replay = null;
47
48
  let fromGettingStarted = false;
48
- if (opts.demoOnboarding || (!opts.demo && await noServerConfigured(opts, await import('../core/config.js')))) {
49
+ if (!isWebPty(process.env) && (opts.demoOnboarding || (!opts.demo && await noServerConfigured(opts, await import('../core/config.js'))))) { // QUOXON web PTY: never the installer journey
49
50
  // Getting started (FIRST_RUN_JOURNEY.md C2a): a separate view, never the chat client talking.
50
51
  if (process.stdout.isTTY && !opts.noClear) process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
51
52
  const welcome = shouldShowWelcome({ print: false });
@@ -165,6 +166,11 @@ export async function main(argv = process.argv.slice(2)) {
165
166
  catch (e) { screenRec?.stop(); transcript?.end(`crash: ${e?.message || e}`); throw e; }
166
167
  finally { screenRec?.stop(); process.stdout.off('resize', onResize); }
167
168
  if (!restart) return;
169
+ if (isWebPty(process.env)) { // QUOXON web PTY: no connect flow, update or re-exec on the server; the page reconnects instead
170
+ restoreTerminal();
171
+ process.stdout.write(`\n${SHELL_REFUSED}. Use Reconnect on the page to start a fresh session.\n`);
172
+ return;
173
+ }
168
174
  if (restart.connect) { // ONBOARD-P2: "Connect to a different server?" said y; the connect flow owns the terminal (certificate card, sign-in)
169
175
  restoreTerminal();
170
176
  const { connectServer } = await import('./onboarding/connectServer.js');
@@ -12,6 +12,7 @@
12
12
  import { spawnSync } from 'node:child_process';
13
13
  import { askText } from '../ask.js';
14
14
  import * as T from './copy.js';
15
+ import { isWebPty, SHELL_REFUSED } from '../webPty.js';
15
16
 
16
17
  export const DOCKER_INSTALL_CMD = 'curl -fsSL https://get.docker.com | sudo sh && sudo usermod -aG docker "$USER"';
17
18
  export const DOCKER_GROUP_CMD = 'sudo usermod -aG docker "$USER"';
@@ -31,7 +32,8 @@ export function dockerOffer(rows) {
31
32
  const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
32
33
 
33
34
  /** Runs the offer with the terminal attached. -> { action: 'exit', status } */
34
- export function runDockerOffer(kind, { spawn = spawnSync, out = (s) => process.stdout.write(s), node = process.execPath, bin } = {}) {
35
+ export function runDockerOffer(kind, { spawn = spawnSync, out = (s) => process.stdout.write(s), node = process.execPath, bin, env = process.env } = {}) {
36
+ if (isWebPty(env)) { out(`\n${SHELL_REFUSED}\n`); return { action: 'exit', status: 126 }; } // QUOXON web PTY
35
37
  const command = COMMANDS[kind];
36
38
  if (command) {
37
39
  out(`\n${T.DOCKER_OFFER_RUNNING}\n ${command}\n\n`);
@@ -102,7 +102,7 @@ const Hint = ({ text, cols }) => h(Box, { paddingLeft: 2 }, h(Text, { color: c('
102
102
 
103
103
  // ---- the view ---------------------------------------------------------------------------------
104
104
  export function GettingStarted({
105
- columns, version = '0.5.0', cwd = '', welcome = false, animateIntro, earlyInput = null,
105
+ columns, version = '0.6.1', cwd = '', welcome = false, animateIntro, earlyInput = null,
106
106
  journeyPath, dryRun = false, runner = null, preflightDeps, runChecks = runPreflight, findLocal = probeLocalQuox, findOllama = detectOllama,
107
107
  requestCredential, broker = null, finisher = null, onExit = () => {}, now = () => Date.now()
108
108
  }) {
@@ -3,6 +3,7 @@
3
3
  * horizontal rules only (no side borders), semantic colour, mono-safe.
4
4
  */
5
5
  import { toolCardLines } from './toolCards.js';
6
+ import { DelegationDragon, delegationDragonLines } from './delegationDragon.js';
6
7
  import React from 'react';
7
8
  import chalk from 'chalk';
8
9
  import { Box, Text, useStdout } from 'ink';
@@ -556,7 +557,9 @@ export function HistoryItem({ item, cols, live = false, fx = null, pulse = null,
556
557
  : h(Text, { key: i, color: c('dim') }, ...runs(paintRuns(l, 'muted'))))));
557
558
  }
558
559
  case 'text': return h(Box, { marginTop: gap ?? 1, paddingLeft: 2, flexDirection: 'column' }, h(Markdown, { text: item.text, cols: cols - 2, live }));
559
- case 'tool': return h(ToolRow, { b: item, cols, fx, pulse, ignite });
560
+ case 'tool': return item.dragon // dragons on: a static dragon marks CommanderQ's hand-off (delegationDragon.js)
561
+ ? h(Box, { flexDirection: 'column' }, h(DelegationDragon, { b: item, cols }), h(ToolRow, { b: item, cols, fx, pulse, ignite }))
562
+ : h(ToolRow, { b: item, cols, fx, pulse, ignite });
560
563
  case 'trace': return h(TraceRow, { b: item, cols, fx, pulse });
561
564
  case 'aocl': return h(AoclRow, { item, cols });
562
565
  case 'error': return h(ErrorView, { item, cols, fx });
@@ -811,7 +814,7 @@ export function liveRows(blocks, cols) {
811
814
  case 'summary': return n + noteLines(b.text, cols - 4).length;
812
815
  case 'error': return n + errorRows(b.text, cols);
813
816
  case 'slotmsg': return n + (b.line?.run && !b.line.run.settled ? 9 + Math.ceil(String(b.line.run.cmd || '').length / Math.max(10, cols - 8)) : 1); // ASK-SETTLE: an armed run card is held live
814
- case 'tool': return n + 1 + (b.status === 'running' ? 0 : toolCardLines(b, cols).length);
817
+ case 'tool': return n + 1 + delegationDragonLines(b, cols).length + (b.status === 'running' ? 0 : toolCardLines(b, cols).length);
815
818
  case 'loopcard': return n + (loopPinned(b.card) ? 0 : 1 + loopCardLines(b.card, cols).length);
816
819
  default: return n + 1;
817
820
  }