quoxcli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +58 -0
- package/lib/api/endpoints/index.js +4 -0
- package/lib/api/endpoints/lucid.js +17 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +7 -0
- package/lib/commands/lucidGoals.js +389 -0
- package/lib/commands/mail.js +427 -25
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +69 -144
- package/lib/commands/monitoring.js +68 -0
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +617 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +52 -12
- package/lib/commands/plan.js +487 -52
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/volt.js +6 -1
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +880 -0
- package/lib/commands/wazuhIntake.js +358 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +9 -0
- package/lib/core/config.js +42 -3
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +120 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +30 -5
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +189 -34
- package/lib/quoxon/app.js +1277 -188
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +72 -12
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +8 -2
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +8 -1
- package/lib/quoxon/commands.js +42 -12
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +118 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +55 -8
- package/lib/quoxon/extensions.js +400 -38
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +7 -2
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +27 -6
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +68 -16
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +88 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +48 -11
- package/lib/quoxon/slash/broadcastRun.js +24 -7
- package/lib/quoxon/slash/cliLeaves.json +98 -1
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +63 -1
- package/lib/quoxon/slash/paletteController.js +15 -5
- package/lib/quoxon/slash/restart.js +9 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +24 -3
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +42 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +61 -3
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GRIPE-QUEUE: a gripe that could not be filed because Quox did not answer (timeout, network, 5xx) is kept on disk and
|
|
3
|
+
* retried, never lost. Local only (~/.quox/gripe-queue.json, mode 0600). Each entry carries an idempotency key
|
|
4
|
+
* (sha256 of the normalised text + the UTC day) that rides on the filing request, so the collector can return the
|
|
5
|
+
* issue it already made when a request that timed out had in fact succeeded.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { redactSecrets } from './secretShape.js';
|
|
12
|
+
|
|
13
|
+
export const RETRY_MIN_MS = 2 * 60 * 1000;
|
|
14
|
+
export const GIVE_UP_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
export const RECENT_FILED_CAP = 20;
|
|
16
|
+
export const defaultQueuePath = (home = homedir()) => join(home, '.quox', 'gripe-queue.json');
|
|
17
|
+
|
|
18
|
+
const normalise = (t) => String(t || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
19
|
+
/** sha256(normalised text + UTC day) -> hex. The same gripe retyped the same day maps to one key. */
|
|
20
|
+
export function gripeKey(text, now = Date.now()) {
|
|
21
|
+
return createHash('sha256').update(`${normalise(text)}|${new Date(now).toISOString().slice(0, 10)}`).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A failure worth queueing: no HTTP status (timeout, network, refused) or a 5xx. 4xx is the user's to fix. */
|
|
25
|
+
export function isRetryable(err) {
|
|
26
|
+
const s = err?.status;
|
|
27
|
+
if (s == null) return true;
|
|
28
|
+
return Number(s) >= 500;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function gripeQueue(path = defaultQueuePath(), { now = () => Date.now() } = {}) {
|
|
32
|
+
const read = () => {
|
|
33
|
+
try { const d = JSON.parse(readFileSync(path, 'utf8')); return Array.isArray(d?.items) ? d : { items: [], lastTry: 0 }; }
|
|
34
|
+
catch { return { items: [], lastTry: 0 }; }
|
|
35
|
+
};
|
|
36
|
+
const write = (d) => {
|
|
37
|
+
try {
|
|
38
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
39
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
40
|
+
writeFileSync(tmp, JSON.stringify(d), { mode: 0o600 });
|
|
41
|
+
renameSync(tmp, path);
|
|
42
|
+
return true;
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
};
|
|
45
|
+
const trim = (d) => { // keep every open item, and only the most recent filed/expired ones
|
|
46
|
+
const open = d.items.filter(i => i.status === 'queued');
|
|
47
|
+
const done = d.items.filter(i => i.status !== 'queued').slice(-RECENT_FILED_CAP);
|
|
48
|
+
d.items = [...done, ...open];
|
|
49
|
+
return d;
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
key: (text) => gripeKey(text, now()),
|
|
53
|
+
list: () => read().items,
|
|
54
|
+
/** Add (or find) the queued entry for this gripe. -> the entry. */
|
|
55
|
+
enqueue({ text, repo = null, key }) {
|
|
56
|
+
const d = read();
|
|
57
|
+
const k = key || gripeKey(text, now());
|
|
58
|
+
let it = d.items.find(i => i.key === k);
|
|
59
|
+
if (!it) { it = { key: k, text: redactSecrets(String(text)), repo, created_at: new Date(now()).toISOString(), status: 'queued', tries: 0 }; d.items.push(it); }
|
|
60
|
+
else if (it.status === 'expired') { it.status = 'queued'; }
|
|
61
|
+
write(trim(d));
|
|
62
|
+
return it;
|
|
63
|
+
},
|
|
64
|
+
/** Record a successful filing (from the live path or a retry) so /gripes can show the link. */
|
|
65
|
+
markFiled({ text, repo = null, key, url }) {
|
|
66
|
+
const d = read();
|
|
67
|
+
const k = key || gripeKey(text, now());
|
|
68
|
+
let it = d.items.find(i => i.key === k);
|
|
69
|
+
if (!it) { it = { key: k, text: redactSecrets(String(text)), repo, created_at: new Date(now()).toISOString(), tries: 0 }; d.items.push(it); }
|
|
70
|
+
Object.assign(it, { status: 'filed', url, filed_at: new Date(now()).toISOString() });
|
|
71
|
+
write(trim(d));
|
|
72
|
+
return it;
|
|
73
|
+
},
|
|
74
|
+
/**
|
|
75
|
+
* File what is queued, at most once per RETRY_MIN_MS across all callers, expiring entries older than 24 h.
|
|
76
|
+
* file(item) -> { filed:true, url } | { filed:false, retryable }. -> { ran, filed:[items], expired:[items], pending:n }.
|
|
77
|
+
*/
|
|
78
|
+
async retry(file, { force = false } = {}) {
|
|
79
|
+
const d = read();
|
|
80
|
+
const t = now();
|
|
81
|
+
const expired = [];
|
|
82
|
+
for (const it of d.items) if (it.status === 'queued' && t - Date.parse(it.created_at) > GIVE_UP_MS) { it.status = 'expired'; expired.push(it); }
|
|
83
|
+
const open = d.items.filter(i => i.status === 'queued');
|
|
84
|
+
if (expired.length) write(d);
|
|
85
|
+
if (!open.length) return { ran: false, filed: [], expired, pending: 0 };
|
|
86
|
+
if (!force && t - (d.lastTry || 0) < RETRY_MIN_MS) return { ran: false, filed: [], expired, pending: open.length };
|
|
87
|
+
d.lastTry = t; write(d);
|
|
88
|
+
const filed = [];
|
|
89
|
+
for (const it of open) {
|
|
90
|
+
let r;
|
|
91
|
+
try { r = await file(it); } catch (e) { r = { filed: false, retryable: isRetryable(e) }; }
|
|
92
|
+
it.tries = (it.tries || 0) + 1;
|
|
93
|
+
if (r?.filed && r.url) { Object.assign(it, { status: 'filed', url: r.url, filed_at: new Date(now()).toISOString() }); filed.push(it); }
|
|
94
|
+
else if (r && r.retryable === false) { it.status = 'failed'; it.error = r.error || 'refused'; }
|
|
95
|
+
else break; // Quox still not answering: leave the rest for the next window
|
|
96
|
+
}
|
|
97
|
+
const fresh = read(); // another process may have enqueued meanwhile; merge by key
|
|
98
|
+
for (const it of d.items) { const j = fresh.items.findIndex(x => x.key === it.key); if (j >= 0) fresh.items[j] = it; else fresh.items.push(it); }
|
|
99
|
+
fresh.lastTry = d.lastTry;
|
|
100
|
+
write(trim(fresh));
|
|
101
|
+
return { ran: true, filed, expired, pending: fresh.items.filter(i => i.status === 'queued').length };
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const SAVED_LINE = "Saved; I'll file it when Quox answers again (/gripes shows the queue)";
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run one live filing. run(key) -> { response, url, superseded, notes } (throws when Quox does not answer).
|
|
110
|
+
* A timeout, network or 5xx failure queues the gripe instead of losing it. -> { ok:true, ... } | { ok:false, queued, message }.
|
|
111
|
+
*/
|
|
112
|
+
export async function fileOrQueue({ text, repo = null, queue, run, queueable = true }) {
|
|
113
|
+
const key = queue.key(text);
|
|
114
|
+
try {
|
|
115
|
+
const r = await run(key);
|
|
116
|
+
if (r?.url) queue.markFiled({ text, repo, key, url: r.url });
|
|
117
|
+
return { ok: true, ...r };
|
|
118
|
+
} catch (e) {
|
|
119
|
+
const message = e?.status ? `HTTP ${e.status}: ${e.message}` : (e?.message || 'failed');
|
|
120
|
+
if (!queueable || !isRetryable(e)) return { ok: false, message };
|
|
121
|
+
queue.enqueue({ text, repo, key });
|
|
122
|
+
return { ok: false, queued: true, message, saved: SAVED_LINE };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The retry filer: straight to the governed proxy (no agent run), carrying the entry's key so a duplicate is impossible. */
|
|
127
|
+
export function makeRetryFiler({ fileDeterministically, client, orgId }) {
|
|
128
|
+
return async (it) => {
|
|
129
|
+
const r = await fileDeterministically({
|
|
130
|
+
client, complaint: it.text, orgId: orgId || null, quiet: true, throwRetryable: true,
|
|
131
|
+
options: { yes: true, ...(it.repo ? { repo: it.repo } : {}), idempotencyKey: it.key },
|
|
132
|
+
});
|
|
133
|
+
return r.filed ? { filed: true, url: r.url } : { filed: false, retryable: false, error: r.error };
|
|
134
|
+
};
|
|
135
|
+
}
|
package/lib/quoxon/inboxView.js
CHANGED
|
@@ -2,32 +2,80 @@
|
|
|
2
2
|
* HITL-INBOX-BAR (owner 2026-10-26): the owner's Quox inbox in the chat. Pure helpers: the status-area bar text,
|
|
3
3
|
* the arrival line, the /inbox table and the one-item view. No I/O here; app.js owns the calls.
|
|
4
4
|
*/
|
|
5
|
+
import { bursts, burstLine } from './approvalBatch.js';
|
|
5
6
|
const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, Math.max(0, n - 1))}…` : t; };
|
|
6
7
|
const pad = (s, n) => { const t = clip(s, n); return t + ' '.repeat(Math.max(0, n - [...t].length)); };
|
|
7
8
|
const ms = (v) => { const t = Date.parse(String(v ?? '').includes('T') || !v ? v : `${String(v).replace(' ', 'T')}Z`); return Number.isFinite(t) ? t : 0; };
|
|
8
|
-
const CLOSED = new Set(['resolved', 'cancelled', 'expired', 'deleted', 'archived']);
|
|
9
|
+
const CLOSED = new Set(['resolved', 'cancelled', 'expired', 'withdrawn', 'deleted', 'archived']);
|
|
9
10
|
|
|
10
11
|
/** Newest first. */
|
|
11
12
|
export const newestFirst = (items) => [...(Array.isArray(items) ? items : [])].filter((i) => i && i.id).sort((a, b) => ms(b.created_at ?? b.createdAt) - ms(a.created_at ?? a.createdAt));
|
|
12
13
|
/** Unread = the server's per-user is_read flag is not set, and the item is not closed or archived. */
|
|
13
14
|
export const isUnread = (it) => !it.is_read && !it.archived_at && !CLOSED.has(String(it.status || '').toLowerCase());
|
|
15
|
+
/** Read but still open: what `/inbox unread all` flips back. */
|
|
16
|
+
export const readItems = (items) => newestFirst(items).filter((i) => i.is_read && !i.archived_at && !CLOSED.has(String(i.status || '').toLowerCase()));
|
|
14
17
|
export const unreadItems = (items) => newestFirst(items).filter(isUnread);
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
const titleOf = (it) => String(it.title || it.kind || 'untitled');
|
|
20
|
+
const sameTitle = (a, b) => titleOf(a) === titleOf(b);
|
|
21
|
+
|
|
22
|
+
/** Inbox noise (owner 2026-10-27). Mutes are { [source lowercase]: expiryEpochMs }, stored per profile in the CLI settings. */
|
|
23
|
+
export const REPEAT_WINDOW_MS = 12 * 3600000;
|
|
24
|
+
export const MUTE_DEFAULT_MS = 8 * 3600000;
|
|
25
|
+
/** '30m' | '8h' | '1d' -> ms, or null when it is not a duration. */
|
|
26
|
+
export function muteDuration(text) {
|
|
27
|
+
const m = /^(\d+)\s*(m|h|d)$/i.exec(String(text || '').trim());
|
|
28
|
+
if (!m || !Number(m[1])) return null;
|
|
29
|
+
return Number(m[1]) * { m: 60000, h: 3600000, d: 86400000 }[m[2].toLowerCase()];
|
|
30
|
+
}
|
|
31
|
+
/** The mutes that have not expired. */
|
|
32
|
+
export function activeMutes(mutes, now = Date.now()) {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [k, v] of Object.entries(mutes && typeof mutes === 'object' ? mutes : {})) if (Number(v) > now) out[String(k).toLowerCase()] = Number(v);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/** An approval is NEVER muted: approvals always announce. */
|
|
38
|
+
export const isMuted = (it, mutes, now = Date.now()) => !isApprovalItem(it) && Boolean(activeMutes(mutes, now)[fromOf(it).toLowerCase()]);
|
|
39
|
+
/** Decide which fresh arrivals print. `announced` is a Map(title -> { at, n }) kept for the window session and updated here. -> { print, collapsed, muted } */
|
|
40
|
+
export function announceFresh(fresh, announced, { mutes = {}, now = Date.now() } = {}) {
|
|
41
|
+
const print = []; let collapsed = 0; let muted = 0;
|
|
42
|
+
for (const it of fresh) {
|
|
43
|
+
if (isMuted(it, mutes, now)) { muted++; continue; }
|
|
44
|
+
const key = titleOf(it); const prev = announced.get(key);
|
|
45
|
+
if (!isApprovalItem(it) && prev && now - prev.at < REPEAT_WINDOW_MS) { prev.n++; collapsed++; continue; }
|
|
46
|
+
announced.set(key, { at: now, n: 1 }); print.push(it);
|
|
47
|
+
}
|
|
48
|
+
return { print, collapsed, muted };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The status-area line, or '' when nothing is unread: `📨 2 new · <newest title> (×3)`. Muted sources are left out of the count. */
|
|
52
|
+
export function inboxBarText(items, cols = 80, { mutes = {}, now = Date.now(), announced = null } = {}) {
|
|
53
|
+
const un = unreadItems(items).filter((i) => !isMuted(i, mutes, now));
|
|
19
54
|
if (!un.length) return '';
|
|
20
55
|
const head = `📨 ${un.length} new · `;
|
|
21
|
-
|
|
56
|
+
const burst = bursts(un).find((b) => b.items.includes(un[0])); // APPROVAL-BURST: a burst of one agent's same-tool approvals reads as one line
|
|
57
|
+
if (burst) return head + clip(burstLine(burst).replace(/^📨 /, '').replace(/ · \/approvals$/, ''), Math.max(8, cols - 4 - [...head].length));
|
|
58
|
+
const reps = Math.max(un.filter((i) => sameTitle(i, un[0])).length, announced?.get(titleOf(un[0]))?.n || 0);
|
|
59
|
+
const tail = reps > 1 ? ` (×${reps})` : '';
|
|
60
|
+
return head + clip(titleOf(un[0]), Math.max(8, cols - 4 - [...head].length - [...tail].length)) + tail;
|
|
22
61
|
}
|
|
23
62
|
|
|
24
63
|
/** Unread items not seen on an earlier poll. `seen` is a Set of ids; the first poll seeds it and announces nothing. */
|
|
25
64
|
export function arrivals(items, seen) {
|
|
26
65
|
const un = unreadItems(items);
|
|
27
66
|
const fresh = seen ? un.filter((i) => !seen.has(i.id)) : [];
|
|
28
|
-
|
|
67
|
+
// Cumulative: a short or empty read (collector restarting) or an item rolling out of the latest 10 must not make it "new" on return.
|
|
68
|
+
const ids = new Set(seen || []);
|
|
69
|
+
for (const i of Array.isArray(items) ? items : []) if (i?.id) ids.add(i.id);
|
|
70
|
+
return { fresh, ids };
|
|
29
71
|
}
|
|
30
|
-
export const
|
|
72
|
+
export const SEEN_KEEP = 300;
|
|
73
|
+
/** The persisted seen ids for one org (profile settings `inboxSeenByOrg`), or null when none were saved. */
|
|
74
|
+
export const loadSeen = (settings, orgId) => { const v = settings?.inboxSeenByOrg?.[orgId || '_']; return Array.isArray(v) && v.length ? new Set(v) : null; };
|
|
75
|
+
/** The settings value with this org's seen ids (newest kept, capped) stored. */
|
|
76
|
+
export const withSeen = (settings, orgId, ids) => ({ ...(settings?.inboxSeenByOrg || {}), [orgId || '_']: [...ids].slice(-SEEN_KEEP) });
|
|
77
|
+
/** One arrival line; `n` is the item's own number in the /inbox list (0 when it is not in the latest 10; defaults to 1). */
|
|
78
|
+
export const arrivalLine = (it, n = 1) => `📨 ${clip(it.title || it.kind || 'untitled', 70)} · /inbox${n ? ` ${n}` : ''}`;
|
|
31
79
|
|
|
32
80
|
export const fromOf = (it) => String(it.created_by_name || it.payload?.source || it.payload?.approval_context?.source_label || it.source || it.inbox_type || it.kind || 'system');
|
|
33
81
|
export function ageOf(v, now = Date.now()) {
|
|
@@ -40,40 +88,95 @@ export function ageOf(v, now = Date.now()) {
|
|
|
40
88
|
}
|
|
41
89
|
|
|
42
90
|
/** The latest 10 as a compact table; unread rows carry a dot. -> [{ text, tone }] */
|
|
43
|
-
export function inboxTable(items, now = Date.now(), cols = 100, max = 10) {
|
|
91
|
+
export function inboxTable(items, now = Date.now(), cols = 100, max = 10, mutes = {}) {
|
|
44
92
|
const rows = newestFirst(items).slice(0, max);
|
|
45
|
-
if (!rows.length) return [{ text: 'Your inbox is empty.', tone: 'dim' }];
|
|
93
|
+
if (!rows.length) return [{ text: 'Your inbox is empty. (email is /mail)', tone: 'dim' }];
|
|
46
94
|
const tw = Math.max(16, Math.min(60, cols - 40));
|
|
47
95
|
const out = [{ text: ` ${pad('#', 3)}${pad('when', 6)}${pad('from', 14)}${pad('title', tw)} state`, tone: 'dim' }];
|
|
48
96
|
rows.forEach((it, i) => {
|
|
49
|
-
const un = isUnread(it);
|
|
50
|
-
|
|
97
|
+
const un = isUnread(it); const mu = isMuted(it, mutes, now);
|
|
98
|
+
const reps = un ? unreadItems(items).filter((x) => sameTitle(x, it)).length : 0;
|
|
99
|
+
const title = titleOf(it); const shown = reps > 1 ? `${clip(title, Math.max(8, tw - 6))} (×${reps})` : title;
|
|
100
|
+
out.push({ text: `${un ? '●' : ' '} ${pad(i + 1, 3)}${pad(ageOf(it.created_at ?? it.createdAt, now), 6)}${pad(fromOf(it), 14)}${pad(shown, tw)} ${un ? 'unread' : (it.status || 'read')}${mu ? ' (muted)' : ''}`, tone: un && !mu ? 'accent' : 'dim' });
|
|
51
101
|
});
|
|
52
|
-
const un = unreadItems(items).length;
|
|
53
|
-
out.push({ text: `${un} unread · /inbox N
|
|
102
|
+
const un = unreadItems(items).filter((i) => !isMuted(i, mutes, now)).length;
|
|
103
|
+
out.push({ text: `${un} unread · /inbox N or /inbox show N reads one · /inbox read N|all marks · /inbox unread N|all · /inbox open N (approvals) · /inbox mute <from> [1h|8h|1d] · email is /mail`, tone: 'dim' });
|
|
54
104
|
return out;
|
|
55
105
|
}
|
|
56
106
|
|
|
57
|
-
/**
|
|
107
|
+
/** '5 6', '5-8', '1,3', '#2 4-5' -> sorted unique numbers, or null when any token is not a number or range. */
|
|
108
|
+
export function parseNumbers(text) {
|
|
109
|
+
const toks = String(text || '').trim().split(/[\s,]+/).filter(Boolean);
|
|
110
|
+
if (!toks.length) return null;
|
|
111
|
+
const out = new Set();
|
|
112
|
+
for (const t of toks) {
|
|
113
|
+
const m = /^#?(\d+)(?:-#?(\d+))?$/.exec(t); if (!m) return null;
|
|
114
|
+
const a = Number(m[1]), b = m[2] === undefined ? a : Number(m[2]);
|
|
115
|
+
if (a < 1 || b < 1 || b - a > 200) return null;
|
|
116
|
+
for (let k = Math.min(a, b); k <= Math.max(a, b); k++) out.add(k);
|
|
117
|
+
}
|
|
118
|
+
return [...out].sort((x, y) => x - y);
|
|
119
|
+
}
|
|
120
|
+
const one = (kind, ns) => (ns.length === 1 ? { kind, n: ns[0] } : { kind, ns });
|
|
121
|
+
|
|
122
|
+
/** Arg grammar: '' | N | N open|read | read N..|all | open N. Several numbers and ranges (5 6, 5-8) are fine for read. -> { kind, n? | ns? | all? } | { kind:'usage' } */
|
|
58
123
|
export function parseInbox(arg) {
|
|
59
124
|
const a = String(arg || '').trim().toLowerCase();
|
|
60
125
|
if (!a || a === 'list' || a === 'latest') return { kind: 'list' };
|
|
61
|
-
|
|
62
|
-
m = /^
|
|
63
|
-
|
|
126
|
+
if (a === 'mutes') return { kind: 'mutes' };
|
|
127
|
+
let m = /^(mute|unmute)\s+(\S+)(?:\s+(\S+))?$/.exec(a);
|
|
128
|
+
if (m) {
|
|
129
|
+
if (m[1] === 'unmute') return m[3] ? { kind: 'usage' } : { kind: 'unmute', source: m[2] };
|
|
130
|
+
const dur = m[3] === undefined ? MUTE_DEFAULT_MS : muteDuration(m[3]);
|
|
131
|
+
return dur ? { kind: 'mute', source: m[2], ms: dur } : { kind: 'usage' };
|
|
132
|
+
}
|
|
133
|
+
m = /^(?:(?:show|view)\s+)?#?(\d+)$/.exec(a); if (m) return { kind: 'show', n: Number(m[1]) };
|
|
134
|
+
m = /^(open|read|unread)\s+(.+)$/.exec(a);
|
|
135
|
+
if (m) {
|
|
136
|
+
if ((m[1] === 'read' || m[1] === 'unread') && m[2] === 'all') return { kind: m[1], all: true };
|
|
137
|
+
const ns = parseNumbers(m[2]); if (!ns) return { kind: 'usage' };
|
|
138
|
+
if (m[1] === 'open') return ns.length === 1 ? { kind: 'open', n: ns[0] } : { kind: 'usage' };
|
|
139
|
+
return one(m[1], ns);
|
|
140
|
+
}
|
|
141
|
+
m = /^(.+?)\s+(open|read|unread|show|view)$/.exec(a); // word order: /inbox 1 open, /inbox 5-8 read, /inbox 1 show
|
|
142
|
+
if (m) {
|
|
143
|
+
const ns = parseNumbers(m[1]); if (!ns) return { kind: 'usage' };
|
|
144
|
+
if (m[2] === 'open') return ns.length === 1 ? { kind: 'open', n: ns[0] } : { kind: 'usage' };
|
|
145
|
+
if (m[2] === 'show' || m[2] === 'view') return ns.length === 1 ? { kind: 'show', n: ns[0] } : { kind: 'usage' };
|
|
146
|
+
return one(m[2], ns);
|
|
147
|
+
}
|
|
64
148
|
return { kind: 'usage' };
|
|
65
149
|
}
|
|
66
|
-
export const INBOX_USAGE = 'Usage: /inbox | /inbox N | /inbox read N|all | /inbox open N';
|
|
150
|
+
export const INBOX_USAGE = 'Usage: /inbox | /inbox N or /inbox show N or /inbox N show (the whole item) | /inbox read N|N-M|all (marks read, does not show) | /inbox unread N|N-M|all (undo a read) | /inbox open N (an approval goes to /approvals; shows any other item) | /inbox mute <from> [1h|8h|1d] | /inbox unmute <from> | /inbox mutes (email is /mail)';
|
|
67
151
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
152
|
+
/** The first non-empty string among the item's own body fields and the payload's (body_md is the stored column every task/info item uses). */
|
|
153
|
+
const bodyOf = (it) => {
|
|
70
154
|
const p = it.payload || {};
|
|
71
|
-
const
|
|
155
|
+
for (const v of [it.body_md, it.body, it.message, p.body_md, p.body, p.body_text, p.text, p.message, p.summary, p.description]) if (typeof v === 'string' && v.trim()) return v;
|
|
156
|
+
return '';
|
|
157
|
+
};
|
|
158
|
+
/** One item in full: sender, subject, the whole body (every line, nothing cut), then the actions that apply. `n` is its /inbox number. -> [{ text, tone }] */
|
|
159
|
+
export function inboxDetail(it, now = Date.now(), n = 0) {
|
|
160
|
+
const p = it.payload || {};
|
|
161
|
+
const body = bodyOf(it);
|
|
162
|
+
const subject = p.subject || p.email_subject || '';
|
|
72
163
|
const out = [{ text: `${it.title || it.kind || 'untitled'}`, tone: 'accent' },
|
|
73
|
-
{ text: `from ${fromOf(it)} · ${ageOf(it.created_at ?? it.createdAt, now)} ago · ${it.kind || 'item'} · ${it.priority || 'normal'} · ${it.status || '?'}`, tone: 'dim' }];
|
|
164
|
+
{ text: `from ${p.from || p.sender || p.from_email || fromOf(it)} · ${ageOf(it.created_at ?? it.createdAt, now)} ago · ${it.kind || 'item'} · ${it.priority || 'normal'} · ${it.status || '?'}`, tone: 'dim' }];
|
|
165
|
+
if (subject && subject !== it.title) out.push({ text: `subject: ${subject}`, tone: 'dim' });
|
|
74
166
|
if (body) for (const l of String(body).split('\n')) out.push({ text: l, tone: 'text' });
|
|
167
|
+
else out.push({ text: '(no body: this item carries only a title)', tone: 'dim' });
|
|
75
168
|
if (it.dashboard_url) out.push({ text: it.dashboard_url, tone: 'dim' });
|
|
169
|
+
const at = n ? ` ${n}` : ' N';
|
|
170
|
+
out.push({ text: `actions: /inbox unread${at} (keep it unread) · /inbox read${at} · /inbox${isApprovalItem(it) ? ` open${at} (decide it in /approvals)` : ''}`, tone: 'dim' });
|
|
76
171
|
return out;
|
|
77
172
|
}
|
|
78
173
|
/** Approval-type items route to /approvals (the existing view). */
|
|
79
174
|
export const isApprovalItem = (it) => String(it.kind || '').toLowerCase() === 'approval' || Boolean(it.payload?.approval_context) || it.payload?.type === 'decision';
|
|
175
|
+
|
|
176
|
+
/** /inbox mutes -> [{ text, tone }] with each expiry. */
|
|
177
|
+
export function mutesLines(mutes, now = Date.now()) {
|
|
178
|
+
const act = Object.entries(activeMutes(mutes, now));
|
|
179
|
+
if (!act.length) return [{ text: 'No sources muted. /inbox mute <from> [1h|8h|1d] (default 8h). Approvals are never muted.', tone: 'dim' }];
|
|
180
|
+
const left = (v) => { const m = Math.ceil((v - now) / 60000); return m < 90 ? `${m}m` : m < 2880 ? `${Math.round(m / 60)}h` : `${Math.round(m / 1440)}d`; };
|
|
181
|
+
return act.map(([k, v]) => ({ text: `${k} muted until ${new Date(v).toISOString().slice(0, 16).replace('T', ' ')} UTC (${left(v)} left)`, tone: 'dim' }));
|
|
182
|
+
}
|
package/lib/quoxon/keys.js
CHANGED
|
@@ -53,7 +53,7 @@ const CSI_KEYS = {
|
|
|
53
53
|
'\x1bOA': 'up', '\x1bOB': 'down', '\x1bOC': 'right', '\x1bOD': 'left',
|
|
54
54
|
'\x1b[H': 'home', '\x1b[F': 'end', '\x1bOH': 'home', '\x1bOF': 'end',
|
|
55
55
|
'\x1b[1~': 'home', '\x1b[4~': 'end', '\x1b[7~': 'home', '\x1b[8~': 'end',
|
|
56
|
-
'\x1b[3~': 'delete', '\x1b[Z': 'shift-tab',
|
|
56
|
+
'\x1b[3~': 'delete', '\x1b[5~': 'pageup', '\x1b[6~': 'pagedown', '\x1b[Z': 'shift-tab',
|
|
57
57
|
'\x1b[13;2u': 'shift-enter', '\x1b[27;2;13~': 'shift-enter', '\x1b\r': 'shift-enter', '\x1b\n': 'shift-enter',
|
|
58
58
|
'\x1b[1;5D': 'word-left', '\x1b[1;5C': 'word-right', '\x1b[1;3D': 'word-left', '\x1b[1;3C': 'word-right',
|
|
59
59
|
// COMPOSER-EDIT (owner 2026-10-17: edit like Claude Code's prompt box). Alt+B/F (macOS Option+Left/Right), iTerm2 Alt+arrows,
|
|
@@ -69,7 +69,7 @@ const CSI_LIST = Object.keys(CSI_KEYS).sort((a, b) => b.length - a.length);
|
|
|
69
69
|
|
|
70
70
|
const CTRL = {
|
|
71
71
|
'\x01': 'home', '\x05': 'end', '\x03': 'ctrl-c', '\x04': 'ctrl-d', '\x07': 'ctrl-g', '\x12': 'ctrl-r',
|
|
72
|
-
'\x0b': 'kill-line-forward', '\x0f': 'ctrl-o', '\x13': 'ctrl-s', '\x15': 'kill-line', '\x17': 'delete-word',
|
|
72
|
+
'\x0b': 'kill-line-forward', '\x0f': 'ctrl-o', '\x13': 'ctrl-s', '\x1d': 'ctrl-]', '\x15': 'kill-line', '\x17': 'delete-word',
|
|
73
73
|
'\x1f': 'undo', '\x19': 'redo', '\x0a': 'newline', '\x7f': 'backspace', '\x08': 'backspace',
|
|
74
74
|
'\r': 'enter', '\t': 'tab', '\x02': 'left', '\x06': 'right'
|
|
75
75
|
};
|
package/lib/quoxon/launcher.js
CHANGED
|
@@ -18,6 +18,7 @@ import { spawn } from 'node:child_process';
|
|
|
18
18
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { tmpdir, constants as osConstants } from 'node:os';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
|
+
import { isWebPty } from './webPty.js';
|
|
21
22
|
|
|
22
23
|
/** The client asks its launcher for a restart with this exit code AND a handoff file (both, never one alone). */
|
|
23
24
|
export const RESTART_EXIT_CODE = 75;
|
|
@@ -46,7 +47,7 @@ export function resetLauncherHandoff() { handoff = undefined; }
|
|
|
46
47
|
|
|
47
48
|
/** Should this invocation become the launcher? Only an interactive client on a real terminal, never a launched child. */
|
|
48
49
|
export function shouldLaunch({ argv = [], env = process.env, isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY) } = {}) {
|
|
49
|
-
if (!isTTY || env[LAUNCHER_HANDOFF_ENV] || env[NO_LAUNCHER_ENV] === '1') return false;
|
|
50
|
+
if (!isTTY || env[LAUNCHER_HANDOFF_ENV] || env[NO_LAUNCHER_ENV] === '1' || isWebPty(env)) return false;
|
|
50
51
|
const head = argv.indexOf('--') === -1 ? argv : argv.slice(0, argv.indexOf('--'));
|
|
51
52
|
return !head.some(a => a === '--print' || a.startsWith('--print=') || a === '-h' || a === '--help');
|
|
52
53
|
}
|
package/lib/quoxon/loopCard.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { graphemes } from './textBuffer.js';
|
|
18
18
|
import { cellWidth } from './cells.js';
|
|
19
|
+
import { stripForeignAnsi } from './foreignText.js';
|
|
19
20
|
|
|
20
21
|
/** Below this width the card is one line (the full title is printed once in chat when the loop starts). */
|
|
21
22
|
export const NARROW_COLS = 60;
|
|
@@ -39,7 +40,7 @@ const SPIN = ['◐', '◓', '◑', '◒'];
|
|
|
39
40
|
|
|
40
41
|
/* ---- text helpers --------------------------------------------------------------------------- */
|
|
41
42
|
|
|
42
|
-
const oneLine = (s) =>
|
|
43
|
+
const oneLine = (s) => stripForeignAnsi(s).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '').replace(/\r/g, '');
|
|
43
44
|
const width = (s) => cellWidth(s);
|
|
44
45
|
|
|
45
46
|
/** Wrap at spaces; a token longer than the line is broken by cells, never dropped. Keeps blank lines. */
|
|
@@ -328,6 +329,7 @@ function stripCells(st, frame) {
|
|
|
328
329
|
const pulseRole = (st, frame) => (st.done || frame % 4 < 2 ? 'brand' : 'sky'); // the ∞ breathes while the loop runs and holds still once it is over
|
|
329
330
|
|
|
330
331
|
function headStatus(st, now, frame) {
|
|
332
|
+
if (st.refused) return [R('✖ ', 'error'), R("can't open this loop", 'error')];
|
|
331
333
|
const elapsed = st.startedAt != null ? fmtElapsed((st.finishedAt ?? now) - st.startedAt) : null;
|
|
332
334
|
const turn = `turn ${(st.iteration ?? st.turn) || 0}${st.maxTurns ? `/${st.maxTurns}` : ''}`;
|
|
333
335
|
if (st.done) {
|
|
@@ -406,7 +408,10 @@ export function cardLines(st, { cols = 80, now = Date.now(), frame = 0, expanded
|
|
|
406
408
|
if (st.approval) for (const l of wrapText(`waiting for you: ${st.approval.what}. Open your approvals to answer.`, w - 2)) lines.push([R('⚠ ', 'warn'), R(l, 'warn')]);
|
|
407
409
|
if (st.stall && !st.done) for (const l of wrapText(st.stall.text || 'no turn progress', w - 2)) lines.push([R('⚠ ', 'warn'), R(l, 'warn')]);
|
|
408
410
|
if (st.conn === 'reconnecting') lines.push([R('◐ ', 'sky'), R(`stream dropped, reconnecting (attempt ${st.connAttempt})`, 'warn')]);
|
|
409
|
-
if (st.
|
|
411
|
+
if (st.refused) for (const l of wrapText(`${st.refused}.`, w - 2)) lines.push([R('✖ ', 'error'), R(l, 'error')]);
|
|
412
|
+
else if (st.conn === 'quiet') lines.push([R('⚠ ', 'warn'), R(`no news from this loop for ${st.connNote} · the runner may be stuck · /loop status ${st.id}`, 'warn')]);
|
|
413
|
+
else if (st.conn === 'silent') for (const l of wrapText(String(st.connNote || ''), w - 2)) lines.push([R('⚠ ', 'warn'), R(l, 'warn')]);
|
|
414
|
+
else if (st.conn === 'lost') for (const l of wrapText(`lost the live stream${st.connNote ? `: ${st.connNote}` : ''}. The loop itself may still be running: quox loop status ${st.id}`, w - 2)) lines.push([R('✖ ', 'error'), R(l, 'error')]);
|
|
410
415
|
if (st.done && st.reason && st.state !== 'completed') for (const l of wrapText(`reason: ${humanReason(st.reason)}`, w - 2)) lines.push([R(' ', 'text'), R(l, FINAL[st.state]?.role || 'text')]);
|
|
411
416
|
|
|
412
417
|
// spool
|
package/lib/quoxon/loopCards.js
CHANGED
|
@@ -27,7 +27,7 @@ export function createLoopCards({ api, say, addItem, bump, dragon = null, now =
|
|
|
27
27
|
let focusId = null, cancelTick = null;
|
|
28
28
|
const throttle = createThrottle(() => bump(), { minMs: () => (focusId ? REDRAW_MIN_MS : REDRAW_UNFOCUSED_MS), now, ...(throttleTimer ? { timer: throttleTimer } : {}) });
|
|
29
29
|
|
|
30
|
-
const running = () => [...cards.values()].some((c) => !c.state.done);
|
|
30
|
+
const running = () => [...cards.values()].some((c) => !c.state.done && !c.state.refused);
|
|
31
31
|
/**
|
|
32
32
|
* A runner loop's stream `turn` counts model messages; its own turn is the snapshot's `iteration`. Read it again when a turn moves
|
|
33
33
|
* (at most every REFRESH_MIN_MS; a request inside the window is made by the next tick). -> a promise that settles when the read is over.
|
|
@@ -43,7 +43,7 @@ export function createLoopCards({ api, say, addItem, bump, dragon = null, now =
|
|
|
43
43
|
function tick() { // elapsed time and the spinner move once per tick, never faster than the throttle allows
|
|
44
44
|
if (!running()) { cancelTick?.(); cancelTick = null; return; }
|
|
45
45
|
if (!focusId && ++tickN % TICK_UNFOCUSED_EVERY) return;
|
|
46
|
-
for (const c of cards.values()) if (!c.state.done) { c.frame = (c.frame + 1) % 4; if (c.refreshDue) refresh(c); }
|
|
46
|
+
for (const c of cards.values()) if (!c.state.done && !c.state.refused) { c.frame = (c.frame + 1) % 4; if (c.refreshDue) refresh(c); }
|
|
47
47
|
throttle.poke();
|
|
48
48
|
}
|
|
49
49
|
const ensureTick = () => { if (!cancelTick && running()) cancelTick = setTick(tick, TICK_MS); };
|
|
@@ -74,16 +74,26 @@ export function createLoopCards({ api, say, addItem, bump, dragon = null, now =
|
|
|
74
74
|
throttle.poke();
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// The start line is only true once the loop has answered: a refused watch never says "started".
|
|
78
|
+
function announceStart(c) {
|
|
79
|
+
if (c.startSaid || !c.announce) return;
|
|
80
|
+
c.startSaid = true; const l = startLine(c.state); say(l.text, l.tone);
|
|
81
|
+
}
|
|
82
|
+
|
|
77
83
|
async function run(c) {
|
|
78
84
|
const ctl = c.abort = new AbortController();
|
|
79
85
|
try {
|
|
80
86
|
const out = await watch({
|
|
81
87
|
api, id: c.id, signal: ctl.signal, sleep,
|
|
82
|
-
onSnapshot: (snap) => { c.state = applySnapshot(c.state, snap); if (c.state.done) c.quiet = true; throttle.poke(); }, // the finish line waits for the replay: it needs the receipts
|
|
83
|
-
onEvent: (ev) => onEvent(c, ev),
|
|
88
|
+
onSnapshot: (snap) => { announceStart(c); c.state = applySnapshot(c.state, snap); if (c.state.done) c.quiet = true; throttle.poke(); }, // the finish line waits for the replay: it needs the receipts
|
|
89
|
+
onEvent: (ev) => { announceStart(c); onEvent(c, ev); },
|
|
84
90
|
onConn: ({ conn, attempt, note }) => { if (!c.state.done) { c.state = setConn(c.state, conn, { attempt, note }); throttle.poke(); } },
|
|
85
91
|
});
|
|
86
|
-
if (out?.lost && !c.state.done) {
|
|
92
|
+
if (out?.lost && !c.state.done) {
|
|
93
|
+
c.state = setConn(c.state, 'lost', { attempt: 0, note: out.reason });
|
|
94
|
+
// Refused (403 / 404) before a single event: the loop is not this caller's (or does not exist). Say so; it never started for us.
|
|
95
|
+
if ((out.status === 403 || out.status === 404) && c.state.startedAt == null && !c.state.turn && !c.startSaid) c.state = { ...c.state, refused: "that loop belongs to another workspace (or doesn't exist)" };
|
|
96
|
+
}
|
|
87
97
|
if (c.state.done && !c.finishedSaid && !c.finishing) { c.finishedSaid = true; for (const l of finishLines(c.state)) say(l.text, l.tone); } // finished before we looked (or the replay ended without a finish event)
|
|
88
98
|
} catch (e) {
|
|
89
99
|
c.state = setConn(c.state, 'lost', { note: e?.message || 'failed' });
|
|
@@ -95,9 +105,8 @@ export function createLoopCards({ api, say, addItem, bump, dragon = null, now =
|
|
|
95
105
|
watch(id, { title = '', agent = null, announce = true } = {}) {
|
|
96
106
|
if (!id) return null;
|
|
97
107
|
if (cards.has(id)) return cards.get(id);
|
|
98
|
-
const c = { id, state: initLoop({ id, title, agent }), expanded: false, focused: false, confirm: false, frame: 0, abort: null, finishedSaid: false };
|
|
108
|
+
const c = { id, state: initLoop({ id, title, agent }), expanded: false, focused: false, confirm: false, frame: 0, abort: null, finishedSaid: false, startSaid: false, announce };
|
|
99
109
|
cards.set(id, c);
|
|
100
|
-
if (announce) { const l = startLine(c.state); say(l.text, l.tone); } // the start line first, then the card, then the story beneath
|
|
101
110
|
addItem({ id: `loopcard-${id}`, kind: 'loopcard', card: c });
|
|
102
111
|
ensureTick();
|
|
103
112
|
run(c).then(() => bump());
|
|
@@ -130,7 +139,7 @@ export function createLoopCards({ api, say, addItem, bump, dragon = null, now =
|
|
|
130
139
|
get: (id) => cards.get(id) || null,
|
|
131
140
|
all: () => [...cards.values()],
|
|
132
141
|
/** True while this history item must stay in the live region (commitLimit): its loop has not finished. */
|
|
133
|
-
held: (item) => Boolean(item?.kind === 'loopcard' && item.card && (!item.card.state.done || item.card.focused)), // a focused card stays live even when finished, so Enter / Esc work on it
|
|
142
|
+
held: (item) => Boolean(item?.kind === 'loopcard' && item.card && ((!item.card.state.done && !item.card.state.refused) || item.card.focused)), // a focused card stays live even when finished, so Enter / Esc work on it
|
|
134
143
|
anyRunning: running,
|
|
135
144
|
/** The cards drawn above the composer: running ones, and a finished one you are still on. */
|
|
136
145
|
pinned: () => [...cards.values()].filter(loopPinned),
|
package/lib/quoxon/loopStream.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
export const BACKOFF_START_MS = 500;
|
|
11
11
|
export const BACKOFF_CAP_MS = 10000;
|
|
12
12
|
export const MAX_FAILS = 8;
|
|
13
|
+
export const QUIET_START_MS = 2000, QUIET_CAP_MS = 60000, QUIET_AFTER = 10, QUIET_RETIRE_AFTER = 20; // (retire: the snapshot still says running after this many empty closes in a row -> stop watching) // empty-close reconnects while the snapshot says running
|
|
13
14
|
const FATAL = new Set([401, 403, 404]);
|
|
14
15
|
const TERMINAL_STATES = new Set(['completed', 'completed_with_errors', 'failed', 'interrupted', 'halted', 'cancelled', 'aborted', 'stopped']);
|
|
15
16
|
const isTerminal = (snap) => Boolean(snap && (snap.finished === true || TERMINAL_STATES.has(snap.state)));
|
|
@@ -28,11 +29,12 @@ const sleepFor = (ms, signal) => new Promise((resolve) => {
|
|
|
28
29
|
* handlers: { onSnapshot(snap), onEvent(ev), onConn({ conn: 'live'|'reconnecting'|'lost', attempt, note }) }
|
|
29
30
|
* -> { finished: boolean, lost: boolean, lastSeq, reason? }
|
|
30
31
|
*/
|
|
31
|
-
export async function watchLoop({ api, id, signal, since = 0, onSnapshot = () => {}, onEvent = () => {}, onConn = () => {}, sleep = sleepFor, backoffStart = BACKOFF_START_MS, backoffCap = BACKOFF_CAP_MS, maxFails = MAX_FAILS }) {
|
|
32
|
+
export async function watchLoop({ api, id, signal, since = 0, onSnapshot = () => {}, onEvent = () => {}, onConn = () => {}, sleep = sleepFor, backoffStart = BACKOFF_START_MS, backoffCap = BACKOFF_CAP_MS, maxFails = MAX_FAILS, quietStart = QUIET_START_MS, quietCap = QUIET_CAP_MS, quietAfter = QUIET_AFTER, quietRetireAfter = QUIET_RETIRE_AFTER, now = Date.now }) {
|
|
33
|
+
let quietRuns = 0, quietSince = 0, quietSaid = false;
|
|
32
34
|
let lastSeq = Number(since) || 0, fails = 0, delay = backoffStart, finished = false;
|
|
33
35
|
try { onSnapshot(await api.loopSnapshot(id)); }
|
|
34
36
|
catch (e) { // the snapshot is a head start, not a gate: the replayed events carry the same facts. A fatal status still stops.
|
|
35
|
-
if (FATAL.has(e?.status)) { onConn({ conn: 'lost', attempt: 0, note: describe(e) }); return { finished: false, lost: true, lastSeq, reason: describe(e) }; }
|
|
37
|
+
if (FATAL.has(e?.status)) { onConn({ conn: 'lost', attempt: 0, note: describe(e) }); return { finished: false, lost: true, lastSeq, status: e?.status, reason: describe(e) }; }
|
|
36
38
|
}
|
|
37
39
|
while (!signal?.aborted && !finished) {
|
|
38
40
|
let sawEvent = false;
|
|
@@ -43,7 +45,7 @@ export async function watchLoop({ api, id, signal, since = 0, onSnapshot = () =>
|
|
|
43
45
|
const seq = Number(ev.event_seq);
|
|
44
46
|
if (Number.isFinite(seq) && seq <= lastSeq) continue; // the boundary event of a resume
|
|
45
47
|
if (Number.isFinite(seq)) lastSeq = seq;
|
|
46
|
-
if (!sawEvent) { sawEvent = true; fails = 0; delay = backoffStart; onConn({ conn: 'live', attempt: 0, note: null }); }
|
|
48
|
+
if (!sawEvent) { sawEvent = true; fails = 0; quietRuns = 0; quietSaid = false; delay = backoffStart; onConn({ conn: 'live', attempt: 0, note: null }); }
|
|
47
49
|
onEvent(ev);
|
|
48
50
|
if (ev.type === 'loop.finished') { finished = true; break; }
|
|
49
51
|
}
|
|
@@ -54,24 +56,32 @@ export async function watchLoop({ api, id, signal, since = 0, onSnapshot = () =>
|
|
|
54
56
|
let snap = null;
|
|
55
57
|
try { snap = await api.loopSnapshot(id); }
|
|
56
58
|
catch (e) {
|
|
57
|
-
if (FATAL.has(e?.status)) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, reason: describe(e) }; }
|
|
59
|
+
if (FATAL.has(e?.status)) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, status: e?.status, reason: describe(e) }; }
|
|
58
60
|
fails += 1;
|
|
59
|
-
if (fails >= maxFails) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, reason: describe(e) }; }
|
|
61
|
+
if (fails >= maxFails) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, status: e?.status, reason: describe(e) }; }
|
|
60
62
|
onConn({ conn: 'reconnecting', attempt: fails, note: describe(e) });
|
|
61
63
|
await sleep(delay, signal); delay = Math.min(delay * 2, backoffCap);
|
|
62
64
|
continue;
|
|
63
65
|
}
|
|
64
66
|
if (isTerminal(snap)) { onSnapshot(snap); return { finished: true, lost: false, lastSeq, final: snap }; }
|
|
65
|
-
fails = 0; // still running: quiet, not a failure
|
|
66
|
-
|
|
67
|
+
fails = 0; // still running: quiet, not a failure, but counted: capped backoff, and one honest line after quietAfter
|
|
68
|
+
if (quietRuns === 0) quietSince = now();
|
|
69
|
+
quietRuns += 1;
|
|
70
|
+
if (quietRuns >= quietRetireAfter) { // retirement: the snapshot (just re-read) still says running but nothing streams. Stop, say so plainly, never spin.
|
|
71
|
+
const reason = `the loop says running but its stream is silent; /loop ${id} to check, or it may have ended`;
|
|
72
|
+
onConn({ conn: 'silent', attempt: quietRuns, note: reason });
|
|
73
|
+
return { finished: false, lost: false, silent: true, lastSeq, reason };
|
|
74
|
+
}
|
|
75
|
+
if (quietRuns >= quietAfter && !quietSaid) { quietSaid = true; onConn({ conn: 'quiet', attempt: quietRuns, note: fmtDur(now() - quietSince) }); }
|
|
76
|
+
await sleep(Math.min(quietStart * 2 ** (quietRuns - 1), quietCap), signal);
|
|
67
77
|
continue;
|
|
68
78
|
}
|
|
69
79
|
else if (!finished && sawEvent) fails = 0;
|
|
70
80
|
} catch (e) {
|
|
71
81
|
if (signal?.aborted) break;
|
|
72
|
-
if (FATAL.has(e?.status)) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, reason: describe(e) }; }
|
|
82
|
+
if (FATAL.has(e?.status)) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, status: e?.status, reason: describe(e) }; }
|
|
73
83
|
fails += 1;
|
|
74
|
-
if (fails >= maxFails) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, reason: describe(e) }; }
|
|
84
|
+
if (fails >= maxFails) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, status: e?.status, reason: describe(e) }; }
|
|
75
85
|
onConn({ conn: 'reconnecting', attempt: fails, note: describe(e) });
|
|
76
86
|
await sleep(delay, signal); delay = Math.min(delay * 2, backoffCap);
|
|
77
87
|
continue;
|
|
@@ -84,4 +94,5 @@ export async function watchLoop({ api, id, signal, since = 0, onSnapshot = () =>
|
|
|
84
94
|
return { finished, lost: false, lastSeq };
|
|
85
95
|
}
|
|
86
96
|
|
|
97
|
+
const fmtDur = (ms) => { const m = Math.round(ms / 60000); return m >= 1 ? `${m} min` : `${Math.round(ms / 1000)}s`; };
|
|
87
98
|
const describe = (e) => `${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}`.trim();
|