quoxcli 0.4.0 → 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/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/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- 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/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 +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 +5 -2
- package/lib/commands/offload.js +2 -3
- package/lib/commands/plan.js +17 -0
- 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/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/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 +8 -0
- package/lib/core/config.js +28 -2
- 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 +37 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +1 -1
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -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 +185 -34
- package/lib/quoxon/app.js +1032 -152
- 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 +69 -11
- 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 +7 -0
- package/lib/quoxon/commands.js +40 -10
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +71 -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 +43 -7
- package/lib/quoxon/extensions.js +398 -38
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +5 -1
- 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 +20 -5
- 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/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 +63 -14
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -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/cliLeaves.json +63 -1
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +22 -1
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/restart.js +7 -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 +17 -0
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -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/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +36 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- 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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { readFileSync, appendFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
8
|
import { join, dirname } from 'node:path';
|
|
9
|
+
import { redactSecrets } from './secretShape.js';
|
|
9
10
|
|
|
10
11
|
export const HISTORY_CAP = 500;
|
|
11
12
|
export const defaultHistoryPath = (home = homedir()) => join(home, '.quox', 'history.jsonl');
|
|
@@ -22,7 +23,8 @@ export function fileHistory(path = defaultHistoryPath(), { cap = HISTORY_CAP } =
|
|
|
22
23
|
return out.slice(-cap);
|
|
23
24
|
} catch { return []; }
|
|
24
25
|
};
|
|
25
|
-
const add = (
|
|
26
|
+
const add = (text0) => {
|
|
27
|
+
const text = redactSecrets(text0); // SECRET-GUARD: a value never reaches the file
|
|
26
28
|
try {
|
|
27
29
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
28
30
|
appendFileSync(path, JSON.stringify({ t: text }) + '\n', { mode: 0o600 });
|
package/lib/quoxon/render.js
CHANGED
|
@@ -30,6 +30,7 @@ import { Markdown, wrapCell, markdownRows, wrapRows, WText, Heading } from './ma
|
|
|
30
30
|
import { parseAsk, keysLabel, askText } from './ask.js';
|
|
31
31
|
import { parseAsideNote } from './asideView.js';
|
|
32
32
|
import { formatSlotBody, inlineable, codeRows } from './slotFormat.js';
|
|
33
|
+
import { BACKGROUND_NOTE } from './backgroundWork.js';
|
|
33
34
|
import { termRows } from './term.js';
|
|
34
35
|
import { cellWidth, ambiguousWide } from './cells.js';
|
|
35
36
|
import { welcomeModel } from './welcome.js';
|
|
@@ -733,10 +734,12 @@ function TipView({ item, cols }) {
|
|
|
733
734
|
const body = wrapPlain(item.text, inner).map((l, i) => row(`t${i}`, [h(Text, { key: 0, color: c('prose') }, l)], [...l].length));
|
|
734
735
|
const cmdText = clip(item.cmd, inner - 5);
|
|
735
736
|
const tryRow = row('try', [h(Text, { key: 0, color: c('prose'), bold: true }, 'Try: '), h(Text, { key: 1, color: c('command') }, cmdText)], 5 + [...cmdText].length);
|
|
736
|
-
const
|
|
737
|
+
const hintPlain = stripMarks(TIP_HINT); // too long for the frame: wrap it inside, never let the terminal wrap the border
|
|
738
|
+
const hintRows = [...hintPlain].length <= inner ? [row('hint', runs(paintRuns(TIP_HINT, 'quiet')), [...hintPlain].length)]
|
|
739
|
+
: wrapPlain(hintPlain, inner).map((l, i) => row(`hint${i}`, [h(Text, { key: 0, color: c('quiet') }, l)], [...l].length));
|
|
737
740
|
return h(Box, { paddingLeft: 2, flexDirection: 'column' },
|
|
738
741
|
h(Text, edge, '╭─ ', h(Text, { bold: true }, 'Tip'), ' ', '─'.repeat(W - 8), '╮'),
|
|
739
|
-
...body, tryRow,
|
|
742
|
+
...body, tryRow, ...hintRows,
|
|
740
743
|
h(Text, edge, '╰', '─'.repeat(W - 2), '╯'));
|
|
741
744
|
}
|
|
742
745
|
|
|
@@ -764,9 +767,13 @@ function WhatsNewView({ item, cols }) {
|
|
|
764
767
|
return out;
|
|
765
768
|
});
|
|
766
769
|
if (more) rows.push(row('more', [h(Text, { key: 0, color: c('prose') }, lead), h(Text, { key: 1, color: c('prose'), bold: true }, 'Try: '), h(Text, { key: 2, color: c('command') }, '/whatsnew')], [...more].length));
|
|
770
|
+
// a head that does not fit in the top edge (the compact "N new commands (...)" line at a narrow width) goes inside the
|
|
771
|
+
// frame, wrapped, under a bare top edge: the terminal must never be the one to wrap a border row
|
|
772
|
+
const headFits = [...HEAD].length <= W - 6;
|
|
773
|
+
if (!headFits) rows.unshift(...wrapPlain(HEAD, inner).map((l, i) => row(`h${i}`, [h(Text, { key: 0, color: c('prose'), bold: true }, l)], [...l].length)));
|
|
767
774
|
if (item.pad) { rows.unshift(row('p0', [], 0)); rows.push(row('p1', [], 0)); }
|
|
768
775
|
return h(Box, { paddingLeft: 2, flexDirection: 'column' },
|
|
769
|
-
h(Text, edge, '╭─ ', h(Text, { bold: true }, HEAD), ' ', '─'.repeat(
|
|
776
|
+
headFits ? h(Text, edge, '╭─ ', h(Text, { bold: true }, HEAD), ' ', '─'.repeat(W - 5 - [...HEAD].length), '╮') : h(Text, edge, '╭', '─'.repeat(W - 2), '╮'),
|
|
770
777
|
...rows,
|
|
771
778
|
h(Text, edge, '╰', '─'.repeat(W - 2), '╯'));
|
|
772
779
|
}
|
|
@@ -843,6 +850,12 @@ export function modelRuleLabel(cols, model, reserve = 0) {
|
|
|
843
850
|
return reserve ? '' : clip(name, room); // with the approvals indicator the model is dropped before the indicator is cut
|
|
844
851
|
}
|
|
845
852
|
|
|
853
|
+
/** Pure: a failed approvals poll goes stale (footer `?`) only after 3 consecutive misses or 60 s without a good read. */
|
|
854
|
+
export function approvalsPollStale({ fails, lastOkAt, now }) {
|
|
855
|
+
if (!(fails > 0)) return false;
|
|
856
|
+
return fails >= 3 || (typeof lastOkAt === 'number' && now - lastOkAt > 60000);
|
|
857
|
+
}
|
|
858
|
+
|
|
846
859
|
/**
|
|
847
860
|
* Pure: the permanent approvals indicator on the bottom rule. `a` = { count, error, signedOut }.
|
|
848
861
|
* Never a false 0: signed out, an unreachable inbox, or a count not read yet is `approvals ?` (warn).
|
|
@@ -850,9 +863,20 @@ export function modelRuleLabel(cols, model, reserve = 0) {
|
|
|
850
863
|
export function approvalsIndicator(a) {
|
|
851
864
|
if (!a) return null;
|
|
852
865
|
const n = a.count;
|
|
866
|
+
if (!a.signedOut && a.error && a.slow) return { text: 'approvals ? (server slow)', role: 'warn' }; // sustained failure, never an all-clear
|
|
853
867
|
if (a.signedOut || a.error || typeof n !== 'number') return { text: 'approvals ?', role: 'warn' };
|
|
868
|
+
// Never an all-clear next to outstanding items or an unseen source: scope the claim to approvals and say what else is open.
|
|
869
|
+
const out = Number(a.outstanding) || 0, blind = Number(a.blind) || 0;
|
|
870
|
+
if (n === 0 && (out > 0 || blind > 0)) {
|
|
871
|
+
const more = [out > 0 ? `${out} other item${out === 1 ? '' : 's'} open` : null, blind > 0 ? `can't see ${blind} source${blind === 1 ? '' : 's'}` : null].filter(Boolean).join(' · ');
|
|
872
|
+
return { text: `no approvals in this view · ${more}`, role: 'warn' };
|
|
873
|
+
}
|
|
874
|
+
// Other orgs the user belongs to (cached counts, core/crossOrgApprovals): never an all-clear beside a waiting approval elsewhere.
|
|
875
|
+
const others = (Array.isArray(a.others) ? a.others : []).filter((o) => o && o.count > 0);
|
|
876
|
+
const elsewhere = others.reduce((t, o) => t + o.count, 0);
|
|
877
|
+
if (n === 0 && elsewhere > 0) return { text: others.length === 1 ? `⏸ ${elsewhere} in ${others[0].name} · /approvals` : `⏸ ${elsewhere} in ${others.length} other orgs · /approvals`, role: 'warn' };
|
|
854
878
|
if (n === 0) return { text: '✔ nothing waiting', role: 'ok' };
|
|
855
|
-
return { text: `⏸ ${n} approval${n === 1 ? '' : 's'} waiting`, role: 'warn' };
|
|
879
|
+
return { text: `⏸ ${n} approval${n === 1 ? '' : 's'} waiting${elsewhere ? ` · +${elsewhere} in ${others.length === 1 ? others[0].name : `${others.length} other orgs`}` : ''}`, role: 'warn' };
|
|
856
880
|
}
|
|
857
881
|
|
|
858
882
|
/** Pure: the labels for this width. The indicator sits top-left, the model bottom-right: independent of each other. */
|
|
@@ -1031,11 +1055,12 @@ export function AskFoot({ footer }) {
|
|
|
1031
1055
|
return h(Box, { paddingLeft: 2 }, h(Box, { width: 2, flexShrink: 0 }, h(Text, { bold: true }, ...runs(paintLine('▶ ', 'ask')))), h(Box, { flexGrow: 1, flexShrink: 1 }, h(Text, { color: c('prose'), bold: true, wrap: 'wrap' }, ...runs(paintLine(f.slice(2), 'ask')))));
|
|
1032
1056
|
}
|
|
1033
1057
|
const footRows = (footer, cols) => (HANG_ASK.test(footer) ? wrapRows(footer.slice(2), cols - 4) : wrapRows(footer, cols - 2));
|
|
1034
|
-
export function NeedsYouCard({ cols, title, rows = [], block = [], notes = [], footer, arming = null, motionE = null }) {
|
|
1058
|
+
export function NeedsYouCard({ cols, title, rows = [], block = [], notes = [], footer, arming = null, motionE = null, lead = null }) {
|
|
1035
1059
|
const e = motionE;
|
|
1036
1060
|
return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
|
|
1037
1061
|
e == null ? h(Rule, { cols, color: 'warn' }) : h(DrawRule, { cols, color: 'warn', e }),
|
|
1038
1062
|
e == null ? h(Text, { color: c('warn'), bold: true, wrap: 'wrap' }, title) : h(Text, null, ...fxNodes([{ t: title, color: 'warn', bold: true }], { e: e - 0.1, timing: TIMING.title }, 23)),
|
|
1063
|
+
lead ? h(Text, { color: c('warn'), bold: true, wrap: 'wrap' }, ` ${lead}`) : null, // a destructive approval leads with what it deletes
|
|
1039
1064
|
// APPROVAL-WHAT: wrap at word boundaries under the label instead of cutting the action or target off
|
|
1040
1065
|
...rows.flatMap(([k, v], ri) => wrapPlain(String(v), Math.max(10, cols - 10)).slice(0, 8).map((line, i) => h(Text, { key: `${ri}${k}${i}`, color: c('text') }, ' ', h(Text, { color: c('label') }, (i ? '' : k).padEnd(7)), h(Text, { color: c(k === 'risk' ? 'warn' : 'value') }, line)))),
|
|
1041
1066
|
// block: pre-shaped segment rows shown verbatim (a command to run: never word-wrapped, never clipped)
|
|
@@ -1068,7 +1093,16 @@ export const askCardRows = (text, cols) => {
|
|
|
1068
1093
|
return 4 + wrapRows(m.title, cols) + m.rows.reduce((n, [, v]) => n + Math.min(8, wrapPlain(String(v), Math.max(10, cols - 10)).length), 0) + footRows(m.footer, cols);
|
|
1069
1094
|
};
|
|
1070
1095
|
|
|
1071
|
-
|
|
1096
|
+
/** APPROVAL-BURST: one card for 2+ approvals from one agent for one tool (approvalBatch.batchModel). */
|
|
1097
|
+
export function BatchCard({ model, cols, armed, motionE = null }) {
|
|
1098
|
+
const e = motionE;
|
|
1099
|
+
const block = model.lines.map((l) => [{ t: `• ${clip(l, Math.max(10, cols - 8))}`, role: 'text' }]);
|
|
1100
|
+
if (model.more) block.push([{ t: model.more, role: 'dim' }]);
|
|
1101
|
+
const footer = askText(`Approve all ${model.n}?`, { keys: model.keys });
|
|
1102
|
+
return h(NeedsYouCard, { cols, title: model.title, rows: [], block, notes: model.notes, footer, arming: armed ? null : (e == null ? h(Text, { color: c('dim') }, ' arming…') : null), motionE: e });
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE = null, confirm = false, position = null, signedOut = false }) {
|
|
1072
1106
|
const when = card.askedAt ? String(card.askedAt).replace('T', ' ').slice(0, 16) + ' UTC' : null;
|
|
1073
1107
|
const rows = card.undescribable ? [['what', card.what], ['from', card.askedBy || card.origin], ['asked', when], ['org', org]].filter(r => r[1]) : [
|
|
1074
1108
|
['what', card.what && card.what !== card.tool ? card.what : null], ['tool', card.tool], ['target', card.target], ['scope', card.scope], ['risk', card.risk], ['from', card.foreign ? card.origin : null], ['org', card.plan && (card.plan.orgName || card.plan.orgId) ? orgLabel(card.plan.orgName, card.plan.orgId) : org]
|
|
@@ -1082,8 +1116,9 @@ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE
|
|
|
1082
1116
|
const f = card.facts || null; const block = [];
|
|
1083
1117
|
if (f && !card.undescribable) {
|
|
1084
1118
|
if (f.where && /^\d+ (hosts|targets):/.test(f.where)) { for (const k of ['target', 'what']) { const i = rows.findIndex(r => r[0] === k && (k === 'target' || String(r[1]).includes(f.hostList[1]))); if (i >= 0) rows.splice(i, 1); } }
|
|
1119
|
+
if (f.background) { const i = rows.findIndex(r => r[0] === 'what'); const note = BACKGROUND_NOTE[0].toUpperCase() + BACKGROUND_NOTE.slice(1); if (i >= 0) rows[i] = ['what', `${rows[i][1]}. ${note}`]; else rows.unshift(['what', note]); }
|
|
1085
1120
|
if (f.command) { block.push([{ t: f.command.kind === 'input' ? 'input:' : 'command:', role: 'label' }]); block.push(...codeRows(f.command.shown.split('\n'), cols - 4)); }
|
|
1086
|
-
for (const r of [['where', f.where], ['who', f.who], ['why', f.why], ['asked', [f.asked && `"${f.asked}"
|
|
1121
|
+
for (const r of [['where', f.where], ['who', f.who], ['why', f.why], ['asked', [f.asked && (f.askedRich ? f.asked : `"${f.asked}"`), f.when].filter(Boolean).join(' · ') || null], ['class', f.access]]) if (r[1]) rows.push(r);
|
|
1087
1122
|
}
|
|
1088
1123
|
const e = motionE;
|
|
1089
1124
|
const title = `⚠ Approval needed${position && position.n > 1 ? ` (${position.i} of ${position.n})` : ''}: execution is held`;
|
|
@@ -1091,12 +1126,15 @@ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE
|
|
|
1091
1126
|
const arming = e == null || mono()
|
|
1092
1127
|
? h(Text, { color: c('dim') }, e == null ? ' arming…' : ` arming… ${'▰'.repeat(filled)}${'▱'.repeat(ARM_BAR - filled)}`)
|
|
1093
1128
|
: h(Text, null, h(Text, { color: c('dim') }, ' arming… '), h(Text, { color: c('warn') }, '▰'.repeat(filled)), h(Text, { color: c('faint') }, '▱'.repeat(ARM_BAR - filled)));
|
|
1094
|
-
const footer =
|
|
1129
|
+
const footer = signedOut ? askText('Read-only while signed out. Sign in to answer?', { keys: 'y signs in' })
|
|
1130
|
+
: card.privileged && confirm ? `▶ Grant ${[card.tool, card.target].filter(Boolean).join(' ')}? press y again to confirm, any other key cancels`
|
|
1095
1131
|
: card.undescribable && confirm ? '▶ Quox could not describe this action. Approve it anyway? press y again to confirm, any other key cancels'
|
|
1132
|
+
: draftHint === 'reason' ? askText('Why deny it?', { keys: 'type a reason · Enter denies · Esc goes back to y/n' })
|
|
1096
1133
|
: draftHint === 'focus' ? askText('Approve it?', { keys: 'y approve · n deny · card focused (Esc returns to your draft)' }) // RESTORE-TUI: every y/n goes through askText (same text)
|
|
1097
1134
|
: draftHint === 'draft' ? askText('Approve it?', { keys: 'y/n answer once your draft is empty · Tab to answer the approval card (your draft is kept)' })
|
|
1098
|
-
: '▶
|
|
1099
|
-
|
|
1135
|
+
: card.destructive ? '▶ Delete it? y then type the name · n deny · c chat · Tab later · /approvals all'
|
|
1136
|
+
: '▶ Approve it? y approve · n deny · c chat · Tab later · /approvals all';
|
|
1137
|
+
return h(NeedsYouCard, { cols, title, rows, block, lead: card.destructive ? `DELETES ${card.destructive.label}` : null, notes: [...(f && f.askedNone ? ['nothing in your request asks for this'] : []), ...(card.undescribable && !confirm ? ['Quox could not describe this action'] : []), ...(position && position.others > 0 ? [`other sessions: ${position.others} waiting · /approvals`] : [])], footer, arming: armed ? null : arming, motionE: e });
|
|
1100
1138
|
}
|
|
1101
1139
|
|
|
1102
1140
|
/** Columns the status line's Q takes: 2 for the Braille glyph, 1 for the Ⓠ fallback (2 where Ambiguous is wide). */
|
|
@@ -1221,11 +1259,22 @@ function hintNodes(hint) {
|
|
|
1221
1259
|
}
|
|
1222
1260
|
|
|
1223
1261
|
/** Live, actionable counts under the status line. Never a transcript message. */
|
|
1224
|
-
export function NotifyBar({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false, inbox = '' }) {
|
|
1262
|
+
export function NotifyBar({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false, inbox = '', spinner = '', away = false, awayCount = 0, dnd = false, dndCount = 0, awaySince = 0, dndUntil = 0, awayUnknown = false }) {
|
|
1263
|
+
const hm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
|
|
1264
|
+
if (away && dnd && !signedOut) { // DND: one persistent quiet line, shown even at 0 waiting so the mode is never invisible
|
|
1265
|
+
const head = dndUntil ? `🌙 do not disturb until ${hm(dndUntil)}` : '🌙 do not disturb';
|
|
1266
|
+
return h(Text, { color: c('ask'), bold: true }, ' ' + clip(`${head}${dndCount ? ` · ${dndCount} waiting` : ''} · /morningreport`, cols - 3));
|
|
1267
|
+
}
|
|
1268
|
+
if (away && !signedOut) { // AFK-QUIET-CARDS: away shows one quiet line, never a prompt
|
|
1269
|
+
const n = awayCount || approvals || 0;
|
|
1270
|
+
const head = awaySince ? `🌙 away since ${hm(awaySince)} · /back` : awayUnknown ? '🌙 away (since before this window opened) · /back' : '🌙 away · /back';
|
|
1271
|
+
return h(Text, { color: c('ask'), bold: true }, ' ' + clip(n ? `⏸ ${n} approval${n === 1 ? '' : 's'} waiting · /back to review` : head, cols - 3)); // >0 keeps the existing wording (asserted elsewhere)
|
|
1272
|
+
}
|
|
1225
1273
|
const base = NotifyBarBase({ approvals, error, cols, signedOut, alerts, hideCount });
|
|
1226
|
-
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1274
|
+
const line = inbox && !signedOut ? h(Text, { key: 'inbox', color: c('ask'), bold: true }, ' ' + clip(inbox, cols - 3)) : null; // HITL-INBOX-BAR: 📨 N new · newest title
|
|
1275
|
+
const spin = spinner ? h(Text, { key: 'spin', color: c('ask') }, ' ' + clip(spinner, cols - 3)) : null; // INSTANT-FEEDBACK: ⠋ <verb>…
|
|
1276
|
+
const parts = [base, line, spin].filter(Boolean);
|
|
1277
|
+
return parts.length > 1 ? h(Box, { flexDirection: 'column' }, ...parts) : (parts[0] || base);
|
|
1229
1278
|
}
|
|
1230
1279
|
function NotifyBarBase({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false }) {
|
|
1231
1280
|
if (signedOut) return h(Text, { color: c('warn') }, ' ' + clip('not signed in: approvals unavailable until you sign in', cols - 2));
|
package/lib/quoxon/roster.js
CHANGED
|
@@ -258,14 +258,14 @@ export function countsLine(rows) {
|
|
|
258
258
|
* one row each plus a summary line (owner, live 05:29): the bottom area must never grow with the inbox. A single approval
|
|
259
259
|
* keeps its own row. Focus (the user is walking the roster) shows the real rows so selection stays truthful.
|
|
260
260
|
*/
|
|
261
|
-
export function collapseApprovals(rows, now = Date.now()) {
|
|
261
|
+
export function collapseApprovals(rows, now = Date.now(), staleAt = null) {
|
|
262
262
|
const aps = rows.filter(r => r.kind === 'approval' && r.state === 'waiting');
|
|
263
|
-
if (aps.length < 2) return rows;
|
|
263
|
+
if (aps.length < (staleAt ? 1 : 2)) return rows;
|
|
264
264
|
const started = aps.map(r => r.startedAt).filter(x => x != null);
|
|
265
265
|
const newest = aps.reduce((a, b) => ((b.startedAt ?? -Infinity) > (a.startedAt ?? -Infinity) ? b : a));
|
|
266
266
|
const one = { key: 'approval:*', id: '*', source: 'approvals', kind: 'approval', state: 'waiting', background: false, collapsed: true, count: aps.length,
|
|
267
267
|
name: `${aps.length} approvals waiting`, activity: '/approvals', status: 'pending', startedAt: started.length ? Math.min(...started) : null, finishedAt: null,
|
|
268
|
-
oldestMs: started.length ? now - Math.min(...started) : null, newestTitle: String(newest.detail?.description || newest.name || ''), detail: {} };
|
|
268
|
+
oldestMs: started.length ? now - Math.min(...started) : null, staleAt: staleAt || null, newestTitle: String(newest.detail?.description || newest.name || ''), detail: {} };
|
|
269
269
|
let placed = false;
|
|
270
270
|
return rows.flatMap(r => (aps.includes(r) ? (placed ? [] : (placed = true, [one])) : [r]));
|
|
271
271
|
}
|
|
@@ -273,9 +273,9 @@ export function collapseApprovals(rows, now = Date.now()) {
|
|
|
273
273
|
const KIND_ICON = { loop: '∞', objective: '◎' }; // QuoxGlyph registry keys qloop / objective
|
|
274
274
|
export const kindLabel = k => (KIND_ICON[k] ? `${KIND_ICON[k]} ${k}` : k);
|
|
275
275
|
|
|
276
|
-
export function rosterView({ rows, cols, termRows = 40, max = MAX_ROWS, on = true, sel = 0, focus = false, now = Date.now(), motion = false }) {
|
|
276
|
+
export function rosterView({ rows, cols, termRows = 40, max = MAX_ROWS, on = true, sel = 0, focus = false, now = Date.now(), motion = false, staleAt = null }) {
|
|
277
277
|
if (!on || !rows.length) return { mode: 'hidden', lines: [], more: 0 };
|
|
278
|
-
if (!focus) rows = collapseApprovals(rows, now);
|
|
278
|
+
if (!focus || staleAt) rows = collapseApprovals(rows, now, staleAt); // signed out: the cached count is last-known, always one stale row
|
|
279
279
|
if (cols < MIN_FULL_COLS || termRows < MIN_FULL_ROWS) {
|
|
280
280
|
return { mode: 'summary', lines: [], more: 0, summary: clip(' ' + countsLine(rows) + ' · /roster', cols - 1) };
|
|
281
281
|
}
|
|
@@ -293,6 +293,11 @@ export function rosterView({ rows, cols, termRows = 40, max = MAX_ROWS, on = tru
|
|
|
293
293
|
const nameW = Math.max(8, cols - 2 - fixed - 1);
|
|
294
294
|
const name = clip(r.name, nameW).padEnd(nameW);
|
|
295
295
|
const marker = focus && i === sel ? '›' : ' ';
|
|
296
|
+
if (r.collapsed && r.staleAt) { // signed out: last-known count with its time, never worded as live (owner live 2026-10-27)
|
|
297
|
+
const d = new Date(r.staleAt), at = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
298
|
+
const text = clip(`${r.count} waiting as of ${at} · sign in to see more: /login`, cols - 4);
|
|
299
|
+
return { key: r.key, kind: 'approval', state: r.state, tone: 'dim', selected: false, marker: ' ', glyph: GLYPH[r.state], rest: ' ' + text, pulse: '', pulseRole: 'dim', pulseKind: 'none', text: ` ${GLYPH[r.state]} ${text}` };
|
|
300
|
+
}
|
|
296
301
|
if (r.collapsed) { // one summary row: count, oldest age, newest title, where to go; clipped to the line
|
|
297
302
|
const head = `${r.count} approvals waiting · oldest ${elapsed(r.oldestMs)} · newest: `, tail = ' · /approvals';
|
|
298
303
|
const room = cols - 5 - [...head].length - [...tail].length;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /roster text for the two things that are not work rows: attached agents (org lease list) and this box's live
|
|
3
|
+
* `claude --resume` processes. Pure; the process list and the attach list are injected by the caller.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const age = (sec) => {
|
|
7
|
+
const s = Math.max(0, Math.round(sec));
|
|
8
|
+
if (s < 60) return `${s}s`;
|
|
9
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
10
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
11
|
+
return `${Math.floor(s / 86400)}d`;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const sinceText = (iso, now) => {
|
|
15
|
+
const t = Date.parse(iso || '');
|
|
16
|
+
return Number.isNaN(t) ? '?' : age((now - t) / 1000);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** attached agents -> one line each: agent, splinter, session, lease id, heartbeat age, expiry. */
|
|
20
|
+
export function attachLines(items, now = Date.now()) {
|
|
21
|
+
if (!items || !items.length) return [' attached agents: none'];
|
|
22
|
+
return items.map((a) => ` ${a.agent_id} ${a.splinter || 'S1'} · ${a.session_label || '(no label)'} · lease ${a.lease_id} · heartbeat ${sinceText(a.last_heartbeat_at, now)} ago · expires ${a.expires_at || '?'}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const where = (p) => (p.tmux ? `tmux ${p.tmux}` : `not in tmux${p.tty && p.tty !== '?' ? ` ${p.tty}` : ''}`);
|
|
26
|
+
const one = (p) => `${p.pid} ${where(p)} ${age(p.ageSec || 0)}`;
|
|
27
|
+
|
|
28
|
+
/** live claude processes -> lines, grouped by name case-insensitively; a name held more than once is flagged. */
|
|
29
|
+
export function sessionLines(procs) {
|
|
30
|
+
if (!procs || !procs.length) return [" this box's sessions: none"];
|
|
31
|
+
const groups = new Map();
|
|
32
|
+
for (const p of procs) {
|
|
33
|
+
const k = String(p.name).toLowerCase();
|
|
34
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
35
|
+
groups.get(k).push(p);
|
|
36
|
+
}
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const g of groups.values()) {
|
|
39
|
+
const name = g[0].name;
|
|
40
|
+
if (g.length === 1) out.push(` ${name}: ${one(g[0])}`);
|
|
41
|
+
else out.push(` ⚠ ${name} x${g.length}: ${g.map(one).join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
@@ -48,7 +48,7 @@ const age = (ms) => {
|
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
/** A line the user cannot have meant as a command they want repeated (PIN-bearing verbs): name only. */
|
|
51
|
-
const PIN_VERBS = /^\/(authenticate|authorise|authorize|pin|login|connect|key)\b/i;
|
|
51
|
+
const PIN_VERBS = /^\/(authenticate|authorise|authorize|pin|login|connect|key|verify-email)\b/i;
|
|
52
52
|
|
|
53
53
|
function whoSection(w) {
|
|
54
54
|
const bits = [];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUOXON-SECRET-GUARD: one detector for secret-shaped text, shared by the composer (offer before send), the
|
|
3
|
+
* history writer and the transcript writer (redact before write). Low false positives on purpose: a bare number
|
|
4
|
+
* never matches unless a keyword sits next to it, or it is a Luhn-valid card number.
|
|
5
|
+
*/
|
|
6
|
+
const REDACTED = '[REDACTED';
|
|
7
|
+
|
|
8
|
+
const luhn = (digits) => {
|
|
9
|
+
let sum = 0, dbl = false;
|
|
10
|
+
for (let i = digits.length - 1; i >= 0; i--) { let d = digits.charCodeAt(i) - 48; if (dbl) { d *= 2; if (d > 9) d -= 9; } sum += d; dbl = !dbl; }
|
|
11
|
+
return sum % 10 === 0;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// keyword, then a value: after ":" or "=" anything non-space; after plain space/"is" only a value carrying a digit (4+ chars)
|
|
15
|
+
const KEYWORDS = [
|
|
16
|
+
['pin', 'pin'], ['passcode', 'pass\\s?code'], ['password', 'password|passwd|pass\\s?word'],
|
|
17
|
+
['api-key', 'api[ _-]?key'], ['secret', 'secret'], ['token', 'token']
|
|
18
|
+
];
|
|
19
|
+
const kwRe = (kw) => new RegExp(`\\b(?:${kw})\\b(?:[ \\t]+(?:number|no\\.?|code))?(?:[ \\t]*[:=][ \\t]*["']?([^\\s"',;]+)|[ \\t]+(?:is[ \\t]+)?["']?([^\\s"',;]*\\d[^\\s"',;]*))`, 'gi');
|
|
20
|
+
|
|
21
|
+
const ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
|
|
22
|
+
const trimVal = (v) => v.replace(/^[(\[`<*_]+/, '').replace(/[)\]`>*_.,;:!?]+$/, '');
|
|
23
|
+
/**
|
|
24
|
+
* Precision mode: is a keyword-adjacent value really secret-shaped, or just prose ("PIN ask", "token budget",
|
|
25
|
+
* a ULID, a path, a placeholder)? pin/passcode need a 4-8 digit run; the others need a 8+ char value that mixes
|
|
26
|
+
* letters and digits (or carries a symbol) and is not an id, path, url, placeholder or short hex.
|
|
27
|
+
*/
|
|
28
|
+
function preciseValueOk(kind, raw) {
|
|
29
|
+
if (/^[<$\{\[~\/]|^[a-z]+:\/\/|\.\.\.|^\$|^\.{0,2}\//i.test(raw)) return false;
|
|
30
|
+
const v = trimVal(raw);
|
|
31
|
+
if (kind === 'pin' || kind === 'passcode') return /^\d{4,8}$/.test(v);
|
|
32
|
+
if (v.length < 8 || ULID.test(v) || /^[0-9a-f]{7,12}$/i.test(v) || /^\d{4}-\d{2}-\d{2}/.test(v)) return false;
|
|
33
|
+
if (/[<>{}()/\\]|^\W/.test(v) && !/^[\w!@#$%^&*+=.-]+$/.test(v)) return false;
|
|
34
|
+
return /[A-Za-z]/.test(v) && /\d/.test(v);
|
|
35
|
+
}
|
|
36
|
+
const CARD_FORM = /^(?:\d{13,19}|\d{4}(?:[ -]\d{4}){2,3}(?:[ -]\d{1,3})?|\d{4}[ -]\d{6}[ -]\d{5})$/;
|
|
37
|
+
|
|
38
|
+
/** -> [{ kind, start, end }] value spans, sorted, non-overlapping. opts.precise = memory-sync mode (words are not secrets). */
|
|
39
|
+
export function findSecrets(text, opts = {}) {
|
|
40
|
+
const s = String(text ?? '');
|
|
41
|
+
const precise = !!opts.precise;
|
|
42
|
+
const hits = [];
|
|
43
|
+
const add = (kind, start, end) => { if (!/^\[redacted/i.test(s.slice(start, end))) hits.push({ kind, start, end }); };
|
|
44
|
+
const grp = (m, i) => { const v = m[i]; const at = m.index + m[0].lastIndexOf(v); return [at, at + v.length]; };
|
|
45
|
+
for (const [kind, kw] of KEYWORDS) {
|
|
46
|
+
for (const m of s.matchAll(kwRe(kw))) {
|
|
47
|
+
const i = m[1] ? 1 : 2;
|
|
48
|
+
if (i === 2 && m[2].length < 4) continue;
|
|
49
|
+
if (i === 1 && m[1].length < 2) continue;
|
|
50
|
+
if (precise && !preciseValueOk(kind, m[i])) continue;
|
|
51
|
+
add(kind, ...grp(m, i));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const m of s.matchAll(/sort[ \t]*code[^\d\n]{0,15}(\d{2}-?\d{2}-?\d{2})(?!\d)/gi)) add('sort-code', ...grp(m, 1));
|
|
55
|
+
for (const m of s.matchAll(/account(?:[ \t]*(?:number|no\.?|num))?[^\d\n]{0,15}(\d{8})(?!\d)/gi)) add('account-number', ...grp(m, 1));
|
|
56
|
+
for (const m of s.matchAll(/customer[ \t]*(?:number|no\.?|num|id)[^\d\n]{0,15}(\d{6,})(?!\d)/gi)) add('customer-number', ...grp(m, 1));
|
|
57
|
+
for (const m of s.matchAll(/(?<![\d-])\d(?:[ -]?\d){12,18}(?![\d-])/g)) {
|
|
58
|
+
const d = m[0].replace(/[ -]/g, '');
|
|
59
|
+
if (precise) {
|
|
60
|
+
if (!CARD_FORM.test(m[0]) || /[A-Za-z_]/.test(s[m.index - 1] ?? '') || /[A-Za-z_]/.test(s[m.index + m[0].length] ?? '')) continue;
|
|
61
|
+
if (/^\d{4}(?:-\d{4}){3}$/.test(m[0]) && /[0-9a-f]{4,}-$/i.test(s.slice(Math.max(0, m.index - 6), m.index))) continue;
|
|
62
|
+
}
|
|
63
|
+
if (d.length >= 13 && d.length <= 19 && luhn(d)) add('card-number', m.index, m.index + m[0].length);
|
|
64
|
+
}
|
|
65
|
+
for (const m of s.matchAll(/\bsk-[A-Za-z0-9_-]{8,}/g)) if (!precise || m[0].length >= 20) add('api-key', m.index, m.index + m[0].length);
|
|
66
|
+
for (const m of s.matchAll(/\b(?:ghp|gho|ghs)_[A-Za-z0-9]{16,}/g)) add('api-key', m.index, m.index + m[0].length);
|
|
67
|
+
for (const m of s.matchAll(/\bAKIA[0-9A-Z]{16}\b/g)) add('api-key', m.index, m.index + m[0].length);
|
|
68
|
+
for (const m of s.matchAll(/\beyJ[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]+){1,2}/g)) add('jwt', m.index, m.index + m[0].length);
|
|
69
|
+
hits.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const h of hits) { const last = out[out.length - 1]; if (last && h.start < last.end) continue; out.push(h); }
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** -> unique kinds in order of appearance ([] when clean). */
|
|
76
|
+
export const secretKinds = (text, opts) => [...new Set(findSecrets(text, opts).map(h => h.kind))];
|
|
77
|
+
|
|
78
|
+
/** Matched values become [REDACTED:<kind>]; everything else is untouched. */
|
|
79
|
+
export function redactSecrets(text) {
|
|
80
|
+
if (typeof text !== 'string' || !text) return text;
|
|
81
|
+
const hits = findSecrets(text);
|
|
82
|
+
if (!hits.length) return text;
|
|
83
|
+
let out = '', at = 0;
|
|
84
|
+
for (const h of hits) { out += text.slice(at, h.start) + `${REDACTED}:${h.kind}]`; at = h.end; }
|
|
85
|
+
return out + text.slice(at);
|
|
86
|
+
}
|
|
@@ -101,11 +101,12 @@ export const LOOSE_SAFE = new Set(['help', 'status', 'context', 'cost', 'agents'
|
|
|
101
101
|
export function looseCommand(cmd, restArg) {
|
|
102
102
|
const words = String(restArg || '').trim().split(/\s+/).filter(Boolean);
|
|
103
103
|
for (let i = 0; i < words.length; i++) {
|
|
104
|
+
if (words[i].replace(/^\//, '').length < 2 && !words[i].startsWith('/')) continue; // a bare "r" in prose is not the /r alias
|
|
104
105
|
const e = findEntry(words[i].replace(/^\//, ''));
|
|
105
106
|
if (!e || e.kind !== 'command' || e.status !== 'active') continue;
|
|
106
107
|
const rest = words.slice(i + 1).join(' ');
|
|
107
108
|
if (LOOSE_SAFE.has(e.id)) return { run: `/${e.id}${rest ? ` ${rest}` : ''}`, name: `/${e.id}` };
|
|
108
|
-
return { suggest: `/${e.id}
|
|
109
|
+
return { suggest: `/${e.id}` }; // a command NAME only, never the user's message text
|
|
109
110
|
}
|
|
110
111
|
if (words.length && !suggestCommands(cmd).length) return { ask: `${String(cmd).replace(/^\//, '')} ${words.join(' ')}` };
|
|
111
112
|
return null;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { CHOICES, connectedChoices, providerId } from '../model.js';
|
|
7
7
|
import { liveModels } from '../modelPrefs.js';
|
|
8
8
|
import { pathItems } from '../slotChat.js';
|
|
9
|
+
import { fromOf, activeMutes } from '../inboxView.js';
|
|
9
10
|
|
|
10
11
|
/** Provider ids /connect understands (one per provider; the subscription has no key). */
|
|
11
12
|
export const connectIds = () => [
|
|
@@ -14,7 +15,7 @@ export const connectIds = () => [
|
|
|
14
15
|
).values(),
|
|
15
16
|
];
|
|
16
17
|
|
|
17
|
-
export function createArgSources({ api, getPrefs = () => ({ favourites: [], default: null }), getRows = () => [] }) {
|
|
18
|
+
export function createArgSources({ api, getPrefs = () => ({ favourites: [], default: null }), getRows = () => [], getExt = null, getInbox = null }) {
|
|
18
19
|
return async function source(src, param) {
|
|
19
20
|
switch (src) {
|
|
20
21
|
case 'providers':
|
|
@@ -38,12 +39,18 @@ export function createArgSources({ api, getPrefs = () => ({ favourites: [], defa
|
|
|
38
39
|
value: String(i + 1),
|
|
39
40
|
desc: `${f.provider} ${f.model}`,
|
|
40
41
|
}));
|
|
42
|
+
case 'ext.listed': return (getExt?.().listed?.() || []).map((v) => ({ value: v }));
|
|
43
|
+
case 'ext.loaded': return (getExt?.().loaded?.() || []).map((v) => ({ value: v }));
|
|
44
|
+
case 'ext.panels': return [...(getExt?.().panels?.() || []).map((p) => ({ value: p.name, desc: p.title })), { value: 'off', desc: 'collapse the panel region' }];
|
|
45
|
+
case 'ext.keys': return (getExt?.().keys?.(param) || []).map((d) => ({ value: d.key, desc: d.help }));
|
|
46
|
+
case 'inbox.sources': return [...new Set((getInbox?.().list?.() || []).map((i) => fromOf(i).toLowerCase()))].map((v) => ({ value: v }));
|
|
47
|
+
case 'inbox.mutes': return Object.keys(activeMutes(getInbox?.().mutes?.())).map((v) => ({ value: v }));
|
|
41
48
|
case 'rows':
|
|
42
49
|
return (getRows?.() || []).map((r) => ({ value: r.id, desc: r.source.type }));
|
|
43
50
|
case 'orgs':
|
|
44
51
|
return (await api.orgs()).map((o) => ({ value: o.slug || o.id, desc: o.name || '' }));
|
|
45
52
|
case 'agents':
|
|
46
|
-
return (await api.agents()).map((a) => ({
|
|
53
|
+
return [...(await api.agents())].sort((x, y) => String(x.agent_id).localeCompare(String(y.agent_id))).map((a) => ({
|
|
47
54
|
value: a.agent_id,
|
|
48
55
|
desc: a.name && a.name !== a.agent_id ? a.name : '',
|
|
49
56
|
}));
|
package/lib/quoxon/slash/args.js
CHANGED
|
@@ -41,7 +41,7 @@ const asItem = (x) =>
|
|
|
41
41
|
/** Static choices of a level as items. */
|
|
42
42
|
export const staticItems = (lv) => (lv?.choices || []).map(asItem);
|
|
43
43
|
|
|
44
|
-
/** Prefix matches first, then substring; the exact sole match is dropped (nothing to add). */
|
|
44
|
+
/** Prefix matches first, then substring, then display-name matches; the exact sole match is dropped (nothing to add). */
|
|
45
45
|
export function rankArgs(items, partial) {
|
|
46
46
|
const q = String(partial || '').toLowerCase();
|
|
47
47
|
const seen = new Set();
|
|
@@ -51,7 +51,9 @@ export function rankArgs(items, partial) {
|
|
|
51
51
|
const sub = uniq.filter(
|
|
52
52
|
(i) => !i.value.toLowerCase().startsWith(q) && i.value.toLowerCase().includes(q),
|
|
53
53
|
);
|
|
54
|
-
|
|
54
|
+
// A display name (desc) that matches also surfaces the item, after every id match: /attach vulcan finds forge.
|
|
55
|
+
const byDesc = uniq.filter((i) => !pre.includes(i) && !sub.includes(i) && String(i.desc || '').toLowerCase().includes(q));
|
|
56
|
+
const out = [...pre, ...sub, ...byDesc];
|
|
55
57
|
return out.length === 1 && out[0].value.toLowerCase() === q ? [] : out;
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* No I/O. v1 scope: all held slots or --slots, --priority urgent, --ack, /page. Scheduling, voice,
|
|
4
4
|
* fleet and org reach, bulletins and drafting are not built.
|
|
5
5
|
*/
|
|
6
|
-
export const BROADCAST_USAGE = 'Usage: /broadcast [--slots a,b] [--priority urgent] [--ack] <text>';
|
|
6
|
+
export const BROADCAST_USAGE = 'Usage: /broadcast [--slots a,b] [--priority urgent] [--ack] [--active [--dry-run]] <text>';
|
|
7
7
|
export const PAGE_USAGE = 'Usage: /page <slot> <text>';
|
|
8
8
|
const NOT_YET = new Set(['--fleet', '--org', '--voice', '--at']);
|
|
9
9
|
|
|
@@ -13,13 +13,15 @@ const NOT_YET = new Set(['--fleet', '--org', '--voice', '--at']);
|
|
|
13
13
|
*/
|
|
14
14
|
export function parseBroadcastArgs(arg) {
|
|
15
15
|
const toks = String(arg || '').trim().split(/\s+/).filter(Boolean);
|
|
16
|
-
let slots = null; let priority = 'normal'; let ack = false;
|
|
16
|
+
let slots = null; let priority = 'normal'; let ack = false; let active = false; let dryRun = false;
|
|
17
17
|
let i = 0;
|
|
18
18
|
for (; i < toks.length && toks[i].startsWith('--'); i += 1) {
|
|
19
19
|
const [flag, inline] = toks[i].split(/=(.*)/s);
|
|
20
20
|
if (NOT_YET.has(flag)) return { ok: false, message: `${flag} is not available yet (v1 sends to held slots in this org: --slots, --priority, --ack).` };
|
|
21
21
|
if (flag === '--all') { slots = null; continue; }
|
|
22
22
|
if (flag === '--ack') { ack = true; continue; }
|
|
23
|
+
if (flag === '--active') { active = true; continue; }
|
|
24
|
+
if (flag === '--dry-run') { dryRun = true; continue; }
|
|
23
25
|
const val = inline !== undefined ? inline : toks[++i];
|
|
24
26
|
if (flag === '--slots') {
|
|
25
27
|
const list = String(val || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
@@ -34,7 +36,12 @@ export function parseBroadcastArgs(arg) {
|
|
|
34
36
|
}
|
|
35
37
|
const text = toks.slice(i).join(' ');
|
|
36
38
|
if (!text) return { ok: false, message: BROADCAST_USAGE };
|
|
37
|
-
return { ok: true, text, slots, priority, ack };
|
|
39
|
+
return { ok: true, text, slots, priority, ack, ...(active ? { active: true } : {}), ...(dryRun ? { dryRun: true } : {}) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** QuoxGlyph announce line for a broadcast to active sessions: { reached, skipped, dryRun }. */
|
|
43
|
+
export function activeLine({ reached = 0, skipped = 0, dryRun = false } = {}) {
|
|
44
|
+
return `\u{1F4CD} QUOXSLOTS \u2502 broadcast to active \u2502 ${reached} ${dryRun ? 'would reach' : 'reached'} \u00B7 ${skipped} skipped dormant \u2714`;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
/** `/page <slot> <text>` -> urgent, ack-required, one recipient. */
|
|
@@ -72,9 +79,10 @@ export function broadcastCard(r, { kind = 'broadcast' } = {}) {
|
|
|
72
79
|
const c = r?.counts || {};
|
|
73
80
|
const rc = roomC(r);
|
|
74
81
|
const rows = [];
|
|
75
|
-
if (!c.total) rows.push([['\u26A0 no held slots in this org to hear it', 'warn']]);
|
|
82
|
+
if (!c.total) rows.push([[r?.skipped_dormant ? `\u26A0 no active sessions to hear it \u00B7 ${r.skipped_dormant.length} dormant skipped` : '\u26A0 no held slots in this org to hear it', 'warn']]);
|
|
76
83
|
else if (c.delivered) {
|
|
77
|
-
|
|
84
|
+
const act = r?.skipped_dormant ? 'active ' : ''; // --active: say who was left alone
|
|
85
|
+
rows.push([[`\u2714 ${c.delivered} ${act}${c.delivered === 1 ? 'session has' : 'sessions have'} it${r?.skipped_dormant ? ` \u00B7 ${c.dormant_skipped ?? r.skipped_dormant.length} dormant skipped` : ''}`, 'ok']]);
|
|
78
86
|
if (namesRow(r)) rows.push([[namesRow(r), 'prose']]); // an older server sends no per-slot list: no empty row
|
|
79
87
|
}
|
|
80
88
|
for (const d of badSlots(r).slice(0, 3)) rows.push([[`\u2716 ${failText(d)}`, 'error']]);
|
|
@@ -105,7 +113,12 @@ export function broadcastSummary(r) {
|
|
|
105
113
|
const c = r?.counts || {};
|
|
106
114
|
const rc = roomC(r);
|
|
107
115
|
const lines = [];
|
|
116
|
+
if (!c.total && r.skipped_dormant) return [[`Broadcast ${r.id}: no active sessions to hear it \u00B7 ${r.skipped_dormant.length} dormant skipped${r.skipped_dormant.length ? ` (${r.skipped_dormant.join(', ')})` : ''}.`, 'warn']];
|
|
108
117
|
if (!c.total) return [[`Broadcast ${r.id}: no held slots in this org to hear it.`, 'warn']];
|
|
118
|
+
if (r.skipped_dormant) { // --active: say who was left alone
|
|
119
|
+
const dn = r.skipped_dormant;
|
|
120
|
+
lines.push([`Broadcast ${r.id} ${titleWord(r)}: ${(c.delivered || 0) + (c.queued || 0)} active have it${okSlots(r).length ? ` (${okSlots(r).map((d) => d.slot).join(', ')})` : ''} \u00B7 ${dn.length} dormant skipped${dn.length ? ` (${dn.join(', ')})` : ''}`, c.failed ? 'warn' : 'plain']);
|
|
121
|
+
} else
|
|
109
122
|
lines.push([`Broadcast ${r.id} ${titleWord(r)}: ${(c.delivered || 0) + (c.queued || 0)} of ${c.total} ${plural(c.total)} have it${okSlots(r).length ? ` (${okSlots(r).map((d) => d.slot).join(', ')})` : ''}`, c.failed ? 'warn' : 'plain']);
|
|
110
123
|
if (c.delivered && roomText(rc, ', ')) lines.push([` chat rooms: ${roomText(rc, ', ')}`, rc.failed ? 'warn' : 'plain']);
|
|
111
124
|
for (const d of badSlots(r)) lines.push([` ${failText(d)}`, 'warn']);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* One server call fans out (POST /api/v1/slots/broadcast); with --ack the sender then sees the delivery line
|
|
4
4
|
* update as recipients acknowledge. The poll is display only: the broadcast and its acks live on the server.
|
|
5
5
|
*/
|
|
6
|
-
import { parseBroadcastArgs, parsePageArgs, parseAckArgs, parseStatusArgs, broadcastSummary, broadcastCard, BROADCAST_USAGE } from './broadcast.js';
|
|
6
|
+
import { parseBroadcastArgs, parsePageArgs, parseAckArgs, parseStatusArgs, broadcastSummary, broadcastCard, BROADCAST_USAGE, activeLine } from './broadcast.js';
|
|
7
7
|
|
|
8
8
|
const roomC = (r) => ({ sent: 0, sending: 0, failed: 0, ...(r?.counts?.room || {}) });
|
|
9
9
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -22,9 +22,20 @@ export async function runBroadcast({ api, kind, arg, from, say, card = null, isC
|
|
|
22
22
|
}
|
|
23
23
|
const parsed = kind === 'page' ? parsePageArgs(arg) : parseBroadcastArgs(arg);
|
|
24
24
|
if (!parsed.ok) return say(parsed.message, parsed.message === BROADCAST_USAGE ? 'plain' : 'warn');
|
|
25
|
-
const r = await api.slotBroadcast({ text: parsed.text, slots: parsed.slots, priority: parsed.priority, ack: parsed.ack, from });
|
|
25
|
+
const r = await api.slotBroadcast({ text: parsed.text, slots: parsed.slots, priority: parsed.priority, ack: parsed.ack, active: parsed.active, ...(parsed.dryRun ? { dryRun: true } : {}), from });
|
|
26
26
|
if (!r.ok) return say(`${kind === 'page' ? 'Page' : 'Broadcast'} failed: ${r.message}`, 'warn');
|
|
27
|
+
if (r.data?.dry_run) { // BROADCAST-ACTIVE --dry-run: who would hear it, who is dormant; nothing sent, no card
|
|
28
|
+
const would = r.data.would_reach || []; const dorm = r.data.skipped_dormant || [];
|
|
29
|
+
say(activeLine({ reached: would.length, skipped: dorm.length, dryRun: true }), 'ok');
|
|
30
|
+
if (would.length) say(` would reach: ${would.join(', ')}`, 'plain');
|
|
31
|
+
if (dorm.length) say(` skipped dormant: ${dorm.join(', ')}`, 'plain');
|
|
32
|
+
return say('Dry run: nothing was sent.', 'plain');
|
|
33
|
+
}
|
|
27
34
|
show(r.data);
|
|
35
|
+
if (parsed.active && r.data?.skipped_dormant) {
|
|
36
|
+
say(activeLine({ reached: r.data.counts?.delivered || 0, skipped: r.data.skipped_dormant.length }), 'ok');
|
|
37
|
+
if (r.data.skipped_dormant.length) say(` skipped dormant: ${r.data.skipped_dormant.join(', ')}`, 'plain');
|
|
38
|
+
}
|
|
28
39
|
// PRESENCE: it reached a human slot whose human is away; approvals to them will queue.
|
|
29
40
|
if (r.data?.human_presence?.away && r.data.human_presence.notice) say(`⏸ ${r.data.human_presence.notice}`, 'warn');
|
|
30
41
|
// POKE-WAKE: a page also asks each delivered slot's keeper to wake its idle session, so the page is read now,
|