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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* fleet and org reach, bulletins and drafting are not built.
|
|
5
5
|
*/
|
|
6
6
|
export const BROADCAST_USAGE = 'Usage: /broadcast [--slots a,b] [--priority urgent] [--ack] [--active [--dry-run]] <text>';
|
|
7
|
-
export const PAGE_USAGE = 'Usage: /page <slot> <text>';
|
|
7
|
+
export const PAGE_USAGE = 'Usage: /page <slot>[,<slot>...] <text> (several slots: a,b,c or a b c)';
|
|
8
8
|
const NOT_YET = new Set(['--fleet', '--org', '--voice', '--at']);
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -44,11 +44,35 @@ export function activeLine({ reached = 0, skipped = 0, dryRun = false } = {}) {
|
|
|
44
44
|
return `\u{1F4CD} QUOXSLOTS \u2502 broadcast to active \u2502 ${reached} ${dryRun ? 'would reach' : 'reached'} \u00B7 ${skipped} skipped dormant \u2714`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
/**
|
|
48
|
+
* TUI-WAKE-MULTI (owner 2026-11-08): the leading slot names of a /page or /wake line, then the message.
|
|
49
|
+
* The first name is always a target. A name joined by a tight comma (seer,quoxon) is always a target, known or not,
|
|
50
|
+
* so an unknown one is reported per slot by the server. A name after a space, or after ", ", is a target only when
|
|
51
|
+
* it is in `known` (the org's slot names); the first word that is not starts the message, kept exactly as typed.
|
|
52
|
+
* With no `known` list only the tight comma list is targets. -> { slots: [lowercase, deduped], text } | null.
|
|
53
|
+
*/
|
|
54
|
+
export function splitTargets(arg, known = null) {
|
|
55
|
+
const set = known ? new Set([...known].map((x) => String(x).toLowerCase())) : null;
|
|
56
|
+
const slots = []; let rest = String(arg || '').trim(); let tight = true;
|
|
57
|
+
for (;;) {
|
|
58
|
+
const m = /^\s*([^\s,]+)/.exec(rest);
|
|
59
|
+
if (!m) break;
|
|
60
|
+
const name = m[1].toLowerCase();
|
|
61
|
+
if (!slots.length && name.startsWith('--')) return null;
|
|
62
|
+
if (!tight && !(set && set.has(name))) break;
|
|
63
|
+
slots.push(name); rest = rest.slice(m[0].length);
|
|
64
|
+
const c = /^\s*,(\s*)/.exec(rest);
|
|
65
|
+
tight = Boolean(c && !c[1]);
|
|
66
|
+
if (c) rest = rest.slice(c[0].length);
|
|
67
|
+
}
|
|
68
|
+
return slots.length ? { slots: [...new Set(slots)], text: rest.trim() } : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `/page <slot>[,<slot>...| <known slot>...] <text>` -> urgent, ack-required, every named recipient. */
|
|
72
|
+
export function parsePageArgs(arg, known = null) {
|
|
73
|
+
const t = splitTargets(arg, known);
|
|
74
|
+
if (!t || !t.text) return { ok: false, message: PAGE_USAGE };
|
|
75
|
+
return { ok: true, text: t.text, slots: t.slots, priority: 'urgent', ack: true };
|
|
52
76
|
}
|
|
53
77
|
|
|
54
78
|
const plural = (n) => (n === 1 ? 'session' : 'sessions');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* One server call fans out (POST /api/v1/slots/broadcast); with --ack the sender then sees the delivery line
|
|
4
4
|
* update as recipients acknowledge. The poll is display only: the broadcast and its acks live on the server.
|
|
5
5
|
*/
|
|
6
|
-
import { parseBroadcastArgs, parsePageArgs, parseAckArgs, parseStatusArgs, broadcastSummary, broadcastCard, BROADCAST_USAGE, activeLine } from './broadcast.js';
|
|
6
|
+
import { parseBroadcastArgs, parsePageArgs, splitTargets, parseAckArgs, parseStatusArgs, broadcastSummary, broadcastCard, BROADCAST_USAGE, activeLine } from './broadcast.js';
|
|
7
7
|
|
|
8
8
|
const roomC = (r) => ({ sent: 0, sending: 0, failed: 0, ...(r?.counts?.room || {}) });
|
|
9
9
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -20,7 +20,7 @@ export async function runBroadcast({ api, kind, arg, from, say, card = null, isC
|
|
|
20
20
|
const st = api.slotBroadcastStatus ? await api.slotBroadcastStatus(st0.id) : { ok: false, message: 'not available in this mode' };
|
|
21
21
|
return st.ok ? show(st.data) : say(`Broadcast status failed: ${st.message}`, 'warn');
|
|
22
22
|
}
|
|
23
|
-
const parsed = kind === 'page' ? parsePageArgs(arg) : parseBroadcastArgs(arg);
|
|
23
|
+
const parsed = kind === 'page' ? parsePageArgs(arg, await slotNames(api)) : parseBroadcastArgs(arg);
|
|
24
24
|
if (!parsed.ok) return say(parsed.message, parsed.message === BROADCAST_USAGE ? 'plain' : 'warn');
|
|
25
25
|
const r = await api.slotBroadcast({ text: parsed.text, slots: parsed.slots, priority: parsed.priority, ack: parsed.ack, active: parsed.active, ...(parsed.dryRun ? { dryRun: true } : {}), from });
|
|
26
26
|
if (!r.ok) return say(`${kind === 'page' ? 'Page' : 'Broadcast'} failed: ${r.message}`, 'warn');
|
|
@@ -72,13 +72,19 @@ export async function runBroadcast({ api, kind, arg, from, say, card = null, isC
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* FORCE-WAKE: `/poke|/wake|/slap|/nudge <slot> [text]
|
|
75
|
+
* FORCE-WAKE: `/poke|/wake|/slap|/nudge <slot>[,<slot>...] [text]` (several slots: see splitTargets). `--force` is accepted anywhere as a no-op (every human
|
|
76
76
|
* wake already sets an unsent draft aside and puts it back) and is never page text. null = no slot given.
|
|
77
77
|
*/
|
|
78
|
-
export function parsePokeArgs(restArg) {
|
|
78
|
+
export function parsePokeArgs(restArg, known = null) {
|
|
79
79
|
const s = String(restArg || '').replace(/(^|\s)--force(?=\s|$)/g, '$1').trim();
|
|
80
|
-
const
|
|
81
|
-
return
|
|
80
|
+
const t = splitTargets(s, known); // TUI-WAKE-MULTI: `slot` is the comma-joined target list, /page splits it again
|
|
81
|
+
return t ? { slot: t.slots.join(','), text: t.text || null } : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The org's slot names for splitTargets, or null when they cannot be read (then only a comma list is targets). */
|
|
85
|
+
export async function slotNames(api) {
|
|
86
|
+
if (!api?.slots) return null;
|
|
87
|
+
try { const rows = await api.slots(); return Array.isArray(rows) ? rows.map((r) => r?.name).filter(Boolean) : null; } catch { return null; }
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/**
|
|
@@ -458,12 +458,25 @@
|
|
|
458
458
|
"lucid area":"read",
|
|
459
459
|
"lucid ask":"read",
|
|
460
460
|
"lucid blind":"write",
|
|
461
|
+
"lucid brief":"read",
|
|
461
462
|
"lucid deck":"never",
|
|
462
463
|
"lucid do":"write",
|
|
463
464
|
"lucid feeds":"read",
|
|
464
465
|
"lucid forget":"write",
|
|
466
|
+
"lucid goals add":"write",
|
|
467
|
+
"lucid goals archive":"write",
|
|
468
|
+
"lucid goals change":"write",
|
|
469
|
+
"lucid goals confirm":"write",
|
|
470
|
+
"lucid goals erase":"never",
|
|
471
|
+
"lucid goals export":"write",
|
|
472
|
+
"lucid goals history":"read",
|
|
473
|
+
"lucid goals link":"write",
|
|
474
|
+
"lucid goals restore":"never",
|
|
475
|
+
"lucid goals show":"read",
|
|
465
476
|
"lucid herald set":"write",
|
|
466
477
|
"lucid herald test":"write",
|
|
478
|
+
"lucid insights show":"read",
|
|
479
|
+
"lucid insights status":"read",
|
|
467
480
|
"lucid journal":"read",
|
|
468
481
|
"lucid lessons":"read",
|
|
469
482
|
"lucid replay":"read",
|
|
@@ -476,9 +489,12 @@
|
|
|
476
489
|
"lucid why":"read",
|
|
477
490
|
"mail account pin":"write",
|
|
478
491
|
"mail accounts":"read",
|
|
492
|
+
"mail block":"write",
|
|
493
|
+
"mail blocked":"read",
|
|
479
494
|
"mail connect":"never",
|
|
480
495
|
"mail disconnect":"write",
|
|
481
496
|
"mail import":"never",
|
|
497
|
+
"mail junk":"write",
|
|
482
498
|
"mail list":"read",
|
|
483
499
|
"mail moves":"read",
|
|
484
500
|
"mail proposals":"read",
|
|
@@ -487,12 +503,18 @@
|
|
|
487
503
|
"mail resolve":"write",
|
|
488
504
|
"mail sync":"write",
|
|
489
505
|
"mail sync-status":"write",
|
|
506
|
+
"mail trust":"write",
|
|
507
|
+
"mail unblock":"write",
|
|
490
508
|
"mail undo":"write",
|
|
491
509
|
"mail watch active":"write",
|
|
492
510
|
"mail watch add":"write",
|
|
493
511
|
"mail watch list":"read",
|
|
494
512
|
"mail watch rm":"write",
|
|
495
513
|
"mail zones":"read",
|
|
514
|
+
"mcp bundle":"never",
|
|
515
|
+
"mcp token create":"never",
|
|
516
|
+
"mcp token list":"read",
|
|
517
|
+
"mcp token revoke":"write",
|
|
496
518
|
"memory core":"read",
|
|
497
519
|
"memory create":"write",
|
|
498
520
|
"memory delete":"write",
|
|
@@ -546,8 +568,10 @@
|
|
|
546
568
|
"mon wazuh triage":"read",
|
|
547
569
|
"mon wazuh vulns":"read",
|
|
548
570
|
"monitoring health":"read",
|
|
571
|
+
"monitoring incidents claim":"write",
|
|
549
572
|
"monitoring incidents get":"read",
|
|
550
573
|
"monitoring incidents list":"read",
|
|
574
|
+
"monitoring incidents status":"write",
|
|
551
575
|
"motion":"write",
|
|
552
576
|
"notes actions":"write",
|
|
553
577
|
"notes append":"write",
|
|
@@ -578,11 +602,13 @@
|
|
|
578
602
|
"notes schedule resume":"write",
|
|
579
603
|
"notes schedule rm":"write",
|
|
580
604
|
"notes scorecard":"write",
|
|
605
|
+
"notes seal convert":"never",
|
|
581
606
|
"notes seal edit":"never",
|
|
582
607
|
"notes seal init":"write",
|
|
583
608
|
"notes seal new":"write",
|
|
584
609
|
"notes seal open":"never",
|
|
585
610
|
"notes seal print":"write",
|
|
611
|
+
"notes seal purge":"never",
|
|
586
612
|
"notes seal status":"read",
|
|
587
613
|
"notes search":"read",
|
|
588
614
|
"notes settings list":"read",
|
|
@@ -606,6 +632,7 @@
|
|
|
606
632
|
"objective list":"read",
|
|
607
633
|
"objective retry":"write",
|
|
608
634
|
"objective start":"write",
|
|
635
|
+
"offload leases":"read",
|
|
609
636
|
"offload release":"write",
|
|
610
637
|
"offload status":"read",
|
|
611
638
|
"onboard":"write",
|
|
@@ -627,15 +654,22 @@
|
|
|
627
654
|
"org update":"write",
|
|
628
655
|
"peek":"read",
|
|
629
656
|
"plan add":"write",
|
|
657
|
+
"plan brief":"read",
|
|
630
658
|
"plan completed":"read",
|
|
631
659
|
"plan focus":"read",
|
|
660
|
+
"plan followup add":"write",
|
|
661
|
+
"plan followup done":"write",
|
|
662
|
+
"plan followup status":"write",
|
|
663
|
+
"plan followups":"read",
|
|
632
664
|
"plan list":"read",
|
|
633
665
|
"plan move":"write",
|
|
634
666
|
"plan note":"write",
|
|
635
667
|
"plan phase":"write",
|
|
636
668
|
"plan plan":"write",
|
|
637
669
|
"plan rename":"write",
|
|
670
|
+
"plan roadmap archive":"write",
|
|
638
671
|
"plan roadmap create":"write",
|
|
672
|
+
"plan roadmap unarchive":"write",
|
|
639
673
|
"plan roadmaps":"read",
|
|
640
674
|
"plan search":"read",
|
|
641
675
|
"plan show":"read",
|
|
@@ -643,6 +677,7 @@
|
|
|
643
677
|
"plan status":"write",
|
|
644
678
|
"plan timeline":"read",
|
|
645
679
|
"plan update":"write",
|
|
680
|
+
"plan watch":"never",
|
|
646
681
|
"plugin assign add":"write",
|
|
647
682
|
"plugin assign list":"read",
|
|
648
683
|
"plugin assign remove":"write",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* on the dashboard home instead; nothing breaks.
|
|
11
11
|
*/
|
|
12
12
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
13
|
+
import { isWebPty } from '../webPty.js';
|
|
13
14
|
|
|
14
15
|
/** Page name -> real route in src/config/routeRegistry.js (approvals = the HITL /inbox, inbox = InboxQ mail). */
|
|
15
16
|
export const PAGES = {
|
|
@@ -40,6 +41,7 @@ export function openerFor(platform = process.platform) {
|
|
|
40
41
|
|
|
41
42
|
/** -> { opened: true } | { opened: false, reason: 'ssh' | 'no-opener' | 'failed' }. Never throws, never uses a shell. */
|
|
42
43
|
export function openInBrowser(url, { env = process.env, platform = process.platform, spawn = nodeSpawn } = {}) {
|
|
44
|
+
if (isWebPty(env)) return Promise.resolve({ opened: false, reason: 'no-opener' }); // QUOXON web PTY: the link is printed, nothing starts on the server
|
|
43
45
|
if (isRemoteSession(env)) return Promise.resolve({ opened: false, reason: 'ssh' });
|
|
44
46
|
const op = openerFor(platform);
|
|
45
47
|
if (!op) return Promise.resolve({ opened: false, reason: 'no-opener' });
|
|
@@ -9,7 +9,7 @@ import { c, clip } from '../theme.js';
|
|
|
9
9
|
|
|
10
10
|
const h = React.createElement;
|
|
11
11
|
|
|
12
|
-
export function ListView({ title = null, rows = [], sel = 0, cols = 80, hint = null, empty = null, more = 0 }) {
|
|
12
|
+
export function ListView({ title = null, rows = [], sel = 0, cols = 80, hint = null, empty = null, more = 0, notes = [] }) {
|
|
13
13
|
// The key column grows with the window (22 cells minimum, 32 at most) and always leaves a one-cell gap, so a name that fits is shown whole;
|
|
14
14
|
// one that does not gets the ellipsis from truncate-end, never a silent cut.
|
|
15
15
|
const keyW = Math.min(Math.max(22, Math.min(32, Math.floor(cols / 3))), Math.max(8, ...rows.map(r => [...String(r.key || '')].length)) + 1);
|
|
@@ -26,5 +26,6 @@ export function ListView({ title = null, rows = [], sel = 0, cols = 80, hint = n
|
|
|
26
26
|
h(Text, { color: c(r.dim ? 'muted' : 'text'), wrap: 'truncate-end' }, clip(String(body || ''), textW)));
|
|
27
27
|
}),
|
|
28
28
|
more > 0 ? h(Text, { color: c('muted') }, ` … ${more} more, keep typing to narrow`) : null,
|
|
29
|
+
...(notes || []).map((n, i) => h(Text, { key: `note:${i}`, color: c('muted'), wrap: 'truncate-end' }, ` ${clip(n, cols - 4)}`)), // never selectable: no marker, not in rows
|
|
29
30
|
hint ? h(Text, { color: c('dim') }, ` ${clip(hint, cols - 4)}`) : null);
|
|
30
31
|
}
|
|
@@ -109,6 +109,23 @@ export function filterPalette(items, query) {
|
|
|
109
109
|
return scored.sort((a, b) => a.best - b.best || a.idx - b.idx).map(x => x.it);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* A match too loose to pre-highlight: not a prefix of the name or an alias, and not a description word that starts
|
|
114
|
+
* with a 2+ letter query. "/y" -> /hyperion (a letter inside the name) is weak; "/one" -> /approvals ("one at a time")
|
|
115
|
+
* is not. A weak top row is shown without the marker until the user moves the selection.
|
|
116
|
+
*/
|
|
117
|
+
const isSubsequence = (q, s) => { let i = 0; for (const ch of s) if (ch === q[i]) i++; return i === q.length; };
|
|
118
|
+
|
|
119
|
+
export function weakMatch(query, it) {
|
|
120
|
+
const q = String(query ?? '').toLowerCase();
|
|
121
|
+
if (!q || !it) return false;
|
|
122
|
+
if ([it.id, ...(it.aliases || []).map(a => a.slice(1))].some(n => String(n).toLowerCase().startsWith(q))) return false;
|
|
123
|
+
// A 3+ letter abbreviation of the name ("/aprvl" -> /approvals) is deliberate, not a stray letter inside it.
|
|
124
|
+
if (q.length >= 3 && isSubsequence(q, String(it.id).toLowerCase())) return false;
|
|
125
|
+
const long = String(it.longDesc || it.desc || '').toLowerCase();
|
|
126
|
+
return !(q.length >= 2 && new RegExp('(^|[^a-z0-9])' + q.replace(/[^a-z0-9]/g, '\\$&')).test(long));
|
|
127
|
+
}
|
|
128
|
+
|
|
112
129
|
/** Bulk or destructive commands: never offered (so never pre-highlighted) by a loose query (owner 2026-10-26: Enter on "/" approved everything). */
|
|
113
130
|
export const GUARDED_IDS = new Set(['yestoall', 'approve', 'deny']);
|
|
114
131
|
|
|
@@ -123,6 +140,30 @@ export function guardBulk(list, query) {
|
|
|
123
140
|
return list.filter(it => !GUARDED_IDS.has(it.id) || names(it).includes(q) || (q.length >= 3 && !shared && names(it).some(n => n.startsWith(q))));
|
|
124
141
|
}
|
|
125
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The guarded commands a loose query hid, so the palette can say why they are missing instead of leaving a weak
|
|
145
|
+
* fuzzy hit on top (owner 2026-11-05: "/y" showed /hyperion). Only names that START with the query count, and only
|
|
146
|
+
* for a non-empty query (a bare "/" stays quiet). `reach` is the shortest input that makes it the unique match.
|
|
147
|
+
* These are notes, never rows: the guard still decides what can be selected.
|
|
148
|
+
*/
|
|
149
|
+
export function guardedHints(items, query) {
|
|
150
|
+
const q = String(query ?? '').toLowerCase();
|
|
151
|
+
if (!q) return [];
|
|
152
|
+
const shown = (p) => guardBulk(filterPalette(items, p), p).map(i => i.id);
|
|
153
|
+
const now = shown(q);
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const it of items) {
|
|
156
|
+
if (!GUARDED_IDS.has(it.id) || it.planned || now.includes(it.id) || !it.id.startsWith(q)) continue;
|
|
157
|
+
let reach = it.name;
|
|
158
|
+
for (let n = Math.max(3, q.length + 1); n < it.id.length; n++) {
|
|
159
|
+
const p = it.id.slice(0, n);
|
|
160
|
+
if (shown(p).includes(it.id)) { reach = '/' + p; break; }
|
|
161
|
+
}
|
|
162
|
+
out.push({ name: it.name, reach });
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
126
167
|
/** What Tab puts in the draft: the name, plus a space when the command takes arguments. */
|
|
127
168
|
export function completion(item) {
|
|
128
169
|
const takesArgs = /^\S+\s+\S/.test(String(item.usage).trim());
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { findEntry } from '../commands.js';
|
|
7
7
|
import { signedOutNotice } from '../sessionDead.js';
|
|
8
|
-
import { paletteQuery, paletteItems, filterPalette, guardBulk, completion, paletteView, PALETTE_MAX } from './palette.js';
|
|
8
|
+
import { paletteQuery, paletteItems, filterPalette, guardBulk, guardedHints, weakMatch, completion, paletteView, PALETTE_MAX } from './palette.js';
|
|
9
9
|
import { argContext, levelFor, staticItems, rankArgs, applyArg } from './args.js';
|
|
10
10
|
import { mentionContext, rankMentions, applyMention } from '../mentions.js';
|
|
11
11
|
|
|
@@ -79,6 +79,8 @@ export function createPalette({
|
|
|
79
79
|
if (q !== st.q) { st.q = q; st.sel = 0; st.picked = false; }
|
|
80
80
|
return q === null ? [] : guardBulk(filterPalette(paletteItems(ctx()), q), q);
|
|
81
81
|
};
|
|
82
|
+
/** The top row is only a weak match (weakMatch) and the user has not moved the selection: it is not highlighted. */
|
|
83
|
+
const weakTop = (list) => !st.picked && list.length > 0 && weakMatch(st.q, list[0]);
|
|
82
84
|
const isOpen = () => {
|
|
83
85
|
const d = getDraft();
|
|
84
86
|
if (st.hold !== null) { // a command was just submitted: closed until the draft is genuinely edited (owner 2026-10-27: a stale palette ran /sleep)
|
|
@@ -162,9 +164,11 @@ export function createPalette({
|
|
|
162
164
|
}
|
|
163
165
|
const list = rows();
|
|
164
166
|
const v = paletteView(list, st.sel, PALETTE_MAX);
|
|
167
|
+
const q = paletteQuery(getDraft());
|
|
168
|
+
const notes = guardedHints(paletteItems(ctx()), q).map(g => `${g.name} is held back so a stray Enter cannot run it: type ${g.reach} to reach it`);
|
|
165
169
|
return {
|
|
166
170
|
rows: v.rows.map(it => ({ key: it.name, text: (it.aliases.length ? `(also ${it.aliases.join(', ')}) ` : '') + it.desc, dim: Boolean(it.disabled), note: it.disabled || null })),
|
|
167
|
-
sel: v.sel, more: v.more,
|
|
171
|
+
sel: weakTop(list) ? -1 : v.sel, more: v.more, notes,
|
|
168
172
|
empty: 'No command matches. Enter sends it anyway; /help lists everything.',
|
|
169
173
|
hint: '↑↓ select · Tab complete · Enter run · Esc close'
|
|
170
174
|
};
|
|
@@ -11,6 +11,7 @@ import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync
|
|
|
11
11
|
import { delimiter, dirname, join, resolve } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import os from 'node:os';
|
|
14
|
+
import { isWebPty, SHELL_REFUSED } from '../webPty.js';
|
|
14
15
|
|
|
15
16
|
/** The CLI tree this code runs from (…/cli). */
|
|
16
17
|
export const RUNNING_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
@@ -205,6 +206,7 @@ export function restartPlan({ argv, conversationId, profile = null, name = null,
|
|
|
205
206
|
* this process, which stays resident until the successor exits. The launcher path (launcher.js) does not.
|
|
206
207
|
*/
|
|
207
208
|
export function execRestart({ argv, conversationId, profile = null, name = null, slot = null, attach = null, org = null, mode = null, declined = null, env = process.env, spawn = spawnSync, bin = findOnPath('quox', env), restore = restoreTerminal }) {
|
|
209
|
+
if (isWebPty(env)) return { error: SHELL_REFUSED, bin: null }; // QUOXON web PTY: reconnect the page instead of re-exec
|
|
208
210
|
const plan = restartPlan({ argv, conversationId, profile, name, slot, attach, org, mode, declined, env, bin });
|
|
209
211
|
restore(); // the child owns the terminal now
|
|
210
212
|
const r = spawn(plan.cmd, plan.args, { stdio: 'inherit', env: plan.env });
|
package/lib/quoxon/slotChat.js
CHANGED
|
@@ -12,6 +12,8 @@ import { join, dirname, basename, resolve, isAbsolute } from 'node:path';
|
|
|
12
12
|
import { scrub } from './transcript.js';
|
|
13
13
|
import { looksLikeKey } from './connect.js';
|
|
14
14
|
import { addressLabel } from './session.js';
|
|
15
|
+
import { isWebPty, FILES_REFUSED } from './webPty.js';
|
|
16
|
+
import { stripForeignAnsi } from './foreignText.js';
|
|
15
17
|
|
|
16
18
|
// ---- outbound ------------------------------------------------------------------------------------
|
|
17
19
|
const AT_RE = /^@([A-Za-z0-9][A-Za-z0-9-]{0,31}):?[ \t]+(\S[\s\S]*)$/;
|
|
@@ -73,7 +75,8 @@ export function redactSecrets(text) {
|
|
|
73
75
|
* Read a local file for /send. Text up to FILE_CAP goes inline; larger or binary files are refused with the cap
|
|
74
76
|
* (slots have no attachment store). -> { ok, name, path, body, redacted, hits, size } | { ok:false, message }.
|
|
75
77
|
*/
|
|
76
|
-
export function prepareFile(p, { cwd = process.cwd() } = {}) {
|
|
78
|
+
export function prepareFile(p, { cwd = process.cwd(), env = process.env } = {}) {
|
|
79
|
+
if (isWebPty(env)) return { ok: false, message: FILES_REFUSED }; // QUOXON web PTY: no file on the server is ever read for a browser user
|
|
77
80
|
const raw = String(p || '').trim().replace(/^['"]|['"]$/g, '');
|
|
78
81
|
if (!raw) return { ok: false, message: 'Usage: /send <slot> <file>' };
|
|
79
82
|
const full = isAbsolute(raw) ? raw : resolve(cwd, raw.replace(/^~(?=\/)/, homedir()));
|
|
@@ -89,7 +92,8 @@ export function prepareFile(p, { cwd = process.cwd() } = {}) {
|
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
/** Tab completion for /send's path argument: entries of the typed directory, directories with a trailing slash. */
|
|
92
|
-
export function pathItems(partial, { cwd = process.cwd(), max = 200 } = {}) {
|
|
95
|
+
export function pathItems(partial, { cwd = process.cwd(), max = 200, env = process.env } = {}) {
|
|
96
|
+
if (isWebPty(env)) return []; // QUOXON web PTY: nothing on the server is listed
|
|
93
97
|
const p = String(partial || '');
|
|
94
98
|
const slash = p.lastIndexOf('/');
|
|
95
99
|
const dirPart = slash >= 0 ? p.slice(0, slash + 1) : '';
|
|
@@ -127,7 +131,7 @@ export function withSenderSlot(rows, m) {
|
|
|
127
131
|
|
|
128
132
|
/** A slot message as a chat line: { glyph, who, time, text, file }. */
|
|
129
133
|
export function slotLine(m) {
|
|
130
|
-
m = m && typeof m.text === 'string' ? { ...m, text: decodeEntities(m.text) } : m;
|
|
134
|
+
m = m && typeof m.text === 'string' ? { ...m, text: stripForeignAnsi(decodeEntities(m.text)) } : m; // another session's colour codes never show raw
|
|
131
135
|
const f = parseFileMessage(m?.text);
|
|
132
136
|
const file = f ? { name: f.name, meta: f.meta, body: f.body, lines: f.body.split('\n').length, preview: f.body.split('\n').slice(0, 3) } : null;
|
|
133
137
|
// SLOTS-REPLY: the slot is the address, so it leads; the session/display name follows in brackets. A sender
|
package/lib/quoxon/slotRun.js
CHANGED
|
@@ -21,6 +21,7 @@ import { codeRows } from './slotFormat.js';
|
|
|
21
21
|
import { graphemes } from './textBuffer.js';
|
|
22
22
|
import { TERMINAL_RESTORE } from './slash/restart.js';
|
|
23
23
|
import { PASTE_ON } from './keys.js';
|
|
24
|
+
import { isWebPty, SHELL_REFUSED } from './webPty.js';
|
|
24
25
|
|
|
25
26
|
export const RUN_TTL_MS = 10 * 60 * 1000;
|
|
26
27
|
export const RUN_MAX_LEN = 2000;
|
|
@@ -105,6 +106,7 @@ export function runAskRows(req, width) {
|
|
|
105
106
|
* over it. -> { code, signal, ms } | { error, ms }.
|
|
106
107
|
*/
|
|
107
108
|
export function handOverRun({ cmd, file = null, args = null, env = process.env, cwd = process.cwd(), stdout = null, setRawMode = null, spawn = spawnSync, shell, now = Date.now, resume = '' }) {
|
|
109
|
+
if (isWebPty(env)) return { error: SHELL_REFUSED, ms: 0 }; // QUOXON web PTY: never hand a browser terminal a local program
|
|
108
110
|
const tty = Boolean(stdout?.isTTY);
|
|
109
111
|
const rows = tty ? Math.max(0, Number(stdout.frameRows) || 0) : 0;
|
|
110
112
|
const put = (s) => { try { stdout.write(Buffer.from(s)); } catch { /* closed */ } };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUOXON web PTY: the dashboard /cli page runs this app in a browser terminal whose process lives on the
|
|
3
|
+
* collector host. The collector marks that PTY with QUOX_WEB_PTY=1, and every path that would start a local
|
|
4
|
+
* program (a `!` command, a terminal hand-off, $EDITOR, a command row, a restart re-exec, a URL opener, the
|
|
5
|
+
* docker offer, an extension import) checks isWebPty() and refuses. A browser user gets the conversation,
|
|
6
|
+
* never a shell on the server.
|
|
7
|
+
*/
|
|
8
|
+
export const WEB_PTY_ENV = 'QUOX_WEB_PTY';
|
|
9
|
+
export const SHELL_REFUSED = 'shell commands are not available in the browser terminal';
|
|
10
|
+
export const EXTENSIONS_REFUSED = 'extensions are not available in the browser terminal';
|
|
11
|
+
|
|
12
|
+
export function isWebPty(env = process.env) {
|
|
13
|
+
return env?.[WEB_PTY_ENV] === '1';
|
|
14
|
+
}
|
|
15
|
+
export const FILES_REFUSED = 'local files are not available in the browser terminal';
|
|
@@ -168,3 +168,9 @@
|
|
|
168
168
|
{"id":"quoxon-2026-10-30-approval-row-background","date":"2026-10-30T12:00:00Z","since":"0.4.0","title":"Approval rows flag background work","text":"/approvals rows now say when a command runs in the background, including screen, tmux and docker detached runs."}
|
|
169
169
|
{"id":"quoxon-2026-10-30-confirm-target-once","date":"2026-10-30T22:00:00Z","since":"0.4.0","title":"Approval confirmations name the hosts once","text":"The line after you approve a multi-host command no longer repeats the host list twice."}
|
|
170
170
|
{"id":"quoxon-2026-10-30-away-retry","date":"2026-10-30T22:10:00Z","since":"0.4.0","title":"While you were away retries a slow source","text":"If a source times out right after sign-in, the digest asks it once more before it says it cannot see it."}
|
|
171
|
+
{"id":"quoxon-2026-11-06-approval-popup-dedupe","date":"2026-11-06T17:40:00Z","since":"0.4.0","title":"An approval you answered does not pop up again","text":"Approving or denying on the inline prompt now stops the same approval opening a second later as a new walkthrough card."}
|
|
172
|
+
{"id":"quoxon-2026-11-07-resend-held-turn","date":"2026-11-07T12:40:00Z","since":"0.4.0","title":"A message sent during a restart is not lost","text":"If the server already received your message before the connection dropped, Quox now shows that answer instead of failing, and if it cannot, your message comes back to the composer."}
|
|
173
|
+
{"id":"quoxon-2026-11-07-actions-list-fits","date":"2026-11-07T12:30:00Z","since":"0.4.0","title":"quox actions list fits your screen","text":"The list now fits the terminal or the Quox pane: short ids (done, move, get and delete accept them), long titles end in an ellipsis instead of wrapping, and the Due column only shows when an item has a due date. --json keeps the full ids."}
|
|
174
|
+
{"id":"quoxon-2026-11-07-offload-lease-warn","date":"2026-11-07T13:20:00Z","since":"0.4.0","title":"Offload warns before a worker lease runs out","shell":"quox offload leases","text":"quox offload status and the new quox offload leases list every offload worker with EXPIRED, EXPIRING or OK, and every offload command warns when a lease ends within 12 hours, naming the command a human runs to renew it."}
|
|
175
|
+
{"id":"quoxon-2026-11-07-select-dot-whole-result","date":"2026-11-07T13:50:00Z","since":"0.4.0","title":"quox tool exec --select . returns the whole result","text":"A dot means the whole result, as in jq, so --select . (and count(.)) no longer fails with \"bad path\". Every other --select path works as before."}
|
|
176
|
+
{"id":"quoxon-2026-11-08-walk-revalidate","date":"2026-11-08T04:55:00Z","since":"0.4.0","title":"A stopped walkthrough only lists what is still waiting","text":"When a walkthrough stops, the TUI asks the server again before listing what is still waiting, so questions answered from another window drop off the list. A held list is re-checked the same way before its first card. If the server cannot be reached, the list says it may be out of date."}
|