quoxcli 0.3.1 → 0.5.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 +61 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/actions.js +15 -0
- package/lib/api/endpoints/admin.js +27 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/contactsq.js +4 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/hat.js +14 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/mon.js +295 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +47 -0
- package/lib/commands/actions.js +81 -11
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agent.js +11 -4
- package/lib/commands/agentAttach.js +61 -7
- package/lib/commands/approvals.js +198 -3
- package/lib/commands/ask.js +4 -2
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/blog.js +110 -0
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +142 -28
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +7 -33
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +85 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/engagement.js +90 -0
- package/lib/commands/evidence.js +155 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fleet.js +25 -0
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/hat.js +76 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +1123 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +39 -1
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +318 -0
- package/lib/commands/lucid.js +1 -0
- package/lib/commands/mail.js +161 -20
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +55 -144
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +259 -0
- package/lib/commands/objective.js +53 -3
- package/lib/commands/offload.js +2 -3
- package/lib/commands/org.js +21 -4
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +87 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/quoxseo.js +31 -0
- package/lib/commands/research.js +79 -0
- package/lib/commands/rows.js +9 -0
- package/lib/commands/run.js +20 -2
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +150 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +24 -5
- 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/vm.js +2 -2
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +824 -0
- package/lib/commands/wazuhIntake.js +317 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/commands/youtube.js +172 -0
- package/lib/core/actionItems.js +115 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -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 +37 -3
- package/lib/core/commandRegistrars.js +17 -0
- package/lib/core/config.js +29 -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/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +1 -1
- package/lib/core/governedSession.js +136 -8
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +73 -7
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +8 -8
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/shortcut.js +83 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +5 -0
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/quoxon/EXTENSIONS.md +146 -0
- package/lib/quoxon/api.js +364 -27
- package/lib/quoxon/app.js +1629 -183
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +64 -8
- package/lib/quoxon/approvalSentence.js +48 -0
- package/lib/quoxon/approvalsView.js +124 -7
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +1 -1
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +22 -3
- package/lib/quoxon/commands.js +110 -15
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/countdownLine.js +10 -1
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/demo.autoplay.js +1 -1
- package/lib/quoxon/demo.js +3 -1
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dividerAocl.js +4 -3
- package/lib/quoxon/dividerRule.js +3 -3
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +285 -0
- package/lib/quoxon/dragonModes.js +1 -0
- package/lib/quoxon/dragonMoments.js +160 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +2 -2
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +67 -10
- package/lib/quoxon/extensions.js +636 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +182 -0
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/keys.js +4 -4
- package/lib/quoxon/loopCard.js +460 -0
- package/lib/quoxon/loopCards.js +189 -0
- package/lib/quoxon/loopStream.js +98 -0
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +29 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/mouse.js +12 -1
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/objects/index.js +22 -4
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/proof.js +12 -4
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +49 -1
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +8 -3
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/qdragonFly.js +7 -6
- package/lib/quoxon/qmotion.js +17 -0
- package/lib/quoxon/render.js +187 -41
- package/lib/quoxon/roster.js +36 -17
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +244 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -0
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +10 -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 +18 -5
- package/lib/quoxon/slash/broadcastRun.js +13 -2
- package/lib/quoxon/slash/cliCommand.js +42 -1
- package/lib/quoxon/slash/cliLeaves.json +139 -2
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +23 -2
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +31 -4
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slash/todoCmd.js +122 -0
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +17 -0
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/suggestCommand.js +34 -3
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/toolCards.js +30 -0
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +81 -0
- package/lib/quoxon/windowOrg.js +11 -1
- package/lib/server/devServers.js +137 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +4 -2
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- 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/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -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/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -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,7 +3,11 @@
|
|
|
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;
|
|
9
|
+
/** Server text can arrive HTML-escaped ("2>/dev/null"); decode for display. Never renders HTML. */
|
|
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);
|
|
7
11
|
const str = (v) => (typeof v === 'string' ? v : v == null ? '' : (() => { try { return JSON.stringify(v); } catch { return String(v); } })());
|
|
8
12
|
|
|
9
13
|
/** Text cut at `max` with a visible, honest tail. `n` is the /approvals number when known. */
|
|
@@ -16,11 +20,11 @@ export function clipVisible(text, max, n = null) {
|
|
|
16
20
|
/** The exact command or tool input, verbatim: method.command, else the reach request, else args. */
|
|
17
21
|
export function commandOf(it) {
|
|
18
22
|
const p = it?.payload || {}; const c = p.approval_context || {}; const m = c.method || {};
|
|
19
|
-
if (typeof m.command === 'string' && m.command) return { text: m.command, kind: 'command' };
|
|
23
|
+
if (typeof m.command === 'string' && m.command) return { text: dec(m.command), kind: 'command' };
|
|
20
24
|
const rq = (p.reach?.requests || []).find(r => r && r.kind === 'command' && typeof r.id === 'string' && r.id);
|
|
21
|
-
if (rq) return { text: rq.id, kind: 'command' };
|
|
25
|
+
if (rq) return { text: dec(rq.id), kind: 'command' };
|
|
22
26
|
const a = m.args ?? c.args ?? p.args ?? null;
|
|
23
|
-
if (a != null && a !== '') return { text: str(a), kind: 'input' };
|
|
27
|
+
if (a != null && a !== '') return { text: dec(str(a)), kind: 'input' };
|
|
24
28
|
return null;
|
|
25
29
|
}
|
|
26
30
|
|
|
@@ -70,24 +74,76 @@ export function whoText(it) {
|
|
|
70
74
|
/** WHY: the reason text (marked inferred when the caller did not state it). */
|
|
71
75
|
export function whyText(it) {
|
|
72
76
|
const c = it?.payload?.approval_context || {}; const r = c.reason;
|
|
73
|
-
if (r && typeof r === 'object') return r.text ? `${r.text}${r.source === 'inferred' ? ' (inferred, not stated by the caller)' : ''}` : null;
|
|
74
|
-
return r || it?.payload?.reason || null;
|
|
77
|
+
if (r && typeof r === 'object') return r.text ? `${dec(r.text)}${r.source === 'inferred' ? ' (inferred, not stated by the caller)' : ''}` : null;
|
|
78
|
+
return dec(r || it?.payload?.reason || null);
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
/** The user's own words that started this, when the server kept them. */
|
|
78
|
-
export const excerptOf = (it) => it?.payload?.approval_context?.on_behalf_of?.request_excerpt || null;
|
|
82
|
+
export const excerptOf = (it) => dec(it?.payload?.approval_context?.on_behalf_of?.request_excerpt) || null;
|
|
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
|
+
}
|
|
79
130
|
|
|
80
131
|
/** One bag of the facts, for the card. */
|
|
81
132
|
export function approvalFacts(it, { n = null } = {}) {
|
|
82
133
|
const cmd = commandOf(it);
|
|
134
|
+
const af = askedFor(it);
|
|
83
135
|
const when = it?.payload?.approval_context?.created_at || it?.created_at || it?.createdAt || null;
|
|
84
136
|
return {
|
|
85
|
-
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)),
|
|
86
140
|
where: whereText(it, { n }),
|
|
87
141
|
hostList: hostsOf(it).list,
|
|
88
142
|
who: whoText(it),
|
|
89
143
|
why: whyText(it),
|
|
90
|
-
asked: excerptOf(it),
|
|
144
|
+
asked: af ? af.text : excerptOf(it),
|
|
145
|
+
askedNone: Boolean(af && af.none),
|
|
146
|
+
askedRich: Boolean(af),
|
|
91
147
|
when: when ? String(when).replace('T', ' ').slice(0, 16) + ' UTC' : null,
|
|
92
148
|
access: accessOf(it)
|
|
93
149
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// TUI-APPROVE: "You approved WHO to do WHAT on WHERE", built only from fields the card already shows.
|
|
2
|
+
// A field that is unknown is left out, never printed as "unknown".
|
|
3
|
+
|
|
4
|
+
const PLAIN = { // tool -> [infinitive, gerund]
|
|
5
|
+
ssh_exec: ['run a shell command', 'running a shell command'],
|
|
6
|
+
host_exec: ['run a shell command', 'running a shell command'],
|
|
7
|
+
host_restart: ['restart a service', 'restarting a service']
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** "VULCAN-SEC via FORGE (CLI) acting for Adam" -> "VULCAN-SEC". */
|
|
11
|
+
export function shortWho(raw) {
|
|
12
|
+
let w = String(raw || '').replace(/\s+acting for .*$/i, '').trim();
|
|
13
|
+
if (!w || /^unknown requester/i.test(w)) return '';
|
|
14
|
+
const sess = w.match(/\(via\s+session\s+([^,)]+)/i); // "FORGE (via session VULCAN-SEC, CLI)": the session is who asked
|
|
15
|
+
if (sess && sess[1].trim()) return sess[1].trim();
|
|
16
|
+
w = w.split('→').pop().split(/\s+via\s+/i)[0].replace(/\s*\(.*$/, '').trim();
|
|
17
|
+
return w;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const clean = (s) => String(s || '').replace(/\s+/g, ' ').replace(/[.\s]+$/, '').trim();
|
|
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
|
+
|
|
25
|
+
function whatOf(a) {
|
|
26
|
+
const plain = PLAIN[a.tool];
|
|
27
|
+
if (plain) return plain;
|
|
28
|
+
const w = clean(a.what && a.what !== a.tool ? a.what : '');
|
|
29
|
+
if (w) return [w, w];
|
|
30
|
+
const t = clean(String(a.tool || '').replace(/_/g, ' '));
|
|
31
|
+
return t ? [`use ${t}`, `using ${t}`] : ['', ''];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The one- or two-line confirmation for an answered card. */
|
|
35
|
+
export function approvalSentence(a, approved) {
|
|
36
|
+
const who = shortWho(a.who);
|
|
37
|
+
const [inf, ger] = whatOf(a);
|
|
38
|
+
const where = tidyWhere(clean(a.where || a.target));
|
|
39
|
+
const said = (t) => where && new RegExp(`\\bon ${where.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'i').test(t); // WHAT already ends "on <where>"
|
|
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}` : '';
|
|
42
|
+
if (approved) {
|
|
43
|
+
const tail = a.foreign && !a.approvalId ? ' It runs now; its result isn\'t tracked here.' : '';
|
|
44
|
+
const head = who ? `${who}${inf ? ` to ${inf}` : ''}` : (ger || 'the request');
|
|
45
|
+
return `\u2714 You approved ${head}${at}.${tail || (a.foreign ? ' It runs now; the result follows here.' : '')}`;
|
|
46
|
+
}
|
|
47
|
+
return `\u2716 You denied ${who ? `${who} ` : ''}${ger || 'the request'}${at}.`;
|
|
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');
|
|
@@ -110,12 +113,66 @@ export function filterPredicate(f, { userId = null, conversationId = null, now =
|
|
|
110
113
|
return (it) => matchesAgent(it, f);
|
|
111
114
|
}
|
|
112
115
|
|
|
116
|
+
/** What the item says it will do, in its own words: the server's what.summary or the post text, whichever is not just the generic "tool target". Null when there is nothing more specific. */
|
|
117
|
+
export function distinctWhat(it) {
|
|
118
|
+
const p = it?.payload || {}; const w = p.what && typeof p.what === 'object' ? p.what : {};
|
|
119
|
+
const generic = [w.action, w.target].filter(Boolean).join(' ').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
120
|
+
const one = (t) => String(t ?? '').replace(/\s+/g, ' ').trim();
|
|
121
|
+
for (const c of [w.summary, p.action_params?.text, p.action_params?.body, p.action_params?.message]) {
|
|
122
|
+
const t = one(c); if (t && t.toLowerCase() !== generic) return t;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/** The content being approved, in full: summary, post text, images, body_md. */
|
|
127
|
+
export function contentRows(it) {
|
|
128
|
+
const p = it?.payload || {}; const w = p.what && typeof p.what === 'object' ? p.what : {}; const ap = p.action_params || {};
|
|
129
|
+
const imgs = [].concat(ap.image_paths || ap.images || []).filter(Boolean).map(String);
|
|
130
|
+
const text = ap.text ?? ap.body ?? ap.message ?? null;
|
|
131
|
+
const body = typeof p.body_md === 'string' ? p.body_md : null;
|
|
132
|
+
return [
|
|
133
|
+
['summary', w.summary ? String(w.summary) : null],
|
|
134
|
+
['content', text != null && String(text).trim() ? String(text).trim() : null],
|
|
135
|
+
['images', imgs.length ? imgs.join(', ') : null],
|
|
136
|
+
['details', body && body.trim() && body.trim() !== String(text ?? '').trim() ? body.trim() : null]
|
|
137
|
+
].filter(r => r[1]);
|
|
138
|
+
}
|
|
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
|
+
|
|
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. */
|
|
156
|
+
export function approvalAsWalkItem(it) {
|
|
157
|
+
if (isDecision(it)) return it;
|
|
158
|
+
const p = it?.payload || {}; const c = p.approval_context || {};
|
|
159
|
+
const dw = distinctWhat(it); const d = describeInboxItem(it);
|
|
160
|
+
const body = contentRows(it).filter(r => r[0] !== 'summary').map(r => `${r[0]}: ${r[1]}`).join('\n');
|
|
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');
|
|
164
|
+
return { ...it, __approval: true, title: dw || d.title || it.title || it.id,
|
|
165
|
+
payload: { ...p, type: 'decision', session: requesterNames(it)[0] || approvalSourceLabel(it), why, high_risk: c.risk === 'high' || p.high_risk === true,
|
|
166
|
+
options: [{ key: 'y', label: 'Approve' }, { key: 'n', label: 'Deny' }] } };
|
|
167
|
+
}
|
|
168
|
+
|
|
113
169
|
/** One line: [N] age who what (risk). Clipped to `cols`. */
|
|
114
170
|
export function approvalRow(it, n, { cols = 100, now = Date.now() } = {}) {
|
|
115
171
|
if (isDecision(it)) return decisionRow(it, n, { cols, now });
|
|
116
172
|
const d = describeInboxItem(it);
|
|
117
173
|
const who = requesterNames(it)[0] || approvalSourceLabel(it);
|
|
118
174
|
let what = [d.tool, d.target && d.target !== d.tool ? d.target : null].filter(Boolean).join(' ') || d.title || it.id;
|
|
175
|
+
const dw = distinctWhat(it); if (dw && commandOf(it)?.kind !== 'command') what = dw; // two posts to the same account must not read the same
|
|
119
176
|
const risk = ctxOf(it).risk ? ` (${ctxOf(it).risk})` : '';
|
|
120
177
|
const priv = isPrivileged({ tool: d.tool, risk: ctxOf(it).risk, flag: it?.payload?.privileged === true || ctxOf(it).privileged === true, title: it.title }) ? ' [privileged]' : '';
|
|
121
178
|
const cmd = commandOf(it);
|
|
@@ -124,8 +181,9 @@ export function approvalRow(it, n, { cols = 100, now = Date.now() } = {}) {
|
|
|
124
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]}` : '');
|
|
125
182
|
const one = cmd.text.replace(/\s*\n\s*/g, ' ; ');
|
|
126
183
|
const head = ` [${n}] ${ageText(d.at, now).padStart(3)} ${String(who).slice(0, 18).padEnd(18)} ${d.tool || 'run'} `;
|
|
127
|
-
const
|
|
128
|
-
|
|
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}`;
|
|
129
187
|
}
|
|
130
188
|
const line = ` [${n}] ${ageText(d.at, now).padStart(3)} ${String(who).slice(0, 18).padEnd(18)} ${what}${risk}${priv}`;
|
|
131
189
|
return line.length > cols ? line.slice(0, Math.max(10, cols - 1)) + '…' : line;
|
|
@@ -150,6 +208,7 @@ export function approvalDetail(it, n, { now = Date.now() } = {}) {
|
|
|
150
208
|
['who', whoText(it)],
|
|
151
209
|
['why', why ? String(why) : null],
|
|
152
210
|
['request', excerptOf(it) ? `"${excerptOf(it)}"` : null],
|
|
211
|
+
...contentRows(it).filter(r => !(r[0] === 'summary' && String(r[1]) === String(why ?? ''))),
|
|
153
212
|
['class', accessOf(it)],
|
|
154
213
|
['from', requesterNames(it).join(', ') || approvalSourceLabel(it)],
|
|
155
214
|
['asked', d.at ? `${ageText(d.at, now)} ago (${String(d.at)})` : null],
|
|
@@ -235,7 +294,7 @@ export function walkNoCatch(it) {
|
|
|
235
294
|
}
|
|
236
295
|
/** A title without the asker's own name in front of it ("SEER: Publish?" asked by SEER -> "Publish?"), whitespace and line breaks folded. */
|
|
237
296
|
export function cleanTitle(it) {
|
|
238
|
-
const p = it.payload || {}; const t = String(it.title || it.id || '').replace(/\s+/g, ' ').trim();
|
|
297
|
+
const p = it.payload || {}; const t = decodeEntities(String(it.title || it.id || '')).replace(/\s+/g, ' ').trim();
|
|
239
298
|
for (const name of [p.session, p.agent].filter(Boolean).map(String)) {
|
|
240
299
|
if (t.toLowerCase().startsWith(name.toLowerCase() + ':')) { const rest = t.slice(name.length + 1).trim(); if (rest) return rest; }
|
|
241
300
|
}
|
|
@@ -252,12 +311,64 @@ export function hang(label, text, cols) {
|
|
|
252
311
|
if (cur || !out.length) out.push(cur);
|
|
253
312
|
return out.map((l, k) => (k ? pad : lead) + l);
|
|
254
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
|
+
}
|
|
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. */
|
|
335
|
+
export function hangBlock(label, text, cols) {
|
|
336
|
+
const segs = unflattenList(text).split('\n').map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
337
|
+
if (segs.length <= 1) return hang(label, segs[0] || '', cols);
|
|
338
|
+
const lead = ' ' + label.padEnd(8); const pad = ' '.repeat(lead.length);
|
|
339
|
+
const room = Math.max(20, (cols || 0) - lead.length - 1); const out = [];
|
|
340
|
+
for (const seg of segs) {
|
|
341
|
+
if (!cols) { out.push(seg); continue; }
|
|
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);
|
|
345
|
+
}
|
|
346
|
+
return out.map((l, k) => (k ? pad : lead) + l);
|
|
347
|
+
}
|
|
348
|
+
/** Slot and server text can arrive HTML-escaped ("->"); decode for display. Never renders HTML. */
|
|
349
|
+
export function decodeEntities(t) {
|
|
350
|
+
return String(t ?? '').replace(/&(amp|lt|gt|quot|apos|#39|#x27);/gi, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", '#x27': "'" })[e.toLowerCase()]);
|
|
351
|
+
}
|
|
255
352
|
/** Owner 2026-10-17: a test or guard loosening needs a plain justification. True when the title, kind or tags say the ask would make a check easier to pass. */
|
|
256
353
|
export const LOOSEN_RE = /loosen|weaken|relax|widen|(?:delete|remove|skip|disable|replace|rewrite|change|edit|update)\w*\s+(?:\w+\s+){0,3}?(?:tests?|guards?|checks?|assertions?|proof cases?)\b|test.?pin|goal ?post/i;
|
|
257
354
|
export const isLoosening = (it) => { const p = it?.payload || {}; return p.loosening === true || LOOSEN_RE.test([it?.title, p.kind, p.category, ...(Array.isArray(p.tags) ? p.tags : [])].filter(Boolean).join(' ')); };
|
|
258
355
|
export const NO_REASON = 'No reason given: ask the session, or say no.';
|
|
259
356
|
export const NO_JUSTIFICATION = 'No plain justification given: ask the session, or say no.';
|
|
260
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
|
+
export const NO_INDEPENDENT_VIEW = 'No independent view: judge the JUSTIFY text and the diff (/diff or the files listed).';
|
|
360
|
+
/** The `quox ask loosening` verb titles its card "Approve test edits: <paths>", which isLoosening does not match. */
|
|
361
|
+
const TEST_EDIT_RE = /approve\s+(?:loosening|test)\b/i;
|
|
362
|
+
function looseAdvice(p) {
|
|
363
|
+
const d = p.diff_stats || p.diff; if (!d || typeof d !== 'object') return NO_INDEPENDENT_VIEW;
|
|
364
|
+
const rm = Number(d.assertions_removed), ad = Number(d.assertions_added), fc = Number(d.files_changed);
|
|
365
|
+
const bits = [];
|
|
366
|
+
if (Number.isFinite(rm) && Number.isFinite(ad)) bits.push(rm === 0 ? `nothing removed, ${ad} added` : `${rm} assertion${rm === 1 ? '' : 's'} removed, ${ad} added`);
|
|
367
|
+
else if (Number.isFinite(rm)) bits.push(`${rm} assertion${rm === 1 ? '' : 's'} removed`);
|
|
368
|
+
if (d.new_test) bits.unshift('a new test');
|
|
369
|
+
if (Number.isFinite(fc)) bits.push(`${fc} file${fc === 1 ? '' : 's'} changed`);
|
|
370
|
+
return bits.length ? `From the diff: ${bits.join(', ')}. Judge the JUSTIFY text against that.` : NO_INDEPENDENT_VIEW;
|
|
371
|
+
}
|
|
261
372
|
export function walkCard(it, i, total, { cols = 0 } = {}) {
|
|
262
373
|
const p = it.payload; const who = p.session || p.agent || 'A session';
|
|
263
374
|
const loose = isLoosening(it); const just = justificationOf(p); // JUSTIFY shows whenever the asker gave one, loosening title or not
|
|
@@ -265,12 +376,14 @@ export function walkCard(it, i, total, { cols = 0 } = {}) {
|
|
|
265
376
|
const yn = p.options.some(o => o.key === 'y') && p.options.some(o => o.key === 'n');
|
|
266
377
|
const rec = bareLoose ? null : (p.options.find(o => o.key === p.recommend) || null);
|
|
267
378
|
const dflt = bareLoose ? (p.options.find(o => o.key === 'n') || null) : (p.options.find(o => o.key === p.default) || null);
|
|
268
|
-
const why = String(p.why || '').replace(/\s+/g, ' ').trim();
|
|
379
|
+
const why = decodeEntities(String(p.why || '')).split('\n').map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n');
|
|
269
380
|
const what = yn
|
|
270
|
-
? `Yes lets ${who} go ahead with this; no stops it.${p.high_risk ? ' High risk: a yes needs your PIN.' : ''}`
|
|
381
|
+
? `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.' : ''}`
|
|
271
382
|
: `${who} needs you to choose how to settle this.${p.high_risk ? ' High risk: a yes needs your PIN.' : ''}`;
|
|
272
383
|
const advice = bareLoose
|
|
273
384
|
? `${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}).` : ''}`
|
|
385
|
+
: rec && (loose || TEST_EDIT_RE.test(String(it.title || '')))
|
|
386
|
+
? looseAdvice(p)
|
|
274
387
|
: rec
|
|
275
388
|
? `I would ${yn ? (rec.key === 'y' ? 'say yes' : 'say no') : `go with ${optWord(rec)} (${rec.key})`}. The asker recommended it.`
|
|
276
389
|
: `${who} gave no recommendation, and I will not guess one.${dflt ? ` If nobody answers it would take ${optWord(dflt)} (${dflt.key}).` : ''}`;
|
|
@@ -280,11 +393,15 @@ export function walkCard(it, i, total, { cols = 0 } = {}) {
|
|
|
280
393
|
{ key: keyClash('s') ? 'skip' : 's', label: 'Skip for now', defer: true },
|
|
281
394
|
{ key: keyClash('?') ? 'ask' : '?', label: 'Ask about it', ask: true },
|
|
282
395
|
];
|
|
396
|
+
const cmdFact = it.__approval ? commandOf(it) : null; // the same facts the single approval card shows (approvalFacts), here in full
|
|
397
|
+
const whereFact = it.__approval ? whereText(it, { all: true }) : null;
|
|
283
398
|
const lines = [
|
|
284
|
-
|
|
399
|
+
`${p.__approval ? 'Approval' : 'Decision'} ${i} of ${total} · ${who} asks`,
|
|
285
400
|
...hang('', cleanTitle(it), cols).map(l => l.slice(8)),
|
|
286
401
|
...hang('WHAT', what, cols),
|
|
287
|
-
...(
|
|
402
|
+
...(cmdFact ? commandRows(cmdFact.kind === 'input' ? 'INPUT' : 'COMMAND', cmdFact.text, cols) : []),
|
|
403
|
+
...(whereFact ? hang('WHERE', whereFact, cols) : []),
|
|
404
|
+
...(why ? hangBlock('WHY', why, cols) : []),
|
|
288
405
|
...(loose || just ? hang('JUSTIFY', just || NO_JUSTIFICATION, cols) : []),
|
|
289
406
|
...(!why && !just && !loose ? hang('WHY', NO_REASON, cols) : []),
|
|
290
407
|
...hang('ADVICE', advice, cols),
|
|
@@ -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
|
+
}
|