quoxcli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +58 -0
- package/lib/api/endpoints/index.js +4 -0
- package/lib/api/endpoints/lucid.js +17 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +7 -0
- package/lib/commands/lucidGoals.js +389 -0
- package/lib/commands/mail.js +427 -25
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +69 -144
- package/lib/commands/monitoring.js +68 -0
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +617 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +52 -12
- package/lib/commands/plan.js +487 -52
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/volt.js +6 -1
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +880 -0
- package/lib/commands/wazuhIntake.js +358 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +9 -0
- package/lib/core/config.js +42 -3
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +120 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +30 -5
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +189 -34
- package/lib/quoxon/app.js +1277 -188
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +72 -12
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +8 -2
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +8 -1
- package/lib/quoxon/commands.js +42 -12
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +118 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +55 -8
- package/lib/quoxon/extensions.js +400 -38
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +7 -2
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +27 -6
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +68 -16
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +88 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +48 -11
- package/lib/quoxon/slash/broadcastRun.js +24 -7
- package/lib/quoxon/slash/cliLeaves.json +98 -1
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +63 -1
- package/lib/quoxon/slash/paletteController.js +15 -5
- package/lib/quoxon/slash/restart.js +9 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +24 -3
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +42 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +61 -3
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// APPROVAL-BURST (owner 2026-10-28; 2026-10-27 09:53 VIGIL filed nine wire_alerts approvals in a minute as nine cards).
|
|
2
|
+
// 2+ pending approvals from the same agent for the same tool, arriving within 5 minutes of each other, are ONE card.
|
|
3
|
+
// A destructive, risky, privileged or step-up item is NEVER part of a batch: it keeps its own card and its own confirm.
|
|
4
|
+
// Pure helpers; app.js owns the keys and the calls.
|
|
5
|
+
import { describeInboxItem } from './events.js';
|
|
6
|
+
import { isDestructive } from './destructive.js';
|
|
7
|
+
import { heldReason } from './bulkRisk.js';
|
|
8
|
+
|
|
9
|
+
/** Tools that restart, reboot, run commands or change a running system: one keypress must never do that on several hosts. */
|
|
10
|
+
export const DISRUPTIVE_TOOL = /(^|_|\.)(restart|reboot|shutdown|poweroff|stop|kill|exec|shell|run_command|ssh|deploy|rollback|migrate|upgrade)(_|\.|$)/i;
|
|
11
|
+
export const isDisruptiveTool = (tool) => DISRUPTIVE_TOOL.test(String(tool ?? '').trim());
|
|
12
|
+
const flagged = (o) => Boolean(o && o.disruptive === true);
|
|
13
|
+
/** True when the server marks the item disruptive, else when its tool name matches the list. */
|
|
14
|
+
export function isDisruptive(it) {
|
|
15
|
+
const p = it?.payload || {}; const c = p.approval_context || {};
|
|
16
|
+
return flagged(it) || flagged(p) || flagged(c) || flagged(c.method) || flagged(p.what) || isDisruptiveTool(describeInboxItem(it).tool);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const BATCH_WINDOW_MS = 5 * 60 * 1000;
|
|
20
|
+
export const BATCH_MAX_LINES = 8;
|
|
21
|
+
|
|
22
|
+
const ms = (v) => { const t = Date.parse(String(v ?? '').includes('T') || !v ? v : `${String(v).replace(' ', 'T')}Z`); return Number.isFinite(t) ? t : null; };
|
|
23
|
+
const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, Math.max(0, n - 1))}…` : t; };
|
|
24
|
+
const key = (agent, tool) => `${String(agent).trim().toLowerCase()}\u0000${String(tool).trim().toLowerCase()}`;
|
|
25
|
+
|
|
26
|
+
/** Chain by time: the members of `list` ({ t }) whose gaps to a neighbour inside the chain are <= window, starting from `from`. */
|
|
27
|
+
function chainFrom(list, from, window) {
|
|
28
|
+
const sorted = [...list].sort((a, b) => a.t - b.t);
|
|
29
|
+
const at = sorted.indexOf(from); if (at < 0) return [from];
|
|
30
|
+
let lo = at; let hi = at;
|
|
31
|
+
while (lo > 0 && sorted[lo].t - sorted[lo - 1].t <= window) lo--;
|
|
32
|
+
while (hi < sorted.length - 1 && sorted[hi + 1].t - sorted[hi].t <= window) hi++;
|
|
33
|
+
return sorted.slice(lo, hi + 1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the owner sees on a card of the batch: the item's target, else what it is, else a short id. */
|
|
37
|
+
export const batchLabel = (c) => clip(c.target || c.what || String(c.inboxItemId || c.callId || 'item').slice(0, 8), 120);
|
|
38
|
+
|
|
39
|
+
// ---- cards (the live approval queue) ---------------------------------------------------------
|
|
40
|
+
const cardTime = (c) => ms(c.askedAt) ?? c.shownAt ?? 0;
|
|
41
|
+
const cardAgent = (c) => c.askedBy || null;
|
|
42
|
+
const cardHeld = (c) => Boolean(c.destructive) || Boolean(c.privileged) || Boolean(c.undescribable) || Boolean(c.plan) || Boolean(c.held) || Boolean(c.disruptive) || isDisruptiveTool(c.tool) || /high|critical/i.test(String(c.risk || ''));
|
|
43
|
+
const cardBatchable = (c) => Boolean(c && c.inboxItemId && c.tool && cardAgent(c) && !cardHeld(c));
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The batch the head card belongs to, or null (a lone card). `cards` is the queue in the order shown.
|
|
47
|
+
* -> { head, agent, tool, cards, heldDeletes, heldOther }. heldDeletes / heldOther count the same agent's destructive
|
|
48
|
+
* and other risky cards inside the same window: they are NOT in `cards` and keep their own card.
|
|
49
|
+
*/
|
|
50
|
+
export function batchFor(cards, head) {
|
|
51
|
+
if (!cardBatchable(head)) return null;
|
|
52
|
+
const k = key(cardAgent(head), head.tool);
|
|
53
|
+
const mates = cards.filter(c => cardBatchable(c) && key(cardAgent(c), c.tool) === k).map(c => ({ c, t: cardTime(c) }));
|
|
54
|
+
const me = mates.find(m => m.c === head) || mates.find(m => m.c.callId === head.callId);
|
|
55
|
+
if (!me) return null;
|
|
56
|
+
const chain = chainFrom(mates, me, BATCH_WINDOW_MS);
|
|
57
|
+
if (chain.length < 2) return null;
|
|
58
|
+
const members = chain.map(m => m.c);
|
|
59
|
+
const lo = chain[0].t - BATCH_WINDOW_MS; const hi = chain[chain.length - 1].t + BATCH_WINDOW_MS;
|
|
60
|
+
const held = cards.filter(c => c !== head && !members.includes(c) && cardAgent(c) && String(cardAgent(c)).trim().toLowerCase() === String(cardAgent(head)).trim().toLowerCase() && cardHeld(c) && cardTime(c) >= lo && cardTime(c) <= hi);
|
|
61
|
+
return { head, agent: cardAgent(head), tool: head.tool, cards: members, heldDeletes: held.filter(c => c.destructive).length, heldOther: held.filter(c => !c.destructive).length };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The card's text: title, the item lines (at most 8, then "+K more"), the held-back note and the footer. */
|
|
65
|
+
export function batchModel(b) {
|
|
66
|
+
const n = b.cards.length;
|
|
67
|
+
const lines = b.cards.slice(0, BATCH_MAX_LINES).map(batchLabel);
|
|
68
|
+
const more = n - lines.length;
|
|
69
|
+
const notes = [];
|
|
70
|
+
if (b.heldDeletes) notes.push(`+${b.heldDeletes} delete${b.heldDeletes === 1 ? '' : 's'} held for you one by one`);
|
|
71
|
+
if (b.heldOther) notes.push(`+${b.heldOther} held for you one by one (risky)`);
|
|
72
|
+
return { n, title: `⚠ ${b.agent} wants to run ${b.tool} on ${n} items`, lines, more: more > 0 ? `+${more} more · p to see all` : null, notes, keys: `y approve all ${n} · n deny all ${n} · p pick one by one · Tab later` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- raw inbox items (the arrival bar) -------------------------------------------------------
|
|
76
|
+
const isApproval = (it) => Boolean(it?.payload?.approval_context) && it?.payload?.type !== 'decision';
|
|
77
|
+
const itemInfo = (it) => { const d = describeInboxItem(it); return { agent: d.by, tool: d.tool }; };
|
|
78
|
+
const itemBatchable = (it) => { if (!isApproval(it)) return false; const i = itemInfo(it); return Boolean(i.agent && i.tool) && !isDestructive(it) && !isDisruptive(it) && heldReason(it) === null && !/high|critical/i.test(String(it.payload?.approval_context?.risk || '')); };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Raw approval items grouped into bursts: [{ agent, tool, items }] for each run of 2+ from one agent and tool inside the window.
|
|
82
|
+
* Held, destructive and non-approval items are never in a burst.
|
|
83
|
+
*/
|
|
84
|
+
export function bursts(items) {
|
|
85
|
+
const by = new Map();
|
|
86
|
+
for (const it of items || []) {
|
|
87
|
+
if (!itemBatchable(it)) continue;
|
|
88
|
+
const i = itemInfo(it); const k = key(i.agent, i.tool);
|
|
89
|
+
(by.get(k) || by.set(k, []).get(k)).push({ it, t: ms(it.created_at ?? it.createdAt) ?? 0, agent: i.agent, tool: i.tool });
|
|
90
|
+
}
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const list of by.values()) {
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
for (const m of [...list].sort((a, b) => a.t - b.t)) {
|
|
95
|
+
if (seen.has(m)) continue;
|
|
96
|
+
const chain = chainFrom(list, m, BATCH_WINDOW_MS); chain.forEach(x => seen.add(x));
|
|
97
|
+
if (chain.length >= 2) out.push({ agent: m.agent, tool: m.tool, items: chain.map(x => x.it) });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The one arrival line for a burst. */
|
|
104
|
+
export const burstLine = (b) => `📨 ${clip(b.agent, 30)}: ${b.items.length} ${clip(b.tool, 40)} waiting · /approvals`;
|
|
105
|
+
|
|
106
|
+
/** Arrival lines for fresh items: a burst collapses into one line, everything else keeps its own (`lineOf(item)`). */
|
|
107
|
+
export function collapseArrivals(items, numOf, lineOf = null) {
|
|
108
|
+
const bs = bursts(items); const inBurst = new Map();
|
|
109
|
+
bs.forEach(b => b.items.forEach(it => inBurst.set(it, b)));
|
|
110
|
+
const out = []; const done = new Set();
|
|
111
|
+
for (const it of items || []) {
|
|
112
|
+
const b = inBurst.get(it);
|
|
113
|
+
if (!b) { out.push(lineOf ? lineOf(it, numOf(it)) : `📨 ${clip(it.title || it.kind || 'untitled', 70)} · /inbox${numOf(it) ? ` ${numOf(it)}` : ''}`); continue; }
|
|
114
|
+
if (done.has(b)) continue; done.add(b); out.push(burstLine(b));
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// (host count and names), WHO (the delegation chain and the human it acts for) and WHY (reason, the user's own words,
|
|
4
4
|
// when). Pure, so the card, the /approvals row and /approvals N detail cannot drift apart.
|
|
5
5
|
|
|
6
|
+
import { startsBackgroundWork } from './backgroundWork.js';
|
|
7
|
+
|
|
6
8
|
export const CMD_CARD_MAX = 400;
|
|
7
9
|
/** Server text can arrive HTML-escaped ("2>/dev/null"); decode for display. Never renders HTML. */
|
|
8
10
|
const dec = (t) => (typeof t === 'string' ? t.replace(/&(amp|lt|gt|quot|apos|#39|#x27);/gi, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", '#x27': "'" })[e.toLowerCase()]) : t);
|
|
@@ -79,17 +81,69 @@ export function whyText(it) {
|
|
|
79
81
|
/** The user's own words that started this, when the server kept them. */
|
|
80
82
|
export const excerptOf = (it) => dec(it?.payload?.approval_context?.on_behalf_of?.request_excerpt) || null;
|
|
81
83
|
|
|
84
|
+
/** The user's full ask when the server kept it (request_full), else the 300-char excerpt. */
|
|
85
|
+
const fullAskOf = (it) => dec(it?.payload?.approval_context?.on_behalf_of?.request_full) || excerptOf(it);
|
|
86
|
+
|
|
87
|
+
export const ASKED_BUDGET = 300;
|
|
88
|
+
const VERBS = [
|
|
89
|
+
[/delet|remov|destroy|drop|purge|wipe/, /\b(delet|remov|destroy|drop|purge|wipe|clean\s?up|tear\s?down|get rid)/i],
|
|
90
|
+
[/creat|build|add|new|make|install|register/, /\b(creat|build|add|make|set\s?up|install|register|new)/i],
|
|
91
|
+
[/run|exec|ssh|shell|start|trigger|invoke/, /\b(run|exec|ssh|start|trigger|invoke|launch|restart)/i],
|
|
92
|
+
[/updat|edit|modif|patch|chang|set|config/, /\b(updat|edit|modif|patch|chang|set|config|rename)/i],
|
|
93
|
+
[/restart|reboot|stop|kill/, /\b(restart|reboot|stop|kill|shut)/i],
|
|
94
|
+
[/send|post|publish|email|notify/, /\b(send|post|publish|email|notify)/i]
|
|
95
|
+
];
|
|
96
|
+
const STOP = new Set(['tool', 'the', 'and', 'for', 'get', 'list', 'with', 'true', 'false', 'null']);
|
|
97
|
+
|
|
98
|
+
/** The sentences (and ", then"/"and finally" clauses) of an ask, in order. */
|
|
99
|
+
function clausesOf(ask) {
|
|
100
|
+
return ask.split(/(?<=[.!?])\s+|\n+/).flatMap(sn => sn.length > 160 ? sn.split(/,\s+(?=(?:and\s+)?(?:then|finally|after that|lastly)\b)/i) : [sn]).map(x => x.trim()).filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Long ask: opening sentence (clipped) plus the clause that best matches the tool's verb and object and any id/name in its
|
|
105
|
+
* input, quoted in full. Null when the ask fits the budget. { text, none }: none = nothing in the ask matches the action.
|
|
106
|
+
*/
|
|
107
|
+
export function askedFor(it, budget = ASKED_BUDGET) {
|
|
108
|
+
const ask = fullAskOf(it);
|
|
109
|
+
if (!ask || ask.length <= budget) return null;
|
|
110
|
+
const p = it?.payload || {}; const tool = String(p.toolName || p.approval_context?.method?.tool || '').toLowerCase();
|
|
111
|
+
const input = commandOf(it)?.text || '';
|
|
112
|
+
const parts = tool.split(/[^a-z0-9]+/).filter(w => w.length > 2);
|
|
113
|
+
const verbRe = (VERBS.find(([t]) => parts.some(w => t.test(w))) || [])[1] || null;
|
|
114
|
+
const objs = parts.filter(w => !STOP.has(w) && !VERBS.some(([t]) => t.test(w)));
|
|
115
|
+
const ids = [...new Set((input.match(/[A-Za-z0-9][A-Za-z0-9_.:-]{3,}/g) || []).filter(t => /[\d_-]/.test(t) && !STOP.has(t.toLowerCase())))];
|
|
116
|
+
const cl = clausesOf(ask);
|
|
117
|
+
const clip = (t, n) => (t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t);
|
|
118
|
+
let best = -1; let bi = -1;
|
|
119
|
+
cl.forEach((c, i) => {
|
|
120
|
+
if (!verbRe || !verbRe.test(c)) return; // the action's own verb must be in the clause
|
|
121
|
+
const lc = c.toLowerCase();
|
|
122
|
+
const sc = 2 + objs.filter(o => lc.includes(o)).length + 3 * ids.filter(id => lc.includes(id.toLowerCase())).length;
|
|
123
|
+
if (sc > best) { best = sc; bi = i; }
|
|
124
|
+
});
|
|
125
|
+
const open = `"${clip(cl[0] || ask, 120)}"`;
|
|
126
|
+
if (bi < 0) return { text: open, none: true };
|
|
127
|
+
if (bi === 0) return { text: `"${clip(cl[0], 240)}" (the part that asks for this)`, none: false };
|
|
128
|
+
return { text: `${open} … "${clip(cl[bi], 200)}" (the part that asks for this)`, none: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
82
131
|
/** One bag of the facts, for the card. */
|
|
83
132
|
export function approvalFacts(it, { n = null } = {}) {
|
|
84
133
|
const cmd = commandOf(it);
|
|
134
|
+
const af = askedFor(it);
|
|
85
135
|
const when = it?.payload?.approval_context?.created_at || it?.created_at || it?.createdAt || null;
|
|
86
136
|
return {
|
|
87
|
-
command
|
|
137
|
+
// Only a command the server classed read-only may be clipped; anything else (write, or unclassified) is shown whole, so no part of it is hidden from the approver.
|
|
138
|
+
command: cmd ? { ...cmd, shown: accessOf(it) === 'read-only' ? clipVisible(cmd.text, CMD_CARD_MAX, n) : cmd.text } : null,
|
|
139
|
+
background: Boolean(cmd && cmd.kind === 'command' && startsBackgroundWork(cmd.text)),
|
|
88
140
|
where: whereText(it, { n }),
|
|
89
141
|
hostList: hostsOf(it).list,
|
|
90
142
|
who: whoText(it),
|
|
91
143
|
why: whyText(it),
|
|
92
|
-
asked: excerptOf(it),
|
|
144
|
+
asked: af ? af.text : excerptOf(it),
|
|
145
|
+
askedNone: Boolean(af && af.none),
|
|
146
|
+
askedRich: Boolean(af),
|
|
93
147
|
when: when ? String(when).replace('T', ' ').slice(0, 16) + ' UTC' : null,
|
|
94
148
|
access: accessOf(it)
|
|
95
149
|
};
|
|
@@ -19,6 +19,9 @@ export function shortWho(raw) {
|
|
|
19
19
|
|
|
20
20
|
const clean = (s) => String(s || '').replace(/\s+/g, ' ').replace(/[.\s]+$/, '').trim();
|
|
21
21
|
|
|
22
|
+
/** "4 hosts: a, b, c (+1 more, /approvals 3 detail for all)" -> "4 hosts: a, b, c (+1 more)": the hint is for the card, not the sentence. */
|
|
23
|
+
const tidyWhere = (w) => String(w || '').replace(/\s*\(\+(\d+) more[^)]*\)/, ' (+$1 more)');
|
|
24
|
+
|
|
22
25
|
function whatOf(a) {
|
|
23
26
|
const plain = PLAIN[a.tool];
|
|
24
27
|
if (plain) return plain;
|
|
@@ -32,13 +35,14 @@ function whatOf(a) {
|
|
|
32
35
|
export function approvalSentence(a, approved) {
|
|
33
36
|
const who = shortWho(a.who);
|
|
34
37
|
const [inf, ger] = whatOf(a);
|
|
35
|
-
const where = clean(a.where || a.target);
|
|
38
|
+
const where = tidyWhere(clean(a.where || a.target));
|
|
36
39
|
const said = (t) => where && new RegExp(`\\bon ${where.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'i').test(t); // WHAT already ends "on <where>"
|
|
37
|
-
const
|
|
40
|
+
const multi = /^\d+ hosts?:/i.test(where || '');
|
|
41
|
+
const at = where && !(multi && (/\bon \d+ hosts?:/i.test(inf) || /\bon \d+ hosts?:/i.test(ger))) && !(said(inf) || said(ger) || new RegExp(`\\b${where.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(inf)) ? ` on ${where}` : '';
|
|
38
42
|
if (approved) {
|
|
39
|
-
const tail = a.foreign ? ' It runs now; its result isn\'t tracked here.' : '';
|
|
43
|
+
const tail = a.foreign && !a.approvalId ? ' It runs now; its result isn\'t tracked here.' : '';
|
|
40
44
|
const head = who ? `${who}${inf ? ` to ${inf}` : ''}` : (ger || 'the request');
|
|
41
|
-
return `\u2714 You approved ${head}${at}.${tail}`;
|
|
45
|
+
return `\u2714 You approved ${head}${at}.${tail || (a.foreign ? ' It runs now; the result follows here.' : '')}`;
|
|
42
46
|
}
|
|
43
47
|
return `\u2716 You denied ${who ? `${who} ` : ''}${ger || 'the request'}${at}.`;
|
|
44
48
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// QUOXON-APPROVALS: pure helpers for /approvals rows, filters, detail and /approve|/deny argument parsing.
|
|
2
2
|
import { describeInboxItem, approvalSourceLabel, isPrivileged } from './events.js';
|
|
3
|
+
import { startsBackgroundWork, BACKGROUND_NOTE } from './backgroundWork.js';
|
|
3
4
|
import { commandOf, hostsOf, whereText, whoText, whyText, excerptOf, accessOf, clipVisible } from './approvalFacts.js';
|
|
4
5
|
|
|
5
6
|
// quox ask: a decision request is an inbox item of kind question with payload.type 'decision'.
|
|
7
|
+
/** The ONE rule for "a yes needs the step-up PIN": approval_context.risk high or a high_risk flag. `quox approvals approve` and the walkthrough both use it. */
|
|
8
|
+
export const needsPin = (it) => it?.payload?.approval_context?.risk === 'high' || it?.payload?.high_risk === true;
|
|
6
9
|
export const isDecision = (it) => it?.payload?.type === 'decision' && Array.isArray(it.payload.options);
|
|
7
10
|
/** Who stands behind a recommendation: an explicit recommended_by, else the asking session or agent, else Quox. Never invented when there is no recommendation. */
|
|
8
11
|
export const recommenderOf = (p) => String(p.recommended_by || p.session || p.agent || 'Quox');
|
|
@@ -134,13 +137,30 @@ export function contentRows(it) {
|
|
|
134
137
|
].filter(r => r[1]);
|
|
135
138
|
}
|
|
136
139
|
|
|
140
|
+
export const INFERRED_WHY = '(guessed from the command, not stated by the caller)';
|
|
141
|
+
/** The exact command or tool input in full (never clipped), broken inside a token that cannot fit, one block under the label. */
|
|
142
|
+
function commandRows(label, text, cols) {
|
|
143
|
+
const lead = ' ' + label.padEnd(8); const pad = ' '.repeat(lead.length);
|
|
144
|
+
const room = cols ? Math.max(20, cols - lead.length - 1) : 0;
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const raw of String(text).split('\n')) {
|
|
147
|
+
if (!room) { out.push(raw); continue; }
|
|
148
|
+
let l = raw; if (!l.length) { out.push(''); continue; }
|
|
149
|
+
while (l.length > room) { let cut = l.lastIndexOf(' ', room); if (cut < room / 2) cut = room; out.push(l.slice(0, cut)); l = l.slice(cut).replace(/^ /, ''); }
|
|
150
|
+
out.push(l);
|
|
151
|
+
}
|
|
152
|
+
return out.map((l, k) => (k ? pad : lead) + l);
|
|
153
|
+
}
|
|
154
|
+
|
|
137
155
|
/** A plain approval (not a quox ask) shaped as a y/n card so the walkthrough can show it. The original id stays, so /approve N routes by the same list. */
|
|
138
156
|
export function approvalAsWalkItem(it) {
|
|
139
157
|
if (isDecision(it)) return it;
|
|
140
158
|
const p = it?.payload || {}; const c = p.approval_context || {};
|
|
141
159
|
const dw = distinctWhat(it); const d = describeInboxItem(it);
|
|
142
160
|
const body = contentRows(it).filter(r => r[0] !== 'summary').map(r => `${r[0]}: ${r[1]}`).join('\n');
|
|
143
|
-
const
|
|
161
|
+
const inferred = c.reason && typeof c.reason === 'object' && c.reason.source === 'inferred' && c.reason.text;
|
|
162
|
+
const whyHead = inferred ? `${INFERRED_WHY}\n${decodeEntities(c.reason.text)}` : (whyText(it) || c.why || p.reason || '');
|
|
163
|
+
const why = [whyHead, body].filter(Boolean).join('\n');
|
|
144
164
|
return { ...it, __approval: true, title: dw || d.title || it.title || it.id,
|
|
145
165
|
payload: { ...p, type: 'decision', session: requesterNames(it)[0] || approvalSourceLabel(it), why, high_risk: c.risk === 'high' || p.high_risk === true,
|
|
146
166
|
options: [{ key: 'y', label: 'Approve' }, { key: 'n', label: 'Deny' }] } };
|
|
@@ -161,8 +181,9 @@ export function approvalRow(it, n, { cols = 100, now = Date.now() } = {}) {
|
|
|
161
181
|
const on = count > 1 ? ` on ${count} ${/ssh|exec|host|shell/i.test(String(d.tool || it?.payload?.toolName || '')) ? 'hosts' : 'targets'} (${list.slice(0, 2).join(', ')}, +${count - 2} more)` : (count === 1 ? ` on ${list[0]}` : '');
|
|
162
182
|
const one = cmd.text.replace(/\s*\n\s*/g, ' ; ');
|
|
163
183
|
const head = ` [${n}] ${ageText(d.at, now).padStart(3)} ${String(who).slice(0, 18).padEnd(18)} ${d.tool || 'run'} `;
|
|
164
|
-
const
|
|
165
|
-
|
|
184
|
+
const bg = startsBackgroundWork(cmd.text) ? ' · runs in background' : '';
|
|
185
|
+
const room = Math.max(24, cols - head.length - on.length - risk.length - priv.length - bg.length - 2);
|
|
186
|
+
return `${head}\`${clipVisible(one, room, n)}\`${on}${risk}${priv}${bg}`;
|
|
166
187
|
}
|
|
167
188
|
const line = ` [${n}] ${ageText(d.at, now).padStart(3)} ${String(who).slice(0, 18).padEnd(18)} ${what}${risk}${priv}`;
|
|
168
189
|
return line.length > cols ? line.slice(0, Math.max(10, cols - 1)) + '…' : line;
|
|
@@ -290,20 +311,37 @@ export function hang(label, text, cols) {
|
|
|
290
311
|
if (cur || !out.length) out.push(cur);
|
|
291
312
|
return out.map((l, k) => (k ? pad : lead) + l);
|
|
292
313
|
}
|
|
314
|
+
/** Word-wrap `text` to `width`, breaking a token longer than the width inside it, so no line is ever wider than `width`. */
|
|
315
|
+
function wrapWords(text, width) {
|
|
316
|
+
const w = Math.max(1, width); const out = []; let cur = '';
|
|
317
|
+
for (let tok of String(text).split(' ')) {
|
|
318
|
+
while (tok.length > w) { // a token that can never fit: break it, never let it outdent or overflow
|
|
319
|
+
if (cur) { out.push(cur); cur = ''; }
|
|
320
|
+
out.push(tok.slice(0, w)); tok = tok.slice(w);
|
|
321
|
+
}
|
|
322
|
+
if (cur && cur.length + 1 + tok.length > w) { out.push(cur); cur = tok; } else cur = cur ? cur + ' ' + tok : tok;
|
|
323
|
+
}
|
|
324
|
+
if (cur || !out.length) out.push(cur);
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
/** A "Files (3): - a - b - c" that arrived with its line breaks flattened becomes a header line plus one "- item" line each. */
|
|
328
|
+
function unflattenList(text) {
|
|
329
|
+
const t = String(text ?? '');
|
|
330
|
+
if (t.includes('\n')) return t;
|
|
331
|
+
const m = /^(.*?\(\d+\):)\s+- (.+)$/.exec(t.trim());
|
|
332
|
+
return m ? [m[1], ...m[2].split(/\s+- /).map(x => '- ' + x)].join('\n') : t;
|
|
333
|
+
}
|
|
293
334
|
/** Like hang, but keeps the text's own line breaks: each line (a "- " list item too) starts a fresh line under the label, wrapped items indented past the dash. */
|
|
294
335
|
export function hangBlock(label, text, cols) {
|
|
295
|
-
const segs =
|
|
336
|
+
const segs = unflattenList(text).split('\n').map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
296
337
|
if (segs.length <= 1) return hang(label, segs[0] || '', cols);
|
|
297
338
|
const lead = ' ' + label.padEnd(8); const pad = ' '.repeat(lead.length);
|
|
298
339
|
const room = Math.max(20, (cols || 0) - lead.length - 1); const out = [];
|
|
299
340
|
for (const seg of segs) {
|
|
300
341
|
if (!cols) { out.push(seg); continue; }
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
if (cur && cur.length + 1 + w.length > room) { out.push(cur); cur = ind + w; } else cur = cur ? cur + ' ' + w : w;
|
|
305
|
-
}
|
|
306
|
-
if (cur) out.push(cur);
|
|
342
|
+
const item = /^[-*] /.test(seg);
|
|
343
|
+
const lines = item ? wrapWords(seg.slice(2), room - 2).map((l, k) => (k ? ' ' : seg.slice(0, 2)) + l) : wrapWords(seg, room);
|
|
344
|
+
out.push(...lines);
|
|
307
345
|
}
|
|
308
346
|
return out.map((l, k) => (k ? pad : lead) + l);
|
|
309
347
|
}
|
|
@@ -317,6 +355,22 @@ export const isLoosening = (it) => { const p = it?.payload || {}; return p.loose
|
|
|
317
355
|
export const NO_REASON = 'No reason given: ask the session, or say no.';
|
|
318
356
|
export const NO_JUSTIFICATION = 'No plain justification given: ask the session, or say no.';
|
|
319
357
|
const justificationOf = (p) => String(p.justification || p.plain_justification || '').replace(/\s+/g, ' ').trim();
|
|
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.';
|
|
361
|
+
export const NO_INDEPENDENT_VIEW = 'No independent view: judge the JUSTIFY text and the diff (/diff or the files listed).';
|
|
362
|
+
/** The `quox ask loosening` verb titles its card "Approve test edits: <paths>", which isLoosening does not match. */
|
|
363
|
+
const TEST_EDIT_RE = /approve\s+(?:loosening|test)\b/i;
|
|
364
|
+
function looseAdvice(p) {
|
|
365
|
+
const d = p.diff_stats || p.diff; if (!d || typeof d !== 'object') return NO_INDEPENDENT_VIEW;
|
|
366
|
+
const rm = Number(d.assertions_removed), ad = Number(d.assertions_added), fc = Number(d.files_changed);
|
|
367
|
+
const bits = [];
|
|
368
|
+
if (Number.isFinite(rm) && Number.isFinite(ad)) bits.push(rm === 0 ? `nothing removed, ${ad} added` : `${rm} assertion${rm === 1 ? '' : 's'} removed, ${ad} added`);
|
|
369
|
+
else if (Number.isFinite(rm)) bits.push(`${rm} assertion${rm === 1 ? '' : 's'} removed`);
|
|
370
|
+
if (d.new_test) bits.unshift('a new test');
|
|
371
|
+
if (Number.isFinite(fc)) bits.push(`${fc} file${fc === 1 ? '' : 's'} changed`);
|
|
372
|
+
return bits.length ? `From the diff: ${bits.join(', ')}. Judge the JUSTIFY text against that.` : NO_INDEPENDENT_VIEW;
|
|
373
|
+
}
|
|
320
374
|
export function walkCard(it, i, total, { cols = 0 } = {}) {
|
|
321
375
|
const p = it.payload; const who = p.session || p.agent || 'A session';
|
|
322
376
|
const loose = isLoosening(it); const just = justificationOf(p); // JUSTIFY shows whenever the asker gave one, loosening title or not
|
|
@@ -326,12 +380,14 @@ export function walkCard(it, i, total, { cols = 0 } = {}) {
|
|
|
326
380
|
const dflt = bareLoose ? (p.options.find(o => o.key === 'n') || null) : (p.options.find(o => o.key === p.default) || null);
|
|
327
381
|
const why = decodeEntities(String(p.why || '')).split('\n').map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n');
|
|
328
382
|
const what = yn
|
|
329
|
-
? `Yes lets ${who} go ahead with this; no stops it.${p.high_risk ? ' High risk: a yes needs your PIN.' : ''}`
|
|
383
|
+
? `Yes lets ${who} go ahead with this; no stops it.${it.__approval && commandOf(it)?.kind === 'command' && startsBackgroundWork(commandOf(it).text) ? ` This ${BACKGROUND_NOTE}.` : ''}${p.high_risk ? ' High risk: a yes needs your PIN.' : ''}`
|
|
330
384
|
: `${who} needs you to choose how to settle this.${p.high_risk ? ' High risk: a yes needs your PIN.' : ''}`;
|
|
331
385
|
const advice = bareLoose
|
|
332
386
|
? `${yn ? 'I would say no' : 'Do not approve this yet'}: this makes a check easier to pass and nothing explains why that is safe.${dflt ? ` Safe default: ${optWord(dflt)} (${dflt.key}).` : ''}`
|
|
387
|
+
: rec && (loose || TEST_EDIT_RE.test(String(it.title || '')))
|
|
388
|
+
? looseAdvice(p)
|
|
333
389
|
: rec
|
|
334
|
-
?
|
|
390
|
+
? NO_INDEPENDENT_VIEW_ASK
|
|
335
391
|
: `${who} gave no recommendation, and I will not guess one.${dflt ? ` If nobody answers it would take ${optWord(dflt)} (${dflt.key}).` : ''}`;
|
|
336
392
|
const keyClash = (k) => p.options.some(o => o.key === k);
|
|
337
393
|
const choices = [
|
|
@@ -339,10 +395,14 @@ export function walkCard(it, i, total, { cols = 0 } = {}) {
|
|
|
339
395
|
{ key: keyClash('s') ? 'skip' : 's', label: 'Skip for now', defer: true },
|
|
340
396
|
{ key: keyClash('?') ? 'ask' : '?', label: 'Ask about it', ask: true },
|
|
341
397
|
];
|
|
398
|
+
const cmdFact = it.__approval ? commandOf(it) : null; // the same facts the single approval card shows (approvalFacts), here in full
|
|
399
|
+
const whereFact = it.__approval ? whereText(it, { all: true }) : null;
|
|
342
400
|
const lines = [
|
|
343
401
|
`${p.__approval ? 'Approval' : 'Decision'} ${i} of ${total} · ${who} asks`,
|
|
344
402
|
...hang('', cleanTitle(it), cols).map(l => l.slice(8)),
|
|
345
403
|
...hang('WHAT', what, cols),
|
|
404
|
+
...(cmdFact ? commandRows(cmdFact.kind === 'input' ? 'INPUT' : 'COMMAND', cmdFact.text, cols) : []),
|
|
405
|
+
...(whereFact ? hang('WHERE', whereFact, cols) : []),
|
|
346
406
|
...(why ? hangBlock('WHY', why, cols) : []),
|
|
347
407
|
...(loose || just ? hang('JUSTIFY', just || NO_JUSTIFICATION, cols) : []),
|
|
348
408
|
...(!why && !just && !loose ? hang('WHY', NO_REASON, cols) : []),
|
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 } = {}) {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// APPROVALS-AUTO (owner 2026-11-06: "just make approvals pop up, i am tired of opening them"): pure rules for opening a
|
|
2
|
+
// newly arrived approval or decision BY ITSELF as the /approvals walkthrough card. It only ever OPENS a card; it never
|
|
3
|
+
// answers one. Keystrokes are never stolen: a draft in the box, typing in the last TYPING_PAUSE_MS, a streaming turn or
|
|
4
|
+
// another ask on screen holds it, and it opens at the next idle point. A walkthrough already open simply gains the card.
|
|
5
|
+
import { matchesAgent } from './approvalsView.js';
|
|
6
|
+
|
|
7
|
+
export const TYPING_PAUSE_MS = 2000;
|
|
8
|
+
/** How long after a "N more arrived" notice a bare y/n still belongs to the card, not the chat. */
|
|
9
|
+
export const STRAY_WINDOW_MS = 30000;
|
|
10
|
+
|
|
11
|
+
/** The /approvals auto setting: ON unless the profile explicitly says false. */
|
|
12
|
+
export const autoOn = (settings) => !(settings && settings.approvalsAuto === false);
|
|
13
|
+
|
|
14
|
+
const keyOf = (orgId, id) => `${orgId || ''}|${id}`;
|
|
15
|
+
|
|
16
|
+
/** @returns {{ enabled: boolean, seen: Set<string>, seeded: Set<string>, queue: object[] }} */
|
|
17
|
+
export function createAutoOpen({ enabled = true } = {}) {
|
|
18
|
+
return { enabled, seen: new Set(), seeded: new Set(), queue: [] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One poll of open items for one org (orgId null = the window's own org). The first read of each org only seeds what was
|
|
23
|
+
* already waiting (nothing pops at launch). New ids queue once; ids in skipIds (shown inline as this conversation's card,
|
|
24
|
+
* or answered here) never queue; a queued item the server no longer lists leaves the queue. While switched off, arrivals
|
|
25
|
+
* are marked seen so turning it on never replays them.
|
|
26
|
+
* Cross-org items carry __orgId and __orgName. seed overrides the first-read rule (false: queue even on a first read).
|
|
27
|
+
*/
|
|
28
|
+
export function noteArrivals(st, items, { skipIds = null, orgId = null, orgName = null, seed = null } = {}) {
|
|
29
|
+
const list = (Array.isArray(items) ? items : []).filter((x) => x && x.id);
|
|
30
|
+
const org = orgId || '';
|
|
31
|
+
const first = seed === null ? !st.seeded.has(org) : Boolean(seed); // seed:false = an org first read after launch: what it has is new
|
|
32
|
+
st.seeded.add(org);
|
|
33
|
+
const live = new Set(list.map((x) => keyOf(orgId, x.id)));
|
|
34
|
+
st.queue = st.queue.filter((x) => (x.__orgId || '') !== org || live.has(keyOf(orgId, x.id)));
|
|
35
|
+
const added = [];
|
|
36
|
+
for (const it of list) {
|
|
37
|
+
const k = keyOf(orgId, it.id);
|
|
38
|
+
if (st.seen.has(k)) continue;
|
|
39
|
+
st.seen.add(k);
|
|
40
|
+
if (first || !st.enabled || (skipIds && skipIds.has(it.id))) continue;
|
|
41
|
+
const item = orgId ? { ...it, __orgId: orgId, __orgName: orgName || orgId } : it;
|
|
42
|
+
st.queue.push(item); added.push(item);
|
|
43
|
+
}
|
|
44
|
+
return { added };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether to open now: 'open' | 'join' (a walk is on screen: append, its own order shows it) | 'wait-draft' |
|
|
49
|
+
* 'wait-typing' | 'wait-turn' | 'wait-ask' | null (nothing queued, or switched off).
|
|
50
|
+
*/
|
|
51
|
+
export function openDecision(st, { draft = '', lastKeyAt = 0, now = Date.now(), streaming = false, walkOpen = false, askOpen = false } = {}) {
|
|
52
|
+
if (!st.enabled || !st.queue.length) return null;
|
|
53
|
+
if (walkOpen) return 'join';
|
|
54
|
+
if (String(draft || '') !== '') return 'wait-draft';
|
|
55
|
+
if (now - (lastKeyAt || 0) < TYPING_PAUSE_MS) return 'wait-typing';
|
|
56
|
+
if (streaming) return 'wait-turn';
|
|
57
|
+
if (askOpen) return 'wait-ask';
|
|
58
|
+
return 'open';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Hands the queued items over once. */
|
|
62
|
+
export function takeQueue(st) { const q = st.queue; st.queue = []; return q; }
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A submitted line that is a bare yes/no while a card is queued (or just after a "more arrived" notice) belongs to that
|
|
66
|
+
* card: the caller opens the card and keeps the key for it. Never chat.
|
|
67
|
+
*/
|
|
68
|
+
export function strayAnswer(text, { queued = 0, walkOpen = false, noticeAt = 0, now = Date.now() } = {}) {
|
|
69
|
+
if (walkOpen) return false;
|
|
70
|
+
if (!/^(y|n|yes|no)$/i.test(String(text ?? '').trim())) return false;
|
|
71
|
+
return queued > 0 || (noticeAt > 0 && now - noticeAt < STRAY_WINDOW_MS);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const APPROVALS_OPTIONS = 'help, walkthrough, N, mine, agents, old, <agent name>, auto on|off';
|
|
75
|
+
|
|
76
|
+
/** /approvals arguments -> { kind: list|help|walkthrough|detail|filter|auto|unknown }. An unknown word says so, never lists. */
|
|
77
|
+
export function parseApprovalsArg(arg, { items = [] } = {}) {
|
|
78
|
+
const t = String(arg ?? '').trim();
|
|
79
|
+
if (!t) return { kind: 'list' };
|
|
80
|
+
if (/^help$/i.test(t)) return { kind: 'help' };
|
|
81
|
+
if (/^walkthrough$/i.test(t)) return { kind: 'walkthrough' };
|
|
82
|
+
if (/^\d+$/.test(t)) return { kind: 'detail', n: Number(t) };
|
|
83
|
+
const m = /^auto(?:\s+(\S+))?$/i.exec(t);
|
|
84
|
+
if (m) {
|
|
85
|
+
if (!m[1]) return { kind: 'auto', set: null };
|
|
86
|
+
if (/^on$/i.test(m[1])) return { kind: 'auto', set: true };
|
|
87
|
+
if (/^off$/i.test(m[1])) return { kind: 'auto', set: false };
|
|
88
|
+
return { kind: 'unknown', word: t };
|
|
89
|
+
}
|
|
90
|
+
const k = t.toLowerCase();
|
|
91
|
+
if (k === 'mine' || k === 'agents' || k === 'old') return { kind: 'filter', f: k };
|
|
92
|
+
if ((items || []).some((it) => matchesAgent(it, t))) return { kind: 'filter', f: t };
|
|
93
|
+
return { kind: 'unknown', word: t };
|
|
94
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Does a shell command start detached work? Then the approver's "it ran fine" only covers the launch. Pure: text in, boolean out.
|
|
2
|
+
// Quote-aware: '&' inside quotes, '&&', redirects (2>&1, >&2, &>f), '|&', a backslash-escaped '\&' and '# comments' are not background.
|
|
3
|
+
|
|
4
|
+
export const BACKGROUND_NOTE = 'starts background work: the result you see is only the launch, not the outcome';
|
|
5
|
+
|
|
6
|
+
const WRAPPERS = new Set(['sudo', 'env', 'command', 'exec', 'time', 'nice', 'ionice', 'doas', 'stdbuf', 'timeout']);
|
|
7
|
+
const DETACHERS = new Set(['nohup', 'setsid', 'disown', 'at', 'batch']);
|
|
8
|
+
|
|
9
|
+
/** One command's words (quotes removed) -> true when its command word detaches. */
|
|
10
|
+
function segmentDetaches(words) {
|
|
11
|
+
let i = 0;
|
|
12
|
+
while (i < words.length) {
|
|
13
|
+
const w = words[i];
|
|
14
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) { i++; continue; } // FOO=1 cmd
|
|
15
|
+
if (WRAPPERS.has(w)) { i++; while (i < words.length && /^-/.test(words[i])) i++; continue; }
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
const cmd = (words[i] || '').replace(/^.*\//, '');
|
|
19
|
+
const rest = words.slice(i + 1);
|
|
20
|
+
if (DETACHERS.has(cmd)) return true;
|
|
21
|
+
if (cmd === 'systemd-run') return !rest.some(a => /^(--wait|--pipe|--pty|--scope|--shell|-P|-t|-S)$/.test(a)); // these wait for the unit; plain systemd-run returns at once
|
|
22
|
+
if (cmd === 'screen') return rest.some(a => /^-[A-Za-z]*d[A-Za-z]*m?[A-Za-z]*$/.test(a) && /^-[A-Za-z]+$/.test(a) && (/d/.test(a)) && (/m/.test(a) || rest.includes('-m'))); // screen -d -m / -dm / -dmS
|
|
23
|
+
if (cmd === 'tmux') return /^(new|new-session)$/.test(rest[0] || '') && rest.slice(1).some(a => /^-[A-Za-z]*d[A-Za-z]*$/.test(a));
|
|
24
|
+
if (cmd === 'docker') {
|
|
25
|
+
const sub = rest.filter(a => !/^-/.test(a));
|
|
26
|
+
if (sub[0] === 'run') return rest.some(a => a === '--detach' || /^-[A-Za-z]*d[A-Za-z]*$/.test(a));
|
|
27
|
+
if (sub[0] === 'compose' && rest.includes('up')) return rest.some(a => a === '--detach' || /^-[A-Za-z]*d[A-Za-z]*$/.test(a));
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (cmd === 'docker-compose') return rest[0] === 'up' && rest.some(a => a === '--detach' || /^-[A-Za-z]*d[A-Za-z]*$/.test(a));
|
|
31
|
+
if (cmd === 'systemctl') return rest.includes('--no-block') && rest.some(a => /^(start|restart|reload|try-restart|reload-or-restart|stop|isolate)$/.test(a));
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function startsBackgroundWork(text) {
|
|
36
|
+
const s = typeof text === 'string' ? text : '';
|
|
37
|
+
if (!s) return false;
|
|
38
|
+
let words = []; let cur = null; let q = null; let found = false;
|
|
39
|
+
const endWord = () => { if (cur !== null) { words.push(cur); cur = null; } };
|
|
40
|
+
const endSeg = () => { endWord(); if (words.length && segmentDetaches(words)) found = true; words = []; };
|
|
41
|
+
for (let i = 0; i < s.length && !found; i++) {
|
|
42
|
+
const ch = s[i];
|
|
43
|
+
if (q) { if (ch === q) q = null; else if (q === '"' && ch === '\\' && i + 1 < s.length) { cur += s[++i]; } else cur += ch; continue; }
|
|
44
|
+
if (ch === '\\') { cur = (cur ?? '') + (s[++i] ?? ''); continue; }
|
|
45
|
+
if (ch === "'" || ch === '"') { q = ch; cur = cur ?? ''; continue; }
|
|
46
|
+
if (ch === '#' && cur === null) { while (i < s.length && s[i] !== '\n') i++; endSeg(); continue; }
|
|
47
|
+
if (/\s/.test(ch)) { if (ch === '\n') endSeg(); else endWord(); continue; }
|
|
48
|
+
if (ch === '&') {
|
|
49
|
+
if (s[i + 1] === '&') { endSeg(); i++; continue; }
|
|
50
|
+
const prev = s[i - 1];
|
|
51
|
+
if (s[i + 1] === '>' || prev === '>' || prev === '<' || prev === '|') { cur = (cur ?? '') + ch; continue; } // &>f, 2>&1, <&3, |&
|
|
52
|
+
endSeg(); found = true; break; // a lone '&' detaches the command before it
|
|
53
|
+
}
|
|
54
|
+
if (ch === ';' || ch === '|' || ch === '(' || ch === ')' || ch === '{' || ch === '}') { endSeg(); continue; }
|
|
55
|
+
cur = (cur ?? '') + ch;
|
|
56
|
+
}
|
|
57
|
+
if (!found) endSeg();
|
|
58
|
+
return found;
|
|
59
|
+
}
|
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;
|
|
@@ -187,7 +193,7 @@ export function runBang(plan, { cwd = process.cwd(), env = process.env, timeoutM
|
|
|
187
193
|
/** The "exit N · 1.2s" footer, plus the plain-words reasons. */
|
|
188
194
|
export function footer(r, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
189
195
|
const secs = `${(r.ms / 1000).toFixed(1)}s`;
|
|
190
|
-
if (r.timedOut) return `timed out after ${Math.round(timeoutMs / 1000)}s
|
|
196
|
+
if (r.timedOut) return `timed out after ${Math.round(timeoutMs / 1000)}s · stopped; anything it already started may have completed. Run long commands in a terminal, or set QUOX_BANG_TIMEOUT_MS · ${secs}`;
|
|
191
197
|
if (r.killed) return `stopped (Ctrl+C) · ${secs}`;
|
|
192
198
|
if (r.signal) return `killed by ${r.signal} · ${secs}`;
|
|
193
199
|
return `exit ${r.code} · ${secs}`;
|
|
@@ -45,6 +45,11 @@ export function offerFor(capabilityId, res) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** -> display names of every requires_setup capability, in server order. */
|
|
49
|
+
export function needsSetupNames(caps) {
|
|
50
|
+
return (Array.isArray(caps) ? caps : []).filter(c => c.availability === 'requires_setup').map(capabilityName);
|
|
51
|
+
}
|
|
52
|
+
|
|
48
53
|
/** -> [[text, tone?]] for /capabilities. */
|
|
49
54
|
export function capabilityLines(res) {
|
|
50
55
|
if (!res?.ok || !Array.isArray(res.capabilities)) {
|
|
@@ -59,7 +64,7 @@ export function capabilityLines(res) {
|
|
|
59
64
|
if (!installed.length) out.push([' nothing is ready yet']);
|
|
60
65
|
const setup = of('requires_setup');
|
|
61
66
|
if (setup.length) {
|
|
62
|
-
out.push([
|
|
67
|
+
out.push([`Needs setup (${setup.length}): ${needsSetupNames(caps).join(', ')}`]);
|
|
63
68
|
for (const c of setup) out.push([`◐ ${capabilityName(c)} ${c.reason || 'needs setup'}${c.setup ? ` · ${c.setup}` : ''}`, 'warn']);
|
|
64
69
|
}
|
|
65
70
|
const unk = caps.filter(statusUnreadable);
|
|
@@ -70,7 +75,7 @@ export function capabilityLines(res) {
|
|
|
70
75
|
const off = of('unavailable').filter(c => !statusUnreadable(c));
|
|
71
76
|
if (off.length) {
|
|
72
77
|
out.push(['Not available here:']);
|
|
73
|
-
for (const c of off) out.push([`○ ${capabilityName(c)} ${c.reason || 'unavailable'}${c.setup ? ` · ${c.setup}` : ''}`]);
|
|
78
|
+
for (const c of off) out.push([`○ ${capabilityName(c)} ${c.reason === 'No signal to check' ? 'not checked: nothing here reports on it yet' : (c.reason || 'unavailable')}${c.setup ? ` · ${c.setup}` : ''}`]);
|
|
74
79
|
}
|
|
75
80
|
const planned = of('planned');
|
|
76
81
|
if (planned.length) {
|