quoxcli 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -1
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inboxq.js +39 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/lucid.js +17 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/lucid.js +6 -0
- package/lib/commands/lucidGoals.js +389 -0
- package/lib/commands/mail.js +266 -5
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/mon.js +14 -0
- package/lib/commands/monitoring.js +68 -0
- package/lib/commands/notesSeal.js +380 -22
- package/lib/commands/offload.js +51 -10
- package/lib/commands/plan.js +471 -53
- package/lib/commands/volt.js +6 -1
- package/lib/commands/wazuh.js +65 -9
- package/lib/commands/wazuhIntake.js +46 -5
- package/lib/core/commandRegistrars.js +1 -0
- package/lib/core/config.js +14 -1
- package/lib/core/offload.js +83 -0
- package/lib/core/output.js +29 -4
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/version.js +1 -1
- package/lib/quoxon/api.js +4 -0
- package/lib/quoxon/app.js +257 -48
- package/lib/quoxon/approvalsView.js +3 -1
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/bang.js +7 -1
- package/lib/quoxon/commandShort.js +1 -1
- package/lib/quoxon/commands.js +4 -4
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/dnd.js +47 -0
- package/lib/quoxon/events.js +12 -1
- package/lib/quoxon/extensions.js +2 -0
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/inboxView.js +5 -5
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +2 -1
- package/lib/quoxon/main.js +8 -2
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/render.js +5 -2
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/secretShape.js +2 -0
- package/lib/quoxon/slash/broadcast.js +30 -6
- package/lib/quoxon/slash/broadcastRun.js +12 -6
- package/lib/quoxon/slash/cliLeaves.json +35 -0
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +41 -0
- package/lib/quoxon/slash/paletteController.js +6 -2
- package/lib/quoxon/slash/restart.js +2 -0
- package/lib/quoxon/slotChat.js +7 -3
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/whatsnew.jsonl +6 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/toolOutputShape.js +6 -3
- package/package.json +1 -1
|
@@ -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
|
-
?
|
|
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 } = {}) {
|
package/lib/quoxon/bang.js
CHANGED
|
@@ -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
|
|
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',
|
package/lib/quoxon/commands.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
+
}
|
package/lib/quoxon/events.js
CHANGED
|
@@ -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');
|
package/lib/quoxon/extensions.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/quoxon/inboxView.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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) => {
|
package/lib/quoxon/launcher.js
CHANGED
|
@@ -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
|
}
|
package/lib/quoxon/loopCard.js
CHANGED
|
@@ -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) =>
|
|
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. */
|
package/lib/quoxon/main.js
CHANGED
|
@@ -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.
|
|
44
|
+
let version = '0.6.0';
|
|
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.
|
|
105
|
+
columns, version = '0.6.0', 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
|
}) {
|
package/lib/quoxon/render.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/lib/quoxon/resend.js
CHANGED
|
@@ -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;
|
package/lib/quoxon/rows.js
CHANGED
|
@@ -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;
|