quoxcli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +58 -0
- package/lib/api/endpoints/index.js +4 -0
- package/lib/api/endpoints/lucid.js +17 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +7 -0
- package/lib/commands/lucidGoals.js +389 -0
- package/lib/commands/mail.js +427 -25
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +69 -144
- package/lib/commands/monitoring.js +68 -0
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +617 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +52 -12
- package/lib/commands/plan.js +487 -52
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/volt.js +6 -1
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +880 -0
- package/lib/commands/wazuhIntake.js +358 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +9 -0
- package/lib/core/config.js +42 -3
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +120 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +30 -5
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +189 -34
- package/lib/quoxon/app.js +1277 -188
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +72 -12
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +8 -2
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +8 -1
- package/lib/quoxon/commands.js +42 -12
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +118 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +55 -8
- package/lib/quoxon/extensions.js +400 -38
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +7 -2
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +27 -6
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +68 -16
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +88 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +48 -11
- package/lib/quoxon/slash/broadcastRun.js +24 -7
- package/lib/quoxon/slash/cliLeaves.json +98 -1
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +63 -1
- package/lib/quoxon/slash/paletteController.js +15 -5
- package/lib/quoxon/slash/restart.js +9 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +24 -3
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +42 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +61 -3
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
|
@@ -18,6 +18,7 @@ import * as T from './copy.js';
|
|
|
18
18
|
import { matchIntent, yesNo, parseConnect } from './intent.js';
|
|
19
19
|
import { loadJourney, saveJourney, clearJourney, resumeStep } from './journey.js';
|
|
20
20
|
import { pendingRows, runPreflight, preflightPassed, detectOllama, defaultDeps } from './preflight.js';
|
|
21
|
+
import { probeLocalQuox } from './probe.js';
|
|
21
22
|
import { credentialOptions, KEY_PROVIDERS, normaliseOllamaUrl, nextSelectable } from './credential.js';
|
|
22
23
|
import { needsConsent, providerLabel } from '../consent.js';
|
|
23
24
|
import { HelloIntro, helloIntroMs } from '../qmotion.js';
|
|
@@ -101,8 +102,8 @@ const Hint = ({ text, cols }) => h(Box, { paddingLeft: 2 }, h(Text, { color: c('
|
|
|
101
102
|
|
|
102
103
|
// ---- the view ---------------------------------------------------------------------------------
|
|
103
104
|
export function GettingStarted({
|
|
104
|
-
columns, version = '0.
|
|
105
|
-
journeyPath, dryRun = false, runner = null, preflightDeps, runChecks = runPreflight, findOllama = detectOllama,
|
|
105
|
+
columns, version = '0.6.0', cwd = '', welcome = false, animateIntro, earlyInput = null,
|
|
106
|
+
journeyPath, dryRun = false, runner = null, preflightDeps, runChecks = runPreflight, findLocal = probeLocalQuox, findOllama = detectOllama,
|
|
106
107
|
requestCredential, broker = null, finisher = null, onExit = () => {}, now = () => Date.now()
|
|
107
108
|
}) {
|
|
108
109
|
const { stdin, setRawMode, isRawModeSupported } = useStdin();
|
|
@@ -179,7 +180,7 @@ export function GettingStarted({
|
|
|
179
180
|
|
|
180
181
|
function offerLines() {
|
|
181
182
|
const items = [{ kind: 'guide', text: T.OFFER }];
|
|
182
|
-
items.push({ kind: 'ask', text: T.OFFER_ASK }, { kind: 'dim', text: T.ALREADY });
|
|
183
|
+
items.push({ kind: 'ask', text: T.OFFER_ASK }, { kind: 'dim', text: T.CHOICES }, { kind: 'dim', text: T.ALREADY });
|
|
183
184
|
push(...items);
|
|
184
185
|
set({ step: 'offer' });
|
|
185
186
|
persist();
|
|
@@ -218,6 +219,10 @@ export function GettingStarted({
|
|
|
218
219
|
if (text.startsWith('/')) { onSlash(text); return; }
|
|
219
220
|
if (s.step === 'ask') return onRequest(text);
|
|
220
221
|
if (s.step === 'offer' || s.step === 'declined') {
|
|
222
|
+
const pick = text.toLowerCase();
|
|
223
|
+
if (pick === 'a') { push({ kind: 'user', text }); startPreflight(); return; } // install here: the same flow as yes
|
|
224
|
+
if (pick === 'b') { push({ kind: 'user', text }, { kind: 'dim', text: T.LOCAL_CHECKING }); useLocal(); return; }
|
|
225
|
+
if (pick === 'c') { push({ kind: 'user', text }, { kind: 'guide', text: T.CONNECT_ELSEWHERE }); return; }
|
|
221
226
|
const yn = yesNo(text);
|
|
222
227
|
if (yn === true) { push({ kind: 'user', text: text || 'yes' }); startPreflight(); return; }
|
|
223
228
|
if (yn === false) { push({ kind: 'user', text }, { kind: 'guide', text: T.DECLINED }, { kind: 'dim', text: T.DECLINED_HINT }); set({ step: 'declined' }); persist(); return; }
|
|
@@ -225,6 +230,14 @@ export function GettingStarted({
|
|
|
225
230
|
}
|
|
226
231
|
}
|
|
227
232
|
|
|
233
|
+
async function useLocal() {
|
|
234
|
+
const f = await findLocal();
|
|
235
|
+
if (!live.current || S.current.done) return;
|
|
236
|
+
if (!f.found) { push({ kind: 'guide', tone: 'warn', text: T.LOCAL_NONE }); return; }
|
|
237
|
+
push({ kind: 'guide', tone: 'ok', text: T.localFound(f.url) }, { kind: 'guide', text: T.connectChecking(f.url) });
|
|
238
|
+
exit({ action: 'connect', url: f.url });
|
|
239
|
+
}
|
|
240
|
+
|
|
228
241
|
async function startPreflight() {
|
|
229
242
|
set({ step: 'preflight', rows: pendingRows(), fixNote: null });
|
|
230
243
|
persist();
|
package/lib/quoxon/orgSwitch.js
CHANGED
|
@@ -8,7 +8,29 @@ export const orgRows = (list) => (Array.isArray(list) ? list : []).map((o) => ({
|
|
|
8
8
|
* query found in the name or slug (so "acme widgets" finds "Acme Widgets Ltd"). Never guesses between ties.
|
|
9
9
|
* -> { org } | { ambiguous: [orgs] } | { none: true }
|
|
10
10
|
*/
|
|
11
|
+
const VERBS = new Set(['switch', 'swith', 'swich', 'sw', 'to', 'go', 'use']);
|
|
12
|
+
/** Drop ONE leading verb-ish token ("swith quox dev" -> "quox dev"); a lone verb is kept (it may be the name). */
|
|
13
|
+
export function stripOrgVerb(query) {
|
|
14
|
+
const m = /^(\S+)\s+(\S[\s\S]*)$/.exec(String(query ?? '').trim());
|
|
15
|
+
return m && VERBS.has(m[1].toLowerCase()) ? m[2].trim() : String(query ?? '').trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Orgs for a picker when nothing matched uniquely: those hitting the most query words (verb stripped), at least one. */
|
|
19
|
+
export function orgCandidates(query, list) {
|
|
20
|
+
const words = norm(stripOrgVerb(query)).split(' ').filter(Boolean);
|
|
21
|
+
const scored = orgRows(list).map((o) => { const hay = `${norm(o.name)} ${norm(o.slug)}`; return { o, n: words.filter((w) => hay.includes(w)).length }; });
|
|
22
|
+
const best = Math.max(0, ...scored.map((x) => x.n));
|
|
23
|
+
return best ? scored.filter((x) => x.n === best).map((x) => x.o) : [];
|
|
24
|
+
}
|
|
25
|
+
|
|
11
26
|
export function matchOrg(query, list) {
|
|
27
|
+
const first = matchOrgExact(query, list);
|
|
28
|
+
if (!first.none) return first;
|
|
29
|
+
const stripped = stripOrgVerb(query);
|
|
30
|
+
return stripped !== String(query ?? '').trim() ? matchOrgExact(stripped, list) : first;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function matchOrgExact(query, list) {
|
|
12
34
|
const rows = orgRows(list);
|
|
13
35
|
const q = norm(query);
|
|
14
36
|
if (!q) return { none: true };
|
package/lib/quoxon/print.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Exit codes: 0 completed, 2 held for approval, 3 interrupted, 4 auth, 5 unconfirmed
|
|
5
5
|
* (stream closed without turn.finished), 1 other.
|
|
6
6
|
*/
|
|
7
|
+
import { eventLine } from '../core/announce.js';
|
|
7
8
|
import { textGate } from './events.js';
|
|
8
9
|
import { streamFields, fallbackNote } from './model.js';
|
|
9
10
|
import { connectOfferFor } from './connect.js';
|
|
@@ -60,6 +61,7 @@ export async function runPrint({ api, prompt, agent = 'quox', output = 'text', c
|
|
|
60
61
|
else if (ev.type === 'done' && !text && ev.full_response) text = ev.full_response;
|
|
61
62
|
else if (ev.type === 'tool.started' && !seen.has(ev.call_id)) { seen.add(ev.call_id); sum.tools++; }
|
|
62
63
|
else if (ev.type === 'tool.finished') { if (!seen.has(ev.call_id)) { seen.add(ev.call_id); sum.tools++; } if (ev.receipt?.ward_seq != null) sum.receipts.push(ev.receipt.ward_seq); }
|
|
64
|
+
else if (ev.type === 'quox_event') { if (output !== 'json') { const q = eventLine(ev); if (q) err(`${q}\n`); } } // QUOX-ANNOUNCE A2: stdout stays the answer
|
|
63
65
|
else if (ev.type === 'approval.requested') { sawApproval = true; sum.approvals++; }
|
|
64
66
|
else if (ev.type === 'approval.resolved') sawApproval = false;
|
|
65
67
|
else if (ev.type === 'turn.objective_suggested' && ev.objective_suggested?.goal) objective = ev.objective_suggested; // QUOXON objective offer
|
|
@@ -23,6 +23,13 @@ export const switchAskLine = (profile, human) => askText('Switch?', {
|
|
|
23
23
|
action: `switch this window to your login '${human}' (same conversation, attach, slot and groups)`,
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
/** The startup offer on an agent login (owner 2026-10-28): restart as the human profile; /profile lists the rest. */
|
|
27
|
+
export const startupSwitchAskLine = (profile, human, others = 0) => askText(`Restart as you (${human})?`, {
|
|
28
|
+
title: `This window would run as the agent '${profile}'.`,
|
|
29
|
+
action: `restart this window as '${human}' (same directory, nothing else changes)`,
|
|
30
|
+
reason: others > 0 ? `${others} other signed-in profile${others === 1 ? '' : 's'}: /profile lists them` : '',
|
|
31
|
+
});
|
|
32
|
+
|
|
26
33
|
/** The ask card for a /slot name that does not exist (the near misses are said on the line above). */
|
|
27
34
|
export const slotCreateAskLine = (nm) => askText('Create it?', { title: `No slot ${nm}.`, action: `create and hold slot ${nm}` });
|
|
28
35
|
|
|
@@ -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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* horizontal rules only (no side borders), semantic colour, mono-safe.
|
|
4
4
|
*/
|
|
5
5
|
import { toolCardLines } from './toolCards.js';
|
|
6
|
+
import { DelegationDragon, delegationDragonLines } from './delegationDragon.js';
|
|
6
7
|
import React from 'react';
|
|
7
8
|
import chalk from 'chalk';
|
|
8
9
|
import { Box, Text, useStdout } from 'ink';
|
|
@@ -30,6 +31,7 @@ import { Markdown, wrapCell, markdownRows, wrapRows, WText, Heading } from './ma
|
|
|
30
31
|
import { parseAsk, keysLabel, askText } from './ask.js';
|
|
31
32
|
import { parseAsideNote } from './asideView.js';
|
|
32
33
|
import { formatSlotBody, inlineable, codeRows } from './slotFormat.js';
|
|
34
|
+
import { BACKGROUND_NOTE } from './backgroundWork.js';
|
|
33
35
|
import { termRows } from './term.js';
|
|
34
36
|
import { cellWidth, ambiguousWide } from './cells.js';
|
|
35
37
|
import { welcomeModel } from './welcome.js';
|
|
@@ -555,7 +557,9 @@ export function HistoryItem({ item, cols, live = false, fx = null, pulse = null,
|
|
|
555
557
|
: h(Text, { key: i, color: c('dim') }, ...runs(paintRuns(l, 'muted'))))));
|
|
556
558
|
}
|
|
557
559
|
case 'text': return h(Box, { marginTop: gap ?? 1, paddingLeft: 2, flexDirection: 'column' }, h(Markdown, { text: item.text, cols: cols - 2, live }));
|
|
558
|
-
case 'tool': return
|
|
560
|
+
case 'tool': return item.dragon // dragons on: a static dragon marks CommanderQ's hand-off (delegationDragon.js)
|
|
561
|
+
? h(Box, { flexDirection: 'column' }, h(DelegationDragon, { b: item, cols }), h(ToolRow, { b: item, cols, fx, pulse, ignite }))
|
|
562
|
+
: h(ToolRow, { b: item, cols, fx, pulse, ignite });
|
|
559
563
|
case 'trace': return h(TraceRow, { b: item, cols, fx, pulse });
|
|
560
564
|
case 'aocl': return h(AoclRow, { item, cols });
|
|
561
565
|
case 'error': return h(ErrorView, { item, cols, fx });
|
|
@@ -733,10 +737,12 @@ function TipView({ item, cols }) {
|
|
|
733
737
|
const body = wrapPlain(item.text, inner).map((l, i) => row(`t${i}`, [h(Text, { key: 0, color: c('prose') }, l)], [...l].length));
|
|
734
738
|
const cmdText = clip(item.cmd, inner - 5);
|
|
735
739
|
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
|
|
740
|
+
const hintPlain = stripMarks(TIP_HINT); // too long for the frame: wrap it inside, never let the terminal wrap the border
|
|
741
|
+
const hintRows = [...hintPlain].length <= inner ? [row('hint', runs(paintRuns(TIP_HINT, 'quiet')), [...hintPlain].length)]
|
|
742
|
+
: wrapPlain(hintPlain, inner).map((l, i) => row(`hint${i}`, [h(Text, { key: 0, color: c('quiet') }, l)], [...l].length));
|
|
737
743
|
return h(Box, { paddingLeft: 2, flexDirection: 'column' },
|
|
738
744
|
h(Text, edge, '╭─ ', h(Text, { bold: true }, 'Tip'), ' ', '─'.repeat(W - 8), '╮'),
|
|
739
|
-
...body, tryRow,
|
|
745
|
+
...body, tryRow, ...hintRows,
|
|
740
746
|
h(Text, edge, '╰', '─'.repeat(W - 2), '╯'));
|
|
741
747
|
}
|
|
742
748
|
|
|
@@ -764,9 +770,13 @@ function WhatsNewView({ item, cols }) {
|
|
|
764
770
|
return out;
|
|
765
771
|
});
|
|
766
772
|
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));
|
|
773
|
+
// a head that does not fit in the top edge (the compact "N new commands (...)" line at a narrow width) goes inside the
|
|
774
|
+
// frame, wrapped, under a bare top edge: the terminal must never be the one to wrap a border row
|
|
775
|
+
const headFits = [...HEAD].length <= W - 6;
|
|
776
|
+
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
777
|
if (item.pad) { rows.unshift(row('p0', [], 0)); rows.push(row('p1', [], 0)); }
|
|
768
778
|
return h(Box, { paddingLeft: 2, flexDirection: 'column' },
|
|
769
|
-
h(Text, edge, '╭─ ', h(Text, { bold: true }, HEAD), ' ', '─'.repeat(
|
|
779
|
+
headFits ? h(Text, edge, '╭─ ', h(Text, { bold: true }, HEAD), ' ', '─'.repeat(W - 5 - [...HEAD].length), '╮') : h(Text, edge, '╭', '─'.repeat(W - 2), '╮'),
|
|
770
780
|
...rows,
|
|
771
781
|
h(Text, edge, '╰', '─'.repeat(W - 2), '╯'));
|
|
772
782
|
}
|
|
@@ -804,7 +814,7 @@ export function liveRows(blocks, cols) {
|
|
|
804
814
|
case 'summary': return n + noteLines(b.text, cols - 4).length;
|
|
805
815
|
case 'error': return n + errorRows(b.text, cols);
|
|
806
816
|
case 'slotmsg': return n + (b.line?.run && !b.line.run.settled ? 9 + Math.ceil(String(b.line.run.cmd || '').length / Math.max(10, cols - 8)) : 1); // ASK-SETTLE: an armed run card is held live
|
|
807
|
-
case 'tool': return n + 1 + (b.status === 'running' ? 0 : toolCardLines(b, cols).length);
|
|
817
|
+
case 'tool': return n + 1 + delegationDragonLines(b, cols).length + (b.status === 'running' ? 0 : toolCardLines(b, cols).length);
|
|
808
818
|
case 'loopcard': return n + (loopPinned(b.card) ? 0 : 1 + loopCardLines(b.card, cols).length);
|
|
809
819
|
default: return n + 1;
|
|
810
820
|
}
|
|
@@ -843,6 +853,12 @@ export function modelRuleLabel(cols, model, reserve = 0) {
|
|
|
843
853
|
return reserve ? '' : clip(name, room); // with the approvals indicator the model is dropped before the indicator is cut
|
|
844
854
|
}
|
|
845
855
|
|
|
856
|
+
/** Pure: a failed approvals poll goes stale (footer `?`) only after 3 consecutive misses or 60 s without a good read. */
|
|
857
|
+
export function approvalsPollStale({ fails, lastOkAt, now }) {
|
|
858
|
+
if (!(fails > 0)) return false;
|
|
859
|
+
return fails >= 3 || (typeof lastOkAt === 'number' && now - lastOkAt > 60000);
|
|
860
|
+
}
|
|
861
|
+
|
|
846
862
|
/**
|
|
847
863
|
* Pure: the permanent approvals indicator on the bottom rule. `a` = { count, error, signedOut }.
|
|
848
864
|
* Never a false 0: signed out, an unreachable inbox, or a count not read yet is `approvals ?` (warn).
|
|
@@ -850,9 +866,20 @@ export function modelRuleLabel(cols, model, reserve = 0) {
|
|
|
850
866
|
export function approvalsIndicator(a) {
|
|
851
867
|
if (!a) return null;
|
|
852
868
|
const n = a.count;
|
|
869
|
+
if (!a.signedOut && a.error && a.slow) return { text: 'approvals ? (server slow)', role: 'warn' }; // sustained failure, never an all-clear
|
|
853
870
|
if (a.signedOut || a.error || typeof n !== 'number') return { text: 'approvals ?', role: 'warn' };
|
|
871
|
+
// Never an all-clear next to outstanding items or an unseen source: scope the claim to approvals and say what else is open.
|
|
872
|
+
const out = Number(a.outstanding) || 0, blind = Number(a.blind) || 0;
|
|
873
|
+
if (n === 0 && (out > 0 || blind > 0)) {
|
|
874
|
+
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(' · ');
|
|
875
|
+
return { text: `no approvals in this view · ${more}`, role: 'warn' };
|
|
876
|
+
}
|
|
877
|
+
// Other orgs the user belongs to (cached counts, core/crossOrgApprovals): never an all-clear beside a waiting approval elsewhere.
|
|
878
|
+
const others = (Array.isArray(a.others) ? a.others : []).filter((o) => o && o.count > 0);
|
|
879
|
+
const elsewhere = others.reduce((t, o) => t + o.count, 0);
|
|
880
|
+
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
881
|
if (n === 0) return { text: '✔ nothing waiting', role: 'ok' };
|
|
855
|
-
return { text: `⏸ ${n} approval${n === 1 ? '' : 's'} waiting`, role: 'warn' };
|
|
882
|
+
return { text: `⏸ ${n} approval${n === 1 ? '' : 's'} waiting${elsewhere ? ` · +${elsewhere} in ${others.length === 1 ? others[0].name : `${others.length} other orgs`}` : ''}`, role: 'warn' };
|
|
856
883
|
}
|
|
857
884
|
|
|
858
885
|
/** Pure: the labels for this width. The indicator sits top-left, the model bottom-right: independent of each other. */
|
|
@@ -1031,11 +1058,12 @@ export function AskFoot({ footer }) {
|
|
|
1031
1058
|
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
1059
|
}
|
|
1033
1060
|
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 }) {
|
|
1061
|
+
export function NeedsYouCard({ cols, title, rows = [], block = [], notes = [], footer, arming = null, motionE = null, lead = null }) {
|
|
1035
1062
|
const e = motionE;
|
|
1036
1063
|
return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
|
|
1037
1064
|
e == null ? h(Rule, { cols, color: 'warn' }) : h(DrawRule, { cols, color: 'warn', e }),
|
|
1038
1065
|
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)),
|
|
1066
|
+
lead ? h(Text, { color: c('warn'), bold: true, wrap: 'wrap' }, ` ${lead}`) : null, // a destructive approval leads with what it deletes
|
|
1039
1067
|
// APPROVAL-WHAT: wrap at word boundaries under the label instead of cutting the action or target off
|
|
1040
1068
|
...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
1069
|
// block: pre-shaped segment rows shown verbatim (a command to run: never word-wrapped, never clipped)
|
|
@@ -1068,7 +1096,16 @@ export const askCardRows = (text, cols) => {
|
|
|
1068
1096
|
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
1097
|
};
|
|
1070
1098
|
|
|
1071
|
-
|
|
1099
|
+
/** APPROVAL-BURST: one card for 2+ approvals from one agent for one tool (approvalBatch.batchModel). */
|
|
1100
|
+
export function BatchCard({ model, cols, armed, motionE = null }) {
|
|
1101
|
+
const e = motionE;
|
|
1102
|
+
const block = model.lines.map((l) => [{ t: `• ${clip(l, Math.max(10, cols - 8))}`, role: 'text' }]);
|
|
1103
|
+
if (model.more) block.push([{ t: model.more, role: 'dim' }]);
|
|
1104
|
+
const footer = askText(`Approve all ${model.n}?`, { keys: model.keys });
|
|
1105
|
+
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 });
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE = null, confirm = false, position = null, signedOut = false }) {
|
|
1072
1109
|
const when = card.askedAt ? String(card.askedAt).replace('T', ' ').slice(0, 16) + ' UTC' : null;
|
|
1073
1110
|
const rows = card.undescribable ? [['what', card.what], ['from', card.askedBy || card.origin], ['asked', when], ['org', org]].filter(r => r[1]) : [
|
|
1074
1111
|
['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 +1119,9 @@ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE
|
|
|
1082
1119
|
const f = card.facts || null; const block = [];
|
|
1083
1120
|
if (f && !card.undescribable) {
|
|
1084
1121
|
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); } }
|
|
1122
|
+
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
1123
|
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}"
|
|
1124
|
+
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
1125
|
}
|
|
1088
1126
|
const e = motionE;
|
|
1089
1127
|
const title = `⚠ Approval needed${position && position.n > 1 ? ` (${position.i} of ${position.n})` : ''}: execution is held`;
|
|
@@ -1091,12 +1129,15 @@ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE
|
|
|
1091
1129
|
const arming = e == null || mono()
|
|
1092
1130
|
? h(Text, { color: c('dim') }, e == null ? ' arming…' : ` arming… ${'▰'.repeat(filled)}${'▱'.repeat(ARM_BAR - filled)}`)
|
|
1093
1131
|
: 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 =
|
|
1132
|
+
const footer = signedOut ? askText('Read-only while signed out. Sign in to answer?', { keys: 'y signs in' })
|
|
1133
|
+
: card.privileged && confirm ? `▶ Grant ${[card.tool, card.target].filter(Boolean).join(' ')}? press y again to confirm, any other key cancels`
|
|
1095
1134
|
: card.undescribable && confirm ? '▶ Quox could not describe this action. Approve it anyway? press y again to confirm, any other key cancels'
|
|
1135
|
+
: draftHint === 'reason' ? askText('Why deny it?', { keys: 'type a reason · Enter denies · Esc goes back to y/n' })
|
|
1096
1136
|
: 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
1137
|
: 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
|
-
|
|
1138
|
+
: card.destructive ? '▶ Delete it? y then type the name · n deny · c chat · Tab later · /approvals all'
|
|
1139
|
+
: '▶ Approve it? y approve · n deny · c chat · Tab later · /approvals all';
|
|
1140
|
+
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
1141
|
}
|
|
1101
1142
|
|
|
1102
1143
|
/** Columns the status line's Q takes: 2 for the Braille glyph, 1 for the Ⓠ fallback (2 where Ambiguous is wide). */
|
|
@@ -1221,11 +1262,22 @@ function hintNodes(hint) {
|
|
|
1221
1262
|
}
|
|
1222
1263
|
|
|
1223
1264
|
/** Live, actionable counts under the status line. Never a transcript message. */
|
|
1224
|
-
export function NotifyBar({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false, inbox = '' }) {
|
|
1265
|
+
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 }) {
|
|
1266
|
+
const hm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
|
|
1267
|
+
if (away && dnd && !signedOut) { // DND: one persistent quiet line, shown even at 0 waiting so the mode is never invisible
|
|
1268
|
+
const head = dndUntil ? `🌙 do not disturb until ${hm(dndUntil)}` : '🌙 do not disturb';
|
|
1269
|
+
return h(Text, { color: c('ask'), bold: true }, ' ' + clip(`${head}${dndCount ? ` · ${dndCount} waiting` : ''} · /morningreport`, cols - 3));
|
|
1270
|
+
}
|
|
1271
|
+
if (away && !signedOut) { // AFK-QUIET-CARDS: away shows one quiet line, never a prompt
|
|
1272
|
+
const n = awayCount || approvals || 0;
|
|
1273
|
+
const head = awaySince ? `🌙 away since ${hm(awaySince)} · /back` : awayUnknown ? '🌙 away (since before this window opened) · /back' : '🌙 away · /back';
|
|
1274
|
+
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)
|
|
1275
|
+
}
|
|
1225
1276
|
const base = NotifyBarBase({ approvals, error, cols, signedOut, alerts, hideCount });
|
|
1226
|
-
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1277
|
+
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
|
|
1278
|
+
const spin = spinner ? h(Text, { key: 'spin', color: c('ask') }, ' ' + clip(spinner, cols - 3)) : null; // INSTANT-FEEDBACK: ⠋ <verb>…
|
|
1279
|
+
const parts = [base, line, spin].filter(Boolean);
|
|
1280
|
+
return parts.length > 1 ? h(Box, { flexDirection: 'column' }, ...parts) : (parts[0] || base);
|
|
1229
1281
|
}
|
|
1230
1282
|
function NotifyBarBase({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false }) {
|
|
1231
1283
|
if (signedOut) return h(Text, { color: c('warn') }, ' ' + clip('not signed in: approvals unavailable until you sign in', cols - 2));
|
package/lib/quoxon/resend.js
CHANGED
|
@@ -14,6 +14,20 @@ export function isServerDown(e) {
|
|
|
14
14
|
return /ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|UND_ERR_SOCKET/.test(String(code)) || /Could not connect to|fetch failed|ECONNREFUSED/i.test(msg);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Owner 2026-11-07: a resend of the SAME turn_id that the server says it already holds (409 turn_id_reused, or
|
|
19
|
+
* turn_in_use while it still runs; also the in-stream error the registry race sends). The first send reached the
|
|
20
|
+
* server before the socket dropped, so the right move is to attach to that turn, never to show a bare failure.
|
|
21
|
+
*/
|
|
22
|
+
export function isTurnIdTaken(e) {
|
|
23
|
+
if (!e) return false;
|
|
24
|
+
const words = `${e.code || ''} ${e.body?.error || ''} ${typeof e.error === 'string' ? e.error : ''} ${e.message || ''}`;
|
|
25
|
+
if (e.type === 'error') return /turn_id_reused|this turn_id already ran|this turn_id is already running/i.test(words);
|
|
26
|
+
return Number(e.status) === 409 && /turn_id_reused|turn_in_use|turn_id already ran|already running/i.test(words);
|
|
27
|
+
}
|
|
28
|
+
/** The text shown when the held turn cannot be shown either: the message is restored, and the owner told it may have run. */
|
|
29
|
+
export const HELD_NOT_SHOWN = 'The server already received this message before the connection dropped, but could not show what happened to it. Your message is back in the composer; it may have run, so check /resume before sending it again.';
|
|
30
|
+
|
|
17
31
|
/** gh #1157: resume during a collector restart. Connection-class failures (refused, fetch failed, hang up, 502/503/504) are worth retrying; 401/404 are answers. */
|
|
18
32
|
export function isResumeRetryable(e) {
|
|
19
33
|
if (!e) return false;
|
package/lib/quoxon/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
|
+
}
|
package/lib/quoxon/rows.js
CHANGED
|
@@ -22,6 +22,7 @@ import { homedir } from 'node:os';
|
|
|
22
22
|
import { join, dirname } from 'node:path';
|
|
23
23
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
24
24
|
import { cellWidth } from './cells.js';
|
|
25
|
+
import { isWebPty, SHELL_REFUSED } from './webPty.js';
|
|
25
26
|
|
|
26
27
|
export const MAX_ROWS = 3;
|
|
27
28
|
/** Lines drawn above the status bar (a row can bring several with `lines`); the stored-row cap stays MAX_ROWS. */
|
|
@@ -566,8 +567,9 @@ export async function plainFetch(url, { timeoutMs = URL_TIMEOUT_MS, fetchImpl =
|
|
|
566
567
|
}
|
|
567
568
|
|
|
568
569
|
/** Run a shell command via the user's shell: timeout, output cap. -> stdout. Throws a short reason. */
|
|
569
|
-
export function runShell(cmd, { timeoutMs = FETCH_TIMEOUT_MS, shell = process.env.SHELL || '/bin/sh', exec = execFile } = {}) {
|
|
570
|
+
export function runShell(cmd, { timeoutMs = FETCH_TIMEOUT_MS, shell = process.env.SHELL || '/bin/sh', exec = execFile, env = process.env } = {}) {
|
|
570
571
|
return new Promise((resolve, reject) => {
|
|
572
|
+
if (isWebPty(env)) return reject(new Error(SHELL_REFUSED)); // QUOXON web PTY: command rows never run on the server
|
|
571
573
|
exec(shell, ['-c', cmd], { timeout: timeoutMs, maxBuffer: OUTPUT_CAP, killSignal: 'SIGKILL', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }, (err, stdout) => {
|
|
572
574
|
if (!err) return resolve(String(stdout ?? ''));
|
|
573
575
|
if (err.killed || err.signal === 'SIGKILL') return reject(new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`));
|
|
@@ -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,88 @@
|
|
|
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
|
+
// a digit run glued to letters/underscore is part of a longer token (hex disk serial, hash, id), never a PAN
|
|
60
|
+
if (/\w/.test(s[m.index - 1] ?? '') || /\w/.test(s[m.index + m[0].length] ?? '')) continue;
|
|
61
|
+
if (precise) {
|
|
62
|
+
if (!CARD_FORM.test(m[0]) || /[A-Za-z_]/.test(s[m.index - 1] ?? '') || /[A-Za-z_]/.test(s[m.index + m[0].length] ?? '')) continue;
|
|
63
|
+
if (/^\d{4}(?:-\d{4}){3}$/.test(m[0]) && /[0-9a-f]{4,}-$/i.test(s.slice(Math.max(0, m.index - 6), m.index))) continue;
|
|
64
|
+
}
|
|
65
|
+
if (d.length >= 13 && d.length <= 19 && luhn(d)) add('card-number', m.index, m.index + m[0].length);
|
|
66
|
+
}
|
|
67
|
+
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);
|
|
68
|
+
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);
|
|
69
|
+
for (const m of s.matchAll(/\bAKIA[0-9A-Z]{16}\b/g)) add('api-key', m.index, m.index + m[0].length);
|
|
70
|
+
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);
|
|
71
|
+
hits.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const h of hits) { const last = out[out.length - 1]; if (last && h.start < last.end) continue; out.push(h); }
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** -> unique kinds in order of appearance ([] when clean). */
|
|
78
|
+
export const secretKinds = (text, opts) => [...new Set(findSecrets(text, opts).map(h => h.kind))];
|
|
79
|
+
|
|
80
|
+
/** Matched values become [REDACTED:<kind>]; everything else is untouched. */
|
|
81
|
+
export function redactSecrets(text) {
|
|
82
|
+
if (typeof text !== 'string' || !text) return text;
|
|
83
|
+
const hits = findSecrets(text);
|
|
84
|
+
if (!hits.length) return text;
|
|
85
|
+
let out = '', at = 0;
|
|
86
|
+
for (const h of hits) { out += text.slice(at, h.start) + `${REDACTED}:${h.kind}]`; at = h.end; }
|
|
87
|
+
return out + text.slice(at);
|
|
88
|
+
}
|
|
@@ -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
|
}));
|