quoxcli 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/actions.js +15 -0
- package/lib/api/endpoints/admin.js +27 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/contactsq.js +4 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/hat.js +14 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/mon.js +295 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +47 -0
- package/lib/commands/actions.js +81 -11
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agent.js +11 -4
- package/lib/commands/agentAttach.js +61 -7
- package/lib/commands/approvals.js +198 -3
- package/lib/commands/ask.js +4 -2
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/blog.js +110 -0
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +142 -28
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +7 -33
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +85 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/engagement.js +90 -0
- package/lib/commands/evidence.js +155 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fleet.js +25 -0
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/hat.js +76 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +1123 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +39 -1
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +318 -0
- package/lib/commands/lucid.js +1 -0
- package/lib/commands/mail.js +161 -20
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +55 -144
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +259 -0
- package/lib/commands/objective.js +53 -3
- package/lib/commands/offload.js +2 -3
- package/lib/commands/org.js +21 -4
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +87 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/quoxseo.js +31 -0
- package/lib/commands/research.js +79 -0
- package/lib/commands/rows.js +9 -0
- package/lib/commands/run.js +20 -2
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +150 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +24 -5
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/vm.js +2 -2
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +824 -0
- package/lib/commands/wazuhIntake.js +317 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/commands/youtube.js +172 -0
- package/lib/core/actionItems.js +115 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +37 -3
- package/lib/core/commandRegistrars.js +17 -0
- package/lib/core/config.js +29 -3
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +1 -1
- package/lib/core/governedSession.js +136 -8
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +73 -7
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +8 -8
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/shortcut.js +83 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +5 -0
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/quoxon/EXTENSIONS.md +146 -0
- package/lib/quoxon/api.js +364 -27
- package/lib/quoxon/app.js +1629 -183
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +64 -8
- package/lib/quoxon/approvalSentence.js +48 -0
- package/lib/quoxon/approvalsView.js +124 -7
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +1 -1
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +22 -3
- package/lib/quoxon/commands.js +110 -15
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/countdownLine.js +10 -1
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/demo.autoplay.js +1 -1
- package/lib/quoxon/demo.js +3 -1
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dividerAocl.js +4 -3
- package/lib/quoxon/dividerRule.js +3 -3
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +285 -0
- package/lib/quoxon/dragonModes.js +1 -0
- package/lib/quoxon/dragonMoments.js +160 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +2 -2
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +67 -10
- package/lib/quoxon/extensions.js +636 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +182 -0
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/keys.js +4 -4
- package/lib/quoxon/loopCard.js +460 -0
- package/lib/quoxon/loopCards.js +189 -0
- package/lib/quoxon/loopStream.js +98 -0
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +29 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/mouse.js +12 -1
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/objects/index.js +22 -4
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/proof.js +12 -4
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +49 -1
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +8 -3
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/qdragonFly.js +7 -6
- package/lib/quoxon/qmotion.js +17 -0
- package/lib/quoxon/render.js +187 -41
- package/lib/quoxon/roster.js +36 -17
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +244 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -0
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +10 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +18 -5
- package/lib/quoxon/slash/broadcastRun.js +13 -2
- package/lib/quoxon/slash/cliCommand.js +42 -1
- package/lib/quoxon/slash/cliLeaves.json +139 -2
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +23 -2
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +31 -4
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slash/todoCmd.js +122 -0
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +17 -0
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/suggestCommand.js +34 -3
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/toolCards.js +30 -0
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +81 -0
- package/lib/quoxon/windowOrg.js +11 -1
- package/lib/server/devServers.js +137 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +4 -2
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QLOOP-LIVE: the live loop card. Pure: events in, state out, lines out. No React, no network, no clock of its own,
|
|
3
|
+
* so every state of the card can be rendered in a test from a recorded stream.
|
|
4
|
+
*
|
|
5
|
+
* The event contract is pinned in ~/quoxon-work/qloop-live-design.md section 1:
|
|
6
|
+
* loop.started / loop.turn.started / loop.turn.output / loop.turn.tool / loop.turn.completed /
|
|
7
|
+
* loop.observe / loop.ward / loop.approval / loop.finished (each with event_seq, at, objective_id)
|
|
8
|
+
*
|
|
9
|
+
* The card is one live region per loop. It reads, top to bottom:
|
|
10
|
+
* ∞ LOOP <title> ◐ turn 3/10 · 1m12s
|
|
11
|
+
* ▕✔✔◐·······▏ receipts 2/2 witnessed ✔ host core-local · scope Read,Write
|
|
12
|
+
* │ <spool: last 3 lines, wrapped, Enter expands>
|
|
13
|
+
* ⋯ milestone: found 214 files
|
|
14
|
+
* Turn strip glyphs: ✔ ok · ⚠ denied / waiting for you · ✖ error · ◐ running · · pending.
|
|
15
|
+
* No grey text (house rule): every run uses a readable role. Nothing the user needs is cut: long text wraps.
|
|
16
|
+
*/
|
|
17
|
+
import { graphemes } from './textBuffer.js';
|
|
18
|
+
import { cellWidth } from './cells.js';
|
|
19
|
+
|
|
20
|
+
/** Below this width the card is one line (the full title is printed once in chat when the loop starts). */
|
|
21
|
+
export const NARROW_COLS = 60;
|
|
22
|
+
export const SPOOL_CAP = 8192;
|
|
23
|
+
export const SPOOL_LINES = 3;
|
|
24
|
+
export const SPOOL_LINES_EXPANDED = 16;
|
|
25
|
+
export const STRIP_MAX = 24;
|
|
26
|
+
export const REDRAW_MIN_MS = 125; // <= 8 redraws per second
|
|
27
|
+
|
|
28
|
+
export const TERMINAL = new Set(['completed', 'completed_with_errors', 'failed', 'halted', 'interrupted']);
|
|
29
|
+
const FINAL = {
|
|
30
|
+
completed: { glyph: '✔', role: 'ok', word: 'completed' },
|
|
31
|
+
completed_with_errors: { glyph: '⚠', role: 'warn', word: 'completed with errors' },
|
|
32
|
+
failed: { glyph: '✖', role: 'error', word: 'failed' },
|
|
33
|
+
halted: { glyph: '⚠', role: 'warn', word: 'halted' },
|
|
34
|
+
interrupted: { glyph: '⚠', role: 'warn', word: 'interrupted' },
|
|
35
|
+
};
|
|
36
|
+
const WITNESSED = new Set(['witnessed', 'signed', 'verified', 'ok']);
|
|
37
|
+
const BAD_WARD = new Set(['failed', 'invalid', 'rejected', 'tampered', 'error']);
|
|
38
|
+
const SPIN = ['◐', '◓', '◑', '◒'];
|
|
39
|
+
|
|
40
|
+
/* ---- text helpers --------------------------------------------------------------------------- */
|
|
41
|
+
|
|
42
|
+
const oneLine = (s) => String(s ?? '').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '').replace(/\r/g, '');
|
|
43
|
+
const width = (s) => cellWidth(s);
|
|
44
|
+
|
|
45
|
+
/** Wrap at spaces; a token longer than the line is broken by cells, never dropped. Keeps blank lines. */
|
|
46
|
+
export function wrapText(text, w) {
|
|
47
|
+
const room = Math.max(4, w);
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const raw of oneLine(text).split('\n')) {
|
|
50
|
+
if (!raw.trim()) { out.push(''); continue; }
|
|
51
|
+
let line = '';
|
|
52
|
+
for (const word of raw.split(/ +/)) {
|
|
53
|
+
if (!word) continue;
|
|
54
|
+
let rest = word;
|
|
55
|
+
while (width(rest) > room) { // hard-break an over-long token
|
|
56
|
+
if (line) { out.push(line); line = ''; }
|
|
57
|
+
let part = '', used = 0;
|
|
58
|
+
for (const g of graphemes(rest)) { const gw = width(g.text); if (used + gw > room) break; part += g.text; used += gw; }
|
|
59
|
+
if (!part) part = graphemes(rest)[0].text;
|
|
60
|
+
out.push(part); rest = rest.slice(part.length);
|
|
61
|
+
}
|
|
62
|
+
if (!line) line = rest;
|
|
63
|
+
else if (width(line) + 1 + width(rest) <= room) line += ' ' + rest;
|
|
64
|
+
else { out.push(line); line = rest; }
|
|
65
|
+
}
|
|
66
|
+
out.push(line);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function fmtElapsed(ms) {
|
|
72
|
+
const s = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
|
|
73
|
+
if (s < 60) return `${s}s`;
|
|
74
|
+
const m = Math.floor(s / 60);
|
|
75
|
+
if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`;
|
|
76
|
+
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A server stop reason for a human: the raw component prefix the spawner puts first (`fleetLoopSpawner: `) is dropped, the
|
|
81
|
+
* sentence is kept whole. Never shortened: the card wraps it.
|
|
82
|
+
*/
|
|
83
|
+
export function humanReason(r) {
|
|
84
|
+
return oneLine(r).replace(/^\s*[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*:\s+/, '').trim();
|
|
85
|
+
}
|
|
86
|
+
const STATE_ALIAS = { cancelled: 'halted', aborted: 'interrupted' };
|
|
87
|
+
const isStop = (state) => state === 'failed' || state === 'interrupted';
|
|
88
|
+
|
|
89
|
+
/* ---- state ---------------------------------------------------------------------------------- */
|
|
90
|
+
|
|
91
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : null);
|
|
92
|
+
const t0 = (v) => { const t = Date.parse(v); return Number.isFinite(t) ? t : null; };
|
|
93
|
+
|
|
94
|
+
export function initLoop({ id, title = '', agent = null } = {}) {
|
|
95
|
+
return {
|
|
96
|
+
id, title: oneLine(title), agent, state: 'running', done: false,
|
|
97
|
+
turn: 0, maxTurns: null, host: null, backend: null, scope: [],
|
|
98
|
+
startedAt: null, finishedAt: null, reason: null, turnsTotal: null,
|
|
99
|
+
turns: {}, // n -> { status: run|ok|warn|err, summary, errors, denied, ms }; n is the stream's `turn`
|
|
100
|
+
// A runner-driven loop's stream `turn` counts MODEL MESSAGES (several per runner turn: live 2026-10-26 it read "turn 9/5"). The loop's
|
|
101
|
+
// own turn is the runner's iteration: from the event when the server sends it, else the snapshot (loopCards refreshes it), else the
|
|
102
|
+
// runner's own "(turn N of M)" in an observe line. null = not a runner loop (or not known yet): the stream's turn is then the turn.
|
|
103
|
+
iteration: null,
|
|
104
|
+
receipts: {}, // envelope_id -> witnessed|pending|failed
|
|
105
|
+
finalReceipts: null, // { total, witnessed } from loop.finished or the snapshot: the server's own count
|
|
106
|
+
spool: '', spoolTurn: 0,
|
|
107
|
+
milestone: null, approval: null, stall: null,
|
|
108
|
+
conn: 'live', connAttempt: 0, connNote: null,
|
|
109
|
+
lastSeq: 0, lastEventAt: null,
|
|
110
|
+
observed: [], // lines appended to chat, kept for tests and /loop peek
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const wardOf = (ev) => ev.status ?? (typeof ev.ward === 'string' ? ev.ward : ev.ward?.status) ?? ev.ward_status;
|
|
115
|
+
/** A receipt's status never goes backwards: a late `pending` (the turn's own completion arriving after the ward event) must not un-witness it. */
|
|
116
|
+
const setReceipt = (s, id, cls) => { if (id && !(s.receipts[id] === 'witnessed' && cls === 'pending')) s.receipts[id] = cls; };
|
|
117
|
+
const wardClass = (status) => { const s = String(status || '').toLowerCase(); return WITNESSED.has(s) ? 'witnessed' : BAD_WARD.has(s) ? 'failed' : 'pending'; };
|
|
118
|
+
|
|
119
|
+
/** A loop that stopped (failed / interrupted) failed at the turn it was on; one that was halted was cut off. The rest did not run. */
|
|
120
|
+
function markStopped(s) {
|
|
121
|
+
const t = s.turns[s.turn]; if (!t) return;
|
|
122
|
+
if (isStop(s.state) && t.status !== 'err') s.turns[s.turn] = { ...t, status: 'err' };
|
|
123
|
+
else if (s.state === 'halted' && t.status === 'run') s.turns[s.turn] = { ...t, status: 'warn' };
|
|
124
|
+
else if (t.status === 'run') s.turns[s.turn] = { ...t, status: s.state === 'completed' ? 'ok' : 'warn' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The snapshot (GET /api/v1/loops/:id) -> state. Tolerant of field spellings; never invents a field it was not given. */
|
|
128
|
+
export function applySnapshot(st, snap) {
|
|
129
|
+
if (!snap || typeof snap !== 'object') return st;
|
|
130
|
+
const s = { ...st, turns: { ...st.turns }, receipts: { ...st.receipts } };
|
|
131
|
+
const loop = snap.loop && typeof snap.loop === 'object' ? { ...snap, ...snap.loop } : snap;
|
|
132
|
+
if (loop.title && !s.title) s.title = oneLine(loop.title);
|
|
133
|
+
const state0 = loop.state || loop.status, state = STATE_ALIAS[state0] || state0;
|
|
134
|
+
if (state && TERMINAL.has(state)) { s.state = state; s.done = true; s.reason = loop.reason ? humanReason(loop.reason) : s.reason; }
|
|
135
|
+
const it = num(loop.iteration); if (it != null) s.iteration = Math.max(s.iteration ?? 0, it);
|
|
136
|
+
const turn = it != null ? null : num(loop.turn ?? loop.current_turn); if (turn != null) s.turn = Math.max(s.turn, turn);
|
|
137
|
+
const max = num(loop.max_turns ?? loop.max); if (max != null) s.maxTurns = max;
|
|
138
|
+
if (loop.host) s.host = String(loop.host);
|
|
139
|
+
if (loop.backend) s.backend = String(loop.backend);
|
|
140
|
+
if (Array.isArray(loop.scope)) s.scope = loop.scope.map(String);
|
|
141
|
+
const started = t0(loop.started_at ?? loop.at); if (started != null) s.startedAt = started;
|
|
142
|
+
const fin = t0(loop.finished_at); if (fin != null) s.finishedAt = fin;
|
|
143
|
+
const runnerLoop = loop.iteration != null || loop.until != null; // its last_turns are keyed by the message counter: not turns
|
|
144
|
+
for (const t of runnerLoop ? [] : (Array.isArray(loop.turns) ? loop.turns : (Array.isArray(loop.last_turns) ? loop.last_turns : []))) {
|
|
145
|
+
const n = num(t.turn ?? t.n); if (n == null || s.turns[n]) continue;
|
|
146
|
+
const ts = String(t.status || t.state || '').toLowerCase();
|
|
147
|
+
const tools = Array.isArray(t.tools) ? t.tools : [];
|
|
148
|
+
const byTools = tools.some((x) => x && x.denied) ? 'warn' : tools.some((x) => x && x.ok === false) ? 'err' : 'ok';
|
|
149
|
+
s.turns[n] = { status: /err|fail/.test(ts) ? 'err' : /deny|warn|approval/.test(ts) ? 'warn' : /run|start/.test(ts) ? 'run' : byTools, summary: oneLine(t.summary || ''), errors: 0, denied: null };
|
|
150
|
+
const w = t.ward?.status ?? t.ward_status ?? (typeof t.ward === 'string' ? t.ward : null); if (w && t.envelope_id) setReceipt(s, t.envelope_id, wardClass(w));
|
|
151
|
+
}
|
|
152
|
+
// The snapshot's receipts: { total, witnessed, items:[{ envelope_id, status }] }. Items fill the map; the totals are the server's own count.
|
|
153
|
+
if (loop.receipts && typeof loop.receipts === 'object') {
|
|
154
|
+
for (const it of Array.isArray(loop.receipts.items) ? loop.receipts.items : []) if (it?.envelope_id) setReceipt(s, it.envelope_id, wardClass(it.status));
|
|
155
|
+
const total = num(loop.receipts.total), wit = num(loop.receipts.witnessed);
|
|
156
|
+
if (total != null && wit != null) s.finalReceipts = { total, witnessed: wit };
|
|
157
|
+
}
|
|
158
|
+
// NOT done here: the snapshot's last_event_seq is the NEWEST event; adopting it would drop the replayed events the stream is about to send.
|
|
159
|
+
if (s.done) markStopped(s);
|
|
160
|
+
return s;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A snapshot read only for the runner's progress (its own turn and budget): never changes the loop's state or the replay position. */
|
|
164
|
+
export function applyProgress(st, snap) {
|
|
165
|
+
const loop = snap && typeof snap === 'object' ? (snap.loop && typeof snap.loop === 'object' ? { ...snap, ...snap.loop } : snap) : null;
|
|
166
|
+
if (!loop) return st;
|
|
167
|
+
const it = num(loop.iteration), max = num(loop.max_turns ?? loop.max);
|
|
168
|
+
if ((it == null || it <= (st.iteration ?? 0)) && (max == null || max === st.maxTurns)) return st;
|
|
169
|
+
return { ...st, iteration: it != null ? Math.max(st.iteration ?? 0, it) : st.iteration, maxTurns: max ?? st.maxTurns };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* One event -> new state. Pure. An event with event_seq <= lastSeq has been seen (a resume replays the boundary): ignored.
|
|
174
|
+
*/
|
|
175
|
+
export function reduceLoop(st, ev) {
|
|
176
|
+
if (!ev || typeof ev.type !== 'string') return st;
|
|
177
|
+
const seq = num(ev.event_seq);
|
|
178
|
+
if (seq != null && seq <= st.lastSeq) return st;
|
|
179
|
+
const s = { ...st, turns: { ...st.turns }, receipts: { ...st.receipts }, observed: st.observed };
|
|
180
|
+
if (seq != null) s.lastSeq = seq;
|
|
181
|
+
const at = t0(ev.at); if (at != null) s.lastEventAt = at;
|
|
182
|
+
s.conn = 'live'; s.connAttempt = 0; s.connNote = null;
|
|
183
|
+
const turn = num(ev.turn);
|
|
184
|
+
const iter = num(ev.iteration ?? ev.loop_turn); if (iter != null) s.iteration = Math.max(s.iteration ?? 0, iter);
|
|
185
|
+
switch (ev.type) {
|
|
186
|
+
case 'loop.started':
|
|
187
|
+
s.startedAt = at ?? s.startedAt; s.host = ev.host ?? s.host; s.backend = ev.backend ?? s.backend;
|
|
188
|
+
if (Array.isArray(ev.scope)) s.scope = ev.scope.map(String);
|
|
189
|
+
s.maxTurns = num(ev.max_turns) ?? s.maxTurns; break;
|
|
190
|
+
case 'loop.turn.started':
|
|
191
|
+
if (turn != null) { s.turn = turn; s.turns[turn] = { status: 'run', summary: '', errors: 0, denied: null, startedAt: at }; s.spool = ''; s.spoolTurn = turn; }
|
|
192
|
+
s.stall = null; s.approval = null; break;
|
|
193
|
+
case 'loop.turn.output': {
|
|
194
|
+
if (turn != null && turn !== s.spoolTurn) { s.spool = ''; s.spoolTurn = turn; }
|
|
195
|
+
const joined = s.spool + oneLine(ev.chunk).replace(/\u001b\[[0-9;]*[A-Za-z]/g, '');
|
|
196
|
+
s.spool = joined.length > SPOOL_CAP ? joined.slice(-SPOOL_CAP) : joined;
|
|
197
|
+
s.stall = null; s.approval = null; break; // output means it is moving again: no longer waiting for you
|
|
198
|
+
}
|
|
199
|
+
case 'loop.turn.tool': {
|
|
200
|
+
const t = turn != null && s.turns[turn] ? { ...s.turns[turn] } : null;
|
|
201
|
+
s.stall = null; s.approval = null;
|
|
202
|
+
if (t) { if (ev.denied) t.denied = String(ev.denied); else if (ev.ok === false) t.errors = (t.errors || 0) + 1; if (ev.note) t.noted = oneLine(ev.note); s.turns[turn] = t; }
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case 'loop.turn.completed': {
|
|
206
|
+
if (turn == null) break;
|
|
207
|
+
const prev = s.turns[turn] || { errors: 0, denied: null };
|
|
208
|
+
s.turns[turn] = { ...prev, status: prev.errors ? 'err' : (prev.denied || prev.noted) ? 'warn' : 'ok', summary: oneLine(ev.summary || '').slice(0, 200) };
|
|
209
|
+
setReceipt(s, ev.envelope_id, wardClass(wardOf({ ward: ev.ward, ward_status: ev.ward_status })));
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'loop.ward':
|
|
213
|
+
setReceipt(s, ev.envelope_id, wardClass(wardOf(ev)));
|
|
214
|
+
break;
|
|
215
|
+
case 'loop.observe':
|
|
216
|
+
{ // the runner states its budget and the turn it is about to take in its own words ("next check in 14s (turn 2 of 5)")
|
|
217
|
+
const m = /\(turn\s+(\d+)\s+of\s+(\d+)\)/i.exec(String(ev.text || ''));
|
|
218
|
+
if (m) { if (!s.maxTurns) s.maxTurns = Number(m[2]); s.iteration = Math.max(s.iteration ?? 0, Number(m[1]) - 1); }
|
|
219
|
+
}
|
|
220
|
+
if (ev.kind === 'milestone') s.milestone = oneLine(ev.text);
|
|
221
|
+
if (ev.kind === 'stall') s.stall = { text: oneLine(ev.text), at };
|
|
222
|
+
if (ev.kind === 'error' && turn != null && s.turns[turn]) s.turns[turn] = { ...s.turns[turn], errors: Math.max(1, s.turns[turn].errors || 0) };
|
|
223
|
+
break;
|
|
224
|
+
case 'loop.approval':
|
|
225
|
+
s.approval = { id: ev.checkpoint_id ?? null, what: oneLine(ev.what) };
|
|
226
|
+
if (s.turn && s.turns[s.turn]) s.turns[s.turn] = { ...s.turns[s.turn], status: s.turns[s.turn].status === 'run' ? 'warn' : s.turns[s.turn].status, waiting: true };
|
|
227
|
+
break;
|
|
228
|
+
case 'loop.finished':
|
|
229
|
+
s.state = TERMINAL.has(STATE_ALIAS[ev.state] || ev.state) ? (STATE_ALIAS[ev.state] || ev.state) : 'failed'; s.done = true; s.finishedAt = at ?? s.finishedAt;
|
|
230
|
+
s.turnsTotal = num(ev.turns) ?? s.turn; // the server's count; for a runner loop it is a message count, so the renderers prefer `iteration`
|
|
231
|
+
s.reason = ev.reason ? humanReason(ev.reason) : s.reason;
|
|
232
|
+
s.approval = null; s.stall = null;
|
|
233
|
+
if (num(ev.receipts) != null) s.finalReceipts = { total: num(ev.receipts), witnessed: num(ev.witnessed) ?? 0 };
|
|
234
|
+
markStopped(s);
|
|
235
|
+
break;
|
|
236
|
+
default: return st; // unknown event types are ignored, never fatal (forward compatible)
|
|
237
|
+
}
|
|
238
|
+
return s;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The connection to the stream (not the loop) changed. */
|
|
242
|
+
export const setConn = (st, conn, { attempt = 0, note = null } = {}) => ({ ...st, conn, connAttempt: attempt, connNote: note });
|
|
243
|
+
|
|
244
|
+
export function receiptCounts(st) {
|
|
245
|
+
const v = Object.values(st.receipts);
|
|
246
|
+
const own = { total: v.length, witnessed: v.filter(x => x === 'witnessed').length, failed: v.filter(x => x === 'failed').length };
|
|
247
|
+
const f = st.finalReceipts; // the server's own count wins when it is higher: a ward event may have been missed, never invented
|
|
248
|
+
return f && (f.total > own.total || f.witnessed > own.witnessed) ? { ...own, total: Math.max(f.total, own.total), witnessed: Math.max(f.witnessed, own.witnessed) } : own;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Where the card is drawn. A running loop's card (and a finished one you are still on) is PINNED above the composer, so a long
|
|
253
|
+
* answer or an approval card can never push it off the screen; its history slot is empty until it is let go, and then holds the
|
|
254
|
+
* frozen card in place, where the story was told.
|
|
255
|
+
*/
|
|
256
|
+
export const loopPinned = (card) => Boolean(card && (!card.state.done || card.focused));
|
|
257
|
+
|
|
258
|
+
/* ---- chat lines (the loop's story) ---------------------------------------------------------- */
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The chat line an event appends beneath the card, or null. -> { text, tone? }. Observer points are the server's decision;
|
|
262
|
+
* this only formats what it sent. A turn completion with no observer point stays inside the card.
|
|
263
|
+
*/
|
|
264
|
+
export function observerLine(ev, st) {
|
|
265
|
+
if (!ev) return null;
|
|
266
|
+
const turn = st?.iteration ?? num(ev.turn) ?? st?.turn ?? 0; // the loop's own turn when it has one
|
|
267
|
+
if (ev.type === 'loop.observe') {
|
|
268
|
+
const text = oneLine(ev.text);
|
|
269
|
+
switch (ev.kind) {
|
|
270
|
+
case 'milestone': return { text: `∞ turn ${turn} │ milestone: ${text}` };
|
|
271
|
+
case 'error': return { text: `∞ turn ${turn} │ ${text}`, tone: 'error' };
|
|
272
|
+
case 'approval': return { text: `∞ turn ${turn} │ waiting for you: ${text}`, tone: 'warn' };
|
|
273
|
+
case 'stall': return { text: `∞ turn ${turn} │ ${text || 'no progress'}`, tone: 'warn' };
|
|
274
|
+
default: return { text: `∞ turn ${turn} │ ${text} ✔` };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (ev.type === 'loop.approval') return { text: `∞ turn ${turn} │ paused: ${oneLine(ev.what)}. It is in your approvals.`, tone: 'warn' };
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The finish line: `∞ QLOOP │ <title> │ <state>, n turns, n/n witnessed`. Always followed by the proof command. */
|
|
282
|
+
export function finishLines(st) {
|
|
283
|
+
const f = FINAL[st.state] || FINAL.failed;
|
|
284
|
+
const r = receiptCounts(st), n = st.iteration ?? st.turnsTotal ?? st.turn;
|
|
285
|
+
const witnessed = r.total ? `${r.witnessed}/${r.total} witnessed` : 'no receipts yet';
|
|
286
|
+
const text = `∞ QLOOP │ ${st.title || st.id} │ ${f.word}, ${n} turn${n === 1 ? '' : 's'}, ${witnessed}${st.reason && st.state !== 'completed' ? ` (${humanReason(st.reason)})` : ''}`;
|
|
287
|
+
return [{ text, tone: st.state === 'completed' ? 'ok' : st.state === 'failed' ? 'error' : 'warn' }, { text: `Proof: quox loop status ${st.id}` }];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function startLine(st) {
|
|
291
|
+
return { text: `∞ QLOOP │ ${st.title || `loop ${st.id}`} │ started${st.host ? ` on ${st.host}` : ''}. /loop peek ${st.id} for detail.` };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* ---- the card ------------------------------------------------------------------------------- */
|
|
295
|
+
|
|
296
|
+
const R = (t, role, bold) => ({ t, role, ...(bold ? { bold: true } : {}) });
|
|
297
|
+
const runWidth = (rs) => rs.reduce((n, r) => n + width(r.t), 0);
|
|
298
|
+
|
|
299
|
+
/** A runner loop knows its own turn (the iteration): turns before it are done, it is the one running, the rest are pending. */
|
|
300
|
+
function runnerCell(st, n, frame) {
|
|
301
|
+
const it = st.iteration;
|
|
302
|
+
if (n < it) return R('✔', 'ok');
|
|
303
|
+
if (n > it) return R('·', 'prose');
|
|
304
|
+
if (st.done) return st.state === 'completed' ? R('✔', 'ok') : isStop(st.state) ? R('✖', 'error') : R('⚠', 'warn');
|
|
305
|
+
return st.approval ? R('⚠', 'warn') : R(SPIN[frame % 4], 'sky');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function stripCells(st, frame) {
|
|
309
|
+
let max = st.maxTurns || Math.max(st.iteration ?? st.turn, 1);
|
|
310
|
+
if (st.done && st.state === 'completed') max = Math.max(1, Math.min(max, st.iteration ?? (st.turnsTotal || st.turn || 1))); // it finished early: the runway it never used is not shown
|
|
311
|
+
const shown = Math.min(max, STRIP_MAX);
|
|
312
|
+
const first = max > STRIP_MAX ? Math.max(1, Math.min((st.iteration ?? st.turn) || 1, max) - Math.floor(STRIP_MAX / 2) + 1) : 1;
|
|
313
|
+
const start = Math.min(first, max - shown + 1);
|
|
314
|
+
const out = [];
|
|
315
|
+
for (let n = start; n < start + shown; n++) {
|
|
316
|
+
if (st.iteration != null) { out.push(runnerCell(st, n, frame)); continue; }
|
|
317
|
+
const t = st.turns[n];
|
|
318
|
+
if (!t) { out.push(R('·', 'prose')); continue; }
|
|
319
|
+
if (t.status === 'ok') out.push(R('✔', 'ok'));
|
|
320
|
+
else if (t.status === 'err') out.push(R('✖', 'error'));
|
|
321
|
+
else if (t.status === 'warn') out.push(R('⚠', 'warn'));
|
|
322
|
+
else out.push(R(st.done ? '⚠' : SPIN[frame % 4], st.done ? 'warn' : 'sky'));
|
|
323
|
+
}
|
|
324
|
+
return { cells: out, hidden: start > 1 };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Head status: glyph + words. */
|
|
328
|
+
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
|
+
function headStatus(st, now, frame) {
|
|
331
|
+
if (st.refused) return [R('✖ ', 'error'), R("can't open this loop", 'error')];
|
|
332
|
+
const elapsed = st.startedAt != null ? fmtElapsed((st.finishedAt ?? now) - st.startedAt) : null;
|
|
333
|
+
const turn = `turn ${(st.iteration ?? st.turn) || 0}${st.maxTurns ? `/${st.maxTurns}` : ''}`;
|
|
334
|
+
if (st.done) {
|
|
335
|
+
const f = FINAL[st.state] || FINAL.failed, n = st.iteration ?? st.turnsTotal ?? st.turn;
|
|
336
|
+
return [R(`${f.glyph} `, f.role), R(`${f.word} · ${n} turn${n === 1 ? '' : 's'}${elapsed ? ` · ${elapsed}` : ''}`, f.role)];
|
|
337
|
+
}
|
|
338
|
+
if (st.startedAt == null && !st.turn) return [R('◐ ', 'sky'), R('waiting to start (approve it in /approvals)', 'warn')];
|
|
339
|
+
if (st.approval) return [R('⚠ ', 'warn'), R(`waiting for you · ${turn}${elapsed ? ` · ${elapsed}` : ''}`, 'warn')];
|
|
340
|
+
if (st.stall) return [R('⚠ ', 'warn'), R(`stalled · ${turn}${elapsed ? ` · ${elapsed}` : ''}`, 'warn')];
|
|
341
|
+
return [R(`${SPIN[frame % 4]} `, 'sky'), R(`${turn}${elapsed ? ` · ${elapsed}` : ''}`, 'text')];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The card as lines of coloured runs: [[{t, role, bold?}, ...], ...]. Width-aware; every line fits `cols`.
|
|
346
|
+
* opts: { cols, now, frame, expanded, focused, confirm, hint } (hint: the pinned card says how to reach its keys)
|
|
347
|
+
*/
|
|
348
|
+
export function cardLines(st, { cols = 80, now = Date.now(), frame = 0, expanded = false, focused = false, confirm = false, hint = false } = {}) {
|
|
349
|
+
const w = Math.max(20, cols - 2); // the view indents the card by 2
|
|
350
|
+
const r = receiptCounts(st);
|
|
351
|
+
const lines = [];
|
|
352
|
+
const rc = r.total ? `${r.witnessed}/${r.total}` : '0/0';
|
|
353
|
+
const rcRole = r.failed ? 'error' : r.total && r.witnessed === r.total ? 'ok' : 'text';
|
|
354
|
+
const rcMark = r.failed ? ' ✖' : r.total && r.witnessed === r.total ? ' ✔' : r.total ? ' ◐' : '';
|
|
355
|
+
|
|
356
|
+
if (cols < NARROW_COLS) { // one line: the full title was printed when the loop started
|
|
357
|
+
const head = headStatus(st, now, frame);
|
|
358
|
+
const tail = [R(` ${rc}${rcMark}`, rcRole)];
|
|
359
|
+
const used = 2 + runWidth(head) + runWidth(tail) + 1;
|
|
360
|
+
const title = oneLine(st.title || st.id);
|
|
361
|
+
const room = Math.max(0, w - used);
|
|
362
|
+
let shown = title;
|
|
363
|
+
if (width(title) > room) { // the first cells that fit, then an ellipsis; the full title is one Enter away and in the start line
|
|
364
|
+
shown = ''; let sw = 0;
|
|
365
|
+
for (const x of graphemes(title)) { const gw = width(x.text); if (sw + gw > room - 1) break; shown += x.text; sw += gw; }
|
|
366
|
+
shown = room > 1 ? shown + '…' : '';
|
|
367
|
+
}
|
|
368
|
+
lines.push([R('∞ ', pulseRole(st, frame), true), ...head, ...tail, ...(shown ? [R(' ' + shown, 'text')] : [])]);
|
|
369
|
+
if (expanded) for (const l of wrapText(st.title || st.id, w - 2)) lines.push([R(' ' + l, 'text')]);
|
|
370
|
+
if (confirm) lines.push([R('Halt this loop? y to halt, any other key cancels', 'warn')]);
|
|
371
|
+
return lines;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// line 1: ∞ LOOP <title> ... status
|
|
375
|
+
const head = headStatus(st, now, frame);
|
|
376
|
+
const lead = [R('∞ ', pulseRole(st, frame), true), R('LOOP', 'brand', true), R(' ', 'text')];
|
|
377
|
+
const title = oneLine(st.title || st.id);
|
|
378
|
+
if (runWidth(lead) + width(title) + 2 + runWidth(head) <= w) {
|
|
379
|
+
const pad = w - runWidth(lead) - width(title) - runWidth(head);
|
|
380
|
+
lines.push([...lead, R(title, 'text', true), R(' '.repeat(pad), 'text'), ...head]);
|
|
381
|
+
} else { // a long title never shares the line with the status; it wraps under it
|
|
382
|
+
lines.push([...lead.slice(0, 2), R(' '.repeat(Math.max(1, w - 6 - runWidth(head))), 'text'), ...head]);
|
|
383
|
+
const wrapped = wrapText(title, w - 2); // capped at 2 lines (Enter expands): a long title is not laid out in full on every frame
|
|
384
|
+
const tl = expanded ? wrapped : wrapped.slice(0, 2);
|
|
385
|
+
if (tl.length < wrapped.length) { const last = [...graphemes(tl[1])]; while (last.length && width(last.map((x) => x.text).join('')) > w - 4) last.pop(); tl[1] = last.map((x) => x.text).join('') + '…'; }
|
|
386
|
+
for (const l of tl) lines.push([R(' ' + l, 'text', true)]);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// line 2: strip + receipts + where
|
|
390
|
+
const { cells, hidden } = stripCells(st, frame);
|
|
391
|
+
const strip = [R('▕', 'brand'), ...(hidden ? [R('…', 'prose')] : []), ...cells, R('▏', 'brand')];
|
|
392
|
+
const barN = Math.min(r.total, 8), barFill = r.total ? Math.round((r.witnessed / r.total) * barN) : 0;
|
|
393
|
+
const receipts = [R(' receipts ', 'label'), R(rc, rcRole, true), R(' witnessed' + rcMark, rcRole), ...(barN ? [R(' ' + '▰'.repeat(barFill) + '▱'.repeat(barN - barFill), rcRole)] : [])]; // fills as the receipts land
|
|
394
|
+
const where = [];
|
|
395
|
+
if (st.host) where.push(R('host ', 'label'), R(st.host, 'text'));
|
|
396
|
+
if (st.scope.length) { if (where.length) where.push(R(' · ', 'prose')); where.push(R('scope ', 'label'), R(st.scope.join(','), 'text')); }
|
|
397
|
+
const base = [...strip, ...receipts];
|
|
398
|
+
if (!where.length) lines.push(base);
|
|
399
|
+
else if (runWidth(base) + 3 + runWidth(where) <= w) lines.push([...base, R(' ', 'text'), ...where]);
|
|
400
|
+
else { lines.push(base); lines.push([R(' ', 'text'), ...where]); } // the scope list may wrap further
|
|
401
|
+
if (lines[lines.length - 1].length && runWidth(lines[lines.length - 1]) > w) { // a very long scope: wrap it as plain text
|
|
402
|
+
const last = lines.pop(); const flat = last.map(x => x.t).join('');
|
|
403
|
+
for (const l of wrapText(flat.trim(), w - 2)) lines.push([R(' ' + l, 'text')]);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// waiting / stalled / connection
|
|
407
|
+
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')]);
|
|
408
|
+
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')]);
|
|
409
|
+
if (st.conn === 'reconnecting') lines.push([R('◐ ', 'sky'), R(`stream dropped, reconnecting (attempt ${st.connAttempt})`, 'warn')]);
|
|
410
|
+
if (st.refused) for (const l of wrapText(`${st.refused}.`, w - 2)) lines.push([R('✖ ', 'error'), R(l, 'error')]);
|
|
411
|
+
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')]);
|
|
412
|
+
else if (st.conn === 'silent') for (const l of wrapText(String(st.connNote || ''), w - 2)) lines.push([R('⚠ ', 'warn'), R(l, 'warn')]);
|
|
413
|
+
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')]);
|
|
414
|
+
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')]);
|
|
415
|
+
|
|
416
|
+
// spool
|
|
417
|
+
// The milestone is said once: the spool's copy of it is dropped (the ticker line below carries it) and, when the loop is over
|
|
418
|
+
// and the chat story already said it, the ticker goes too.
|
|
419
|
+
let spoolText = st.spool.replace(/\s+$/g, '');
|
|
420
|
+
if (st.milestone) {
|
|
421
|
+
const words = st.milestone.trim().split(/\s+/).map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
422
|
+
if (words.length) spoolText = spoolText.replace(new RegExp(`(?:MILESTONE:\\s*)?${words.join('\\s+')}`, 'i'), '').replace(/\n{3,}/g, '\n\n').replace(/\s+$/g, '');
|
|
423
|
+
}
|
|
424
|
+
if (spoolText) {
|
|
425
|
+
const all = wrapText(spoolText, w - 4).filter((l, i, a) => l || (i > 0 && i < a.length - 1));
|
|
426
|
+
const keep = expanded ? SPOOL_LINES_EXPANDED : SPOOL_LINES;
|
|
427
|
+
const tail = all.slice(-keep), cut = all.length - tail.length;
|
|
428
|
+
if (cut > 0) lines.push([R('│ ', 'brand'), R(`${cut} earlier line${cut === 1 ? '' : 's'}${expanded ? '' : ' (Enter shows more)'}`, 'label')]);
|
|
429
|
+
tail.forEach((l) => lines.push([R('│ ', 'brand'), R(l, 'text')])); // no per-line prefix beyond the bar: a prefix would push the wrap past the width
|
|
430
|
+
}
|
|
431
|
+
if (st.milestone && !(st.done && st.observed.some((l) => String(l).includes(st.milestone)))) for (const l of wrapText(`milestone: ${st.milestone}`, w - 2)) lines.push([R('⋯ ', 'violet'), R(l, 'text')]);
|
|
432
|
+
if (expanded && !spoolText) lines.push([R('│ ', 'brand'), R(st.done ? 'no output was captured' : 'no output yet this turn', 'label')]);
|
|
433
|
+
|
|
434
|
+
if (confirm) lines.push([R('⚠ ', 'warn'), R('Halt this loop? y to halt, any other key cancels', 'warn', true)]);
|
|
435
|
+
else if (focused && !st.done) lines.push([R('Enter ', 'key'), R('expand output · ', 'label'), R('s ', 'key'), R('halt · ', 'label'), R('Esc ', 'key'), R('collapse', 'label')]);
|
|
436
|
+
else if (focused) lines.push([R('Enter ', 'key'), R('expand · ', 'label'), R('Esc ', 'key'), R('back', 'label')]);
|
|
437
|
+
else if (hint && !st.done) lines.push([R('↓ ', 'key'), R('to control this loop (Enter expand · s halt)', 'label')]); // an EMPTY composer + Down focuses it; no letter is ever taken from a draft
|
|
438
|
+
return lines;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/* ---- redraw throttle ------------------------------------------------------------------------ */
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Coalesces calls so `fn` runs at most once per `minMs` (trailing edge kept, so the last state always lands).
|
|
445
|
+
* `timer(fn, ms)` returns a cancel function; injectable for tests.
|
|
446
|
+
*/
|
|
447
|
+
export const REDRAW_UNFOCUSED_MS = 500; // a card nobody is focused on redraws at most twice a second
|
|
448
|
+
export function createThrottle(fn, { minMs = REDRAW_MIN_MS, now = Date.now, timer = (f, ms) => { const t = setTimeout(f, ms); t.unref?.(); return () => clearTimeout(t); } } = {}) {
|
|
449
|
+
let last = -Infinity, cancel = null, pending = false;
|
|
450
|
+
const fire = () => { cancel = null; pending = false; last = now(); fn(); };
|
|
451
|
+
return {
|
|
452
|
+
poke() {
|
|
453
|
+
if (cancel) { pending = true; return; }
|
|
454
|
+
const wait = last + (typeof minMs === 'function' ? minMs() : minMs) - now();
|
|
455
|
+
if (wait <= 0) fire(); else { pending = true; cancel = timer(fire, wait); }
|
|
456
|
+
},
|
|
457
|
+
dispose() { cancel?.(); cancel = null; pending = false; },
|
|
458
|
+
get pending() { return pending; },
|
|
459
|
+
};
|
|
460
|
+
}
|