toolroll 0.9.63 → 0.9.67
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 +108 -0
- package/README.md +33 -5
- package/dist/acp-agents.d.ts +43 -0
- package/dist/acp-agents.js +62 -0
- package/dist/acp-attest.d.ts +44 -0
- package/dist/acp-attest.js +269 -0
- package/dist/acp-check.d.ts +28 -0
- package/dist/acp-check.js +410 -0
- package/dist/acp-client.d.ts +163 -0
- package/dist/acp-client.js +738 -0
- package/dist/acp-inspect.d.ts +23 -0
- package/dist/acp-inspect.js +109 -0
- package/dist/agent-board.d.ts +19 -6
- package/dist/agent-board.js +30 -9
- package/dist/agent-fence.d.ts +117 -2
- package/dist/agent-fence.js +439 -12
- package/dist/agent-pulse.js +9 -2
- package/dist/agent-state-words.d.ts +19 -0
- package/dist/agent-state-words.js +20 -0
- package/dist/agent-state.d.ts +65 -0
- package/dist/agent-state.js +229 -0
- package/dist/agentconfig.js +39 -13
- package/dist/agents-board.d.ts +34 -2
- package/dist/agents-board.js +110 -1
- package/dist/approval-rules-ui.js +2 -2
- package/dist/assignment-delivery.d.ts +14 -0
- package/dist/assignment-delivery.js +13 -0
- package/dist/assignment.js +13 -0
- package/dist/attest.js +7 -0
- package/dist/backup-ui.js +2 -2
- package/dist/batch-checks.js +3 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
- package/dist/browser/terminal.css +37 -0
- package/dist/browser/terminal.js +228 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +42 -38
- package/dist/browser-shell.js +6 -3
- package/dist/browser-workspace.d.ts +235 -1
- package/dist/browser-workspace.js +60 -0
- package/dist/builder.d.ts +10 -1
- package/dist/builder.js +87 -24
- package/dist/chat-actions.d.ts +13 -1
- package/dist/chat-actions.js +197 -15
- package/dist/chat-ask.d.ts +4 -0
- package/dist/chat-ask.js +14 -0
- package/dist/chat-channel.js +20 -0
- package/dist/chat-continuity.js +5 -4
- package/dist/chat-decide.d.ts +0 -14
- package/dist/chat-decide.js +4 -1
- package/dist/chat-delivery.d.ts +9 -0
- package/dist/chat-delivery.js +45 -6
- package/dist/chat-messages.d.ts +8 -0
- package/dist/chat-messages.js +36 -4
- package/dist/check-levels-ui.js +2 -2
- package/dist/checkout-proof.d.ts +28 -0
- package/dist/checkout-proof.js +62 -0
- package/dist/claim.d.ts +12 -0
- package/dist/claim.js +19 -0
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +4 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +21 -2
- package/dist/coding-ui.js +6 -4
- package/dist/console-instance.d.ts +124 -0
- package/dist/console-instance.js +337 -0
- package/dist/contracts/chat-actions.d.ts +16 -0
- package/dist/contracts/chat-actions.js +4 -0
- package/dist/contracts/chat-content.d.ts +19 -0
- package/dist/contracts/chat-content.js +6 -0
- package/dist/contracts/console-api.d.ts +13 -4
- package/dist/contracts/console-api.js +13 -4
- package/dist/contracts/flow-card.d.ts +4 -0
- package/dist/contracts/flow-card.js +3 -0
- package/dist/contracts/flow.d.ts +46 -0
- package/dist/contracts/flow.js +11 -2
- package/dist/contracts/lead-tools.d.ts +92 -0
- package/dist/contracts/lead-tools.js +13 -0
- package/dist/contracts/provider.d.ts +11 -1
- package/dist/contracts/provider.js +1 -1
- package/dist/contracts/route.d.ts +76 -1
- package/dist/contracts/route.js +1 -1
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/control-setup.d.ts +1 -1
- package/dist/control-ui.js +2 -2
- package/dist/converse.d.ts +16 -0
- package/dist/converse.js +32 -4
- package/dist/demo-chat.js +4 -4
- package/dist/demo.js +2 -2
- package/dist/desktop-update-gate.js +6 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-chat.js +4 -1
- package/dist/doctor.js +1 -1
- package/dist/egress-bridge.d.ts +17 -0
- package/dist/egress-bridge.js +111 -0
- package/dist/egress.d.ts +119 -0
- package/dist/egress.js +423 -0
- package/dist/exec.d.ts +78 -0
- package/dist/exec.js +367 -9
- package/dist/export-ui.js +2 -2
- package/dist/flow-engine.js +24 -5
- package/dist/flow-gallery-ui.js +2 -2
- package/dist/flow-gallery.js +27 -8
- package/dist/flow-share.js +16 -5
- package/dist/flow-triggers.d.ts +77 -8
- package/dist/flow-triggers.js +517 -70
- package/dist/flows-cli.js +8 -2
- package/dist/flows-ui.d.ts +0 -2
- package/dist/flows-ui.js +52 -4
- package/dist/flows.d.ts +4 -0
- package/dist/flows.js +9 -1
- package/dist/html.d.ts +9 -0
- package/dist/html.js +11 -0
- package/dist/invoke.d.ts +35 -0
- package/dist/invoke.js +347 -28
- package/dist/keys.d.ts +18 -14
- package/dist/keys.js +5 -0
- package/dist/lead-capabilities.js +3 -2
- package/dist/lead-contract.js +2 -0
- package/dist/lead-doors.d.ts +2 -0
- package/dist/lead-doors.js +7 -0
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +58 -2
- package/dist/lead-progress.js +1 -0
- package/dist/lead-queue-runtime.d.ts +46 -0
- package/dist/lead-queue-runtime.js +191 -0
- package/dist/lead-queue.d.ts +104 -0
- package/dist/lead-queue.js +187 -0
- package/dist/lead-sweep.d.ts +67 -0
- package/dist/lead-sweep.js +99 -0
- package/dist/lead-tools.d.ts +22 -0
- package/dist/lead-tools.js +152 -1
- package/dist/lead.d.ts +13 -5
- package/dist/lead.js +89 -29
- package/dist/limits-ui.d.ts +8 -1
- package/dist/limits-ui.js +26 -0
- package/dist/live-bus.d.ts +8 -0
- package/dist/live-bus.js +40 -4
- package/dist/live-detail.d.ts +27 -0
- package/dist/live-detail.js +57 -0
- package/dist/live-notes.d.ts +20 -0
- package/dist/live-notes.js +47 -0
- package/dist/live-records.d.ts +140 -0
- package/dist/live-records.js +935 -0
- package/dist/live-terminal.d.ts +269 -0
- package/dist/live-terminal.js +686 -0
- package/dist/live.d.ts +38 -27
- package/dist/live.js +142 -124
- package/dist/mcp-oauth.d.ts +6 -3
- package/dist/mcp-oauth.js +8 -4
- package/dist/memory-pass.js +2 -2
- package/dist/mobile-viewport.d.ts +5 -1
- package/dist/mobile-viewport.js +18 -2
- package/dist/model-catalog.js +4 -0
- package/dist/monitoring-ui.js +2 -2
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +11 -4
- package/dist/operate.d.ts +4 -0
- package/dist/operate.js +335 -19
- package/dist/passwordless.d.ts +23 -0
- package/dist/passwordless.js +26 -0
- package/dist/peek-cli.d.ts +12 -4
- package/dist/peek-cli.js +19 -11
- package/dist/phase-routing.js +10 -3
- package/dist/planner-source.d.ts +3 -0
- package/dist/planner-source.js +14 -0
- package/dist/planner.d.ts +2 -1
- package/dist/planner.js +7 -1
- package/dist/policy-ui.js +2 -2
- package/dist/policy.d.ts +1 -20
- package/dist/policy.js +29 -2
- package/dist/project-delete-ui.d.ts +18 -0
- package/dist/project-delete-ui.js +60 -9
- package/dist/project-delete.js +2 -2
- package/dist/project-memory.d.ts +3 -1
- package/dist/project-memory.js +57 -25
- package/dist/project-tools.d.ts +5 -3
- package/dist/project-tools.js +12 -9
- package/dist/provider-auth.d.ts +4 -3
- package/dist/provider-auth.js +5 -4
- package/dist/provider-connection.d.ts +2 -1
- package/dist/provider-connection.js +5 -0
- package/dist/provider.d.ts +25 -2
- package/dist/provider.js +63 -9
- package/dist/pull-request-ui.js +2 -2
- package/dist/push.js +1 -1
- package/dist/quick-start.d.ts +24 -0
- package/dist/quick-start.js +127 -0
- package/dist/release-adapters.d.ts +6 -0
- package/dist/release-adapters.js +70 -3
- package/dist/release.d.ts +10 -1
- package/dist/release.js +34 -15
- package/dist/request-budget-ui.js +2 -2
- package/dist/result-follow-ups.js +3 -2
- package/dist/result-review.d.ts +1 -1
- package/dist/result-review.js +9 -3
- package/dist/retention-ui.js +2 -2
- package/dist/retention.js +10 -1
- package/dist/run-efficiency.d.ts +73 -0
- package/dist/run-efficiency.js +228 -0
- package/dist/runner.d.ts +5 -1
- package/dist/runner.js +22 -1
- package/dist/scope.d.ts +183 -5
- package/dist/scope.js +256 -31
- package/dist/scout-net.d.ts +4 -1
- package/dist/scout-net.js +37 -12
- package/dist/scout.d.ts +20 -6
- package/dist/scout.js +27 -3
- package/dist/serve.js +248 -40
- package/dist/server/chat.d.ts +1 -1
- package/dist/server/chat.js +122 -31
- package/dist/server/chrome.d.ts +16 -2
- package/dist/server/chrome.js +63 -11
- package/dist/server/console.css +107 -42
- package/dist/server/flows.js +38 -8
- package/dist/server/guards.d.ts +1 -0
- package/dist/server/guards.js +23 -9
- package/dist/server/http.d.ts +7 -0
- package/dist/server/http.js +3 -2
- package/dist/server/live.d.ts +34 -1
- package/dist/server/live.js +39 -17
- package/dist/server/pages-assets.js +5 -0
- package/dist/server/pages.js +90 -13
- package/dist/server/people-tokens.d.ts +3 -0
- package/dist/server/people-tokens.js +55 -17
- package/dist/server/remote-hooks.js +12 -6
- package/dist/server/remote.d.ts +2 -1
- package/dist/server/remote.js +14 -5
- package/dist/server/render-chat.d.ts +12 -3
- package/dist/server/render-chat.js +47 -16
- package/dist/server/render-pages.d.ts +32 -4
- package/dist/server/render-pages.js +83 -13
- package/dist/server/render-people.d.ts +3 -2
- package/dist/server/render-people.js +3 -2
- package/dist/server/render-results.d.ts +18 -2
- package/dist/server/render-results.js +37 -28
- package/dist/server/render-settings.js +6 -5
- package/dist/server/render-tasks.d.ts +35 -10
- package/dist/server/render-tasks.js +111 -27
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/request-context.js +2 -1
- package/dist/server/route-table.d.ts +3 -0
- package/dist/server/route-table.js +47 -5
- package/dist/server/runtime.d.ts +33 -2
- package/dist/server/session.d.ts +25 -1
- package/dist/server/session.js +18 -4
- package/dist/server/settings.js +142 -10
- package/dist/server/sweeps.d.ts +4 -0
- package/dist/server/sweeps.js +58 -0
- package/dist/server/task-views.d.ts +2 -1
- package/dist/server/task-views.js +96 -6
- package/dist/server/tasks.js +327 -78
- package/dist/server/terminals.d.ts +31 -0
- package/dist/server/terminals.js +365 -0
- package/dist/setup-guide.js +3 -2
- package/dist/slack-chat.js +4 -1
- package/dist/spend-ui.js +3 -3
- package/dist/spend.js +2 -1
- package/dist/steer-capabilities.d.ts +32 -0
- package/dist/steer-capabilities.js +60 -0
- package/dist/steer-controls.d.ts +14 -0
- package/dist/steer-controls.js +36 -0
- package/dist/steer-status.js +2 -2
- package/dist/storage-ui.js +4 -4
- package/dist/store-baseline.d.ts +1 -1
- package/dist/store-baseline.js +1 -1
- package/dist/store.d.ts +440 -15
- package/dist/store.js +1072 -52
- package/dist/subagent-desk.js +1 -1
- package/dist/summary.js +3 -0
- package/dist/task-source.d.ts +58 -0
- package/dist/task-source.js +93 -0
- package/dist/team-leads.js +1 -1
- package/dist/teams-chat.js +4 -1
- package/dist/teardown-git.d.ts +22 -0
- package/dist/teardown-git.js +136 -2
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +44 -7
- package/dist/telegram.d.ts +11 -2
- package/dist/telegram.js +51 -17
- package/dist/terminal-catalog.d.ts +11 -0
- package/dist/terminal-catalog.js +129 -0
- package/dist/text-limits.d.ts +2 -0
- package/dist/text-limits.js +2 -0
- package/dist/tool-broker.d.ts +14 -0
- package/dist/tool-broker.js +67 -2
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +13 -3
- package/dist/tools-ui.js +2 -2
- package/dist/unfenced-test-support.d.ts +2 -0
- package/dist/unfenced-test-support.js +11 -0
- package/dist/webhook-limits.d.ts +23 -0
- package/dist/webhook-limits.js +30 -0
- package/dist/webhook-readers.d.ts +56 -0
- package/dist/webhook-readers.js +138 -0
- package/dist/webhook-signing.d.ts +27 -0
- package/dist/webhook-signing.js +119 -0
- package/dist/work-index.d.ts +29 -0
- package/dist/work-index.js +16 -2
- package/dist/workspace-revision.js +3 -1
- package/dist/writes-mode.d.ts +9 -0
- package/dist/writes-mode.js +8 -0
- package/package.json +15 -2
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live conversation's records (docs/plans/live-conversation.md): what a
|
|
3
|
+
* running agent said and did, one JSON object per line of `live/<run>.jsonl`.
|
|
4
|
+
*
|
|
5
|
+
* The recorder turns provider stream events into records. It names a step's
|
|
6
|
+
* target only from a closed list of argument fields, keeps a command's text
|
|
7
|
+
* and output only when the project turned "Show commands in the live view"
|
|
8
|
+
* on (the `commands` tier), and never writes a tool's own name — only our
|
|
9
|
+
* fixed kind. Every string is redacted, then cleaned of controls, then cut.
|
|
10
|
+
* The redaction is best effort; the opt-in is the real control.
|
|
11
|
+
*
|
|
12
|
+
* Readers trust nothing they read back: `parseLiveRecord` re-checks shape,
|
|
13
|
+
* caps and redaction, since the machine running the agent could alter the
|
|
14
|
+
* file. Display state, never evidence.
|
|
15
|
+
*/
|
|
16
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
17
|
+
import { scanForSecrets } from "./evidence.js";
|
|
18
|
+
export const LIVE_ACTIVITY_KINDS = ["session", "message", "edit", "command", "read", "search", "lookup", "plan", "delegate", "tool"];
|
|
19
|
+
/** A record's serialized size limit: a longer one is replaced by `cut`. */
|
|
20
|
+
export const LIVE_RECORD_CAP = 8 * 1024;
|
|
21
|
+
const LINE_CAP = 2 * 1024;
|
|
22
|
+
const COMMAND_CAP = 300;
|
|
23
|
+
const SHORT_CAP = 120;
|
|
24
|
+
const OUT_LINES = 20;
|
|
25
|
+
const OUT_BYTES = 4 * 1024;
|
|
26
|
+
const OPEN_STEPS_CAP = 64;
|
|
27
|
+
/** A `say` record is split before it grows past this many bytes or lines (the reader's own limits). */
|
|
28
|
+
const SAY_CHUNK = 6 * 1024;
|
|
29
|
+
const SAY_LINES = 400;
|
|
30
|
+
export const HIDDEN_TARGET = "[hidden]";
|
|
31
|
+
export const HIDDEN_LINE = "[a line was hidden]";
|
|
32
|
+
export const HIDDEN_KEY_BLOCK = "[a key block was hidden]";
|
|
33
|
+
export const OUTSIDE_PROJECT = "a file outside the project";
|
|
34
|
+
export const SOME_FILE = "a file";
|
|
35
|
+
/* ---------- redaction and cleaning ---------- */
|
|
36
|
+
/**
|
|
37
|
+
* The live view's own shapes, beside the evidence scanner's high-confidence
|
|
38
|
+
* ones: generic `key = value` credentials, auth headers, and URLs carrying
|
|
39
|
+
* credentials. Over-hiding is the accepted cost here — this is a display.
|
|
40
|
+
*/
|
|
41
|
+
const LIVE_SECRET_SHAPES = [
|
|
42
|
+
/(?:^|[^A-Za-z0-9])[A-Za-z0-9_.-]*?(?:pass(?:word|wd)?|secret|token|api[_-]?key|authorization|auth|bearer|credentials?|private[_-]?key|session(?:[_-]?(?:id|key|token))?|cookie)["']?\s*[:=]\s*\S/i,
|
|
43
|
+
/\bauthorization\s*:/i,
|
|
44
|
+
/\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/i,
|
|
45
|
+
/\b[a-z][a-z0-9+.-]*:\/\/[^\s/@]+@/i,
|
|
46
|
+
/[?&](?:access_token|token|key|api_key|apikey|secret|sig|signature|password|pwd|auth|code)=[^&\s]+/i,
|
|
47
|
+
];
|
|
48
|
+
const BIDI_CONTROLS = /[--]/g;
|
|
49
|
+
/** A line that is only a long run of mixed-case base64 — a key body whose BEGIN line was elsewhere (a hash is not). */
|
|
50
|
+
function keyBodyShaped(line) {
|
|
51
|
+
const bare = line.trim();
|
|
52
|
+
return bare.length >= 24 && /^[A-Za-z0-9+/]+={0,2}$/.test(bare) && /[A-Z]/.test(bare) && /[a-z]/.test(bare) && /[0-9]/.test(bare);
|
|
53
|
+
}
|
|
54
|
+
/** Whether a line looks like it carries a credential (best effort). */
|
|
55
|
+
export function liveSecretHit(line) {
|
|
56
|
+
if (scanForSecrets(line).length > 0)
|
|
57
|
+
return true;
|
|
58
|
+
return keyBodyShaped(line) || LIVE_SECRET_SHAPES.some(shape => shape.test(line));
|
|
59
|
+
}
|
|
60
|
+
/** Controls, ANSI escapes and bidi overrides out; tabs to spaces. */
|
|
61
|
+
export function cleanLiveText(text) {
|
|
62
|
+
return text
|
|
63
|
+
// Escape sequences in every form: CSI (7- or 8-bit, any parameter bytes including ':'), OSC, and the rest.
|
|
64
|
+
.replace(/(?:\x1b\[|\u009b)[0-?]*[ -/]*[@-~]/g, "")
|
|
65
|
+
.replace(/(?:\x1b\]|\u009d)[^\x07\x1b\u009c]*(?:\x07|\x1b\\|\u009c)?/g, "")
|
|
66
|
+
.replace(/\x1b[ -/]*[0-~]/g, "")
|
|
67
|
+
.replace(/\t/g, " ")
|
|
68
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, "")
|
|
69
|
+
// Every invisible format character (zero-width, direction marks and overrides, joiners, BOM): never shown.
|
|
70
|
+
.replace(/\p{Cf}/gu, "")
|
|
71
|
+
.replace(BIDI_CONTROLS, "");
|
|
72
|
+
}
|
|
73
|
+
/** Letters, digits and dashes only, after folding look-alike letters and dashes. */
|
|
74
|
+
const reduceForm = (text) => text.normalize("NFKC").replace(/[\u2010-\u2015\u2212\u2E3A\u2E3B\uFE58\uFE63\uFF0D]/g, "-").toUpperCase().replace(/[^A-Z0-9-]/g, "");
|
|
75
|
+
const CSI_ANY = /(?:\x1b\[|\u009b)[0-?]*[ -/]*[@-~]/g;
|
|
76
|
+
/** The part of a line that only finishes the escape the previous line began: never shown, never text. */
|
|
77
|
+
function finishEscape(line, carry) {
|
|
78
|
+
if (carry === "C") {
|
|
79
|
+
const done = /^[0-?]*[ -/]*[@-~]/.exec(line);
|
|
80
|
+
return done === null ? { rest: "", carry: /^[0-?]*[ -/]*$/.test(line) ? "C" : "" } : { rest: line.slice(done[0].length), carry: "" };
|
|
81
|
+
}
|
|
82
|
+
if (carry === "O") {
|
|
83
|
+
const done = /^[^\x07\x1b\u009c]*(?:\x07|\x1b\\|\u009c)/.exec(line);
|
|
84
|
+
return done === null ? { rest: "", carry: "O" } : { rest: line.slice(done[0].length), carry: "" };
|
|
85
|
+
}
|
|
86
|
+
if (carry === "E")
|
|
87
|
+
return { rest: line.slice(1), carry: "" };
|
|
88
|
+
return { rest: line, carry: "" };
|
|
89
|
+
}
|
|
90
|
+
/** Whether a line ends inside an escape the next line must finish. */
|
|
91
|
+
function escapeLeftOpen(line) {
|
|
92
|
+
if (/(?:\x1b\[|\u009b)[0-?]*[ -/]*$/.test(line))
|
|
93
|
+
return "C";
|
|
94
|
+
if (/(?:\x1b\]|\u009d)[^\x07\x1b\u009c]*$/.test(line))
|
|
95
|
+
return "O";
|
|
96
|
+
return /\x1b$/.test(line) ? "E" : "";
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Where, in `text`, the escape `carry` left open at the end of what came before is over — read exactly as
|
|
100
|
+
* cleanLiveText reads escapes — or null while it is still open. Words written after it are words; words written
|
|
101
|
+
* inside it are cleaned away with it.
|
|
102
|
+
*/
|
|
103
|
+
function escapeEnd(text, carry) {
|
|
104
|
+
if (carry === "C") {
|
|
105
|
+
const done = /^[0-?]*[ -/]*[@-~]/.exec(text);
|
|
106
|
+
if (done !== null)
|
|
107
|
+
return done[0].length;
|
|
108
|
+
return /^[0-?]*[ -/]*$/.test(text) ? null : 0;
|
|
109
|
+
}
|
|
110
|
+
if (carry === "O") {
|
|
111
|
+
const stop = text.search(/[\x07\x1b\u009c]/);
|
|
112
|
+
if (stop < 0)
|
|
113
|
+
return null;
|
|
114
|
+
if (text[stop] !== "\x1b")
|
|
115
|
+
return stop + 1;
|
|
116
|
+
if (stop + 1 === text.length)
|
|
117
|
+
return null;
|
|
118
|
+
return text[stop + 1] === "\\" ? stop + 2 : stop;
|
|
119
|
+
}
|
|
120
|
+
if (carry === "I") {
|
|
121
|
+
// ESC and intermediate bytes so far: the rest is more of them, then one final byte.
|
|
122
|
+
const done = /^[ -/]*[0-~]/.exec(text);
|
|
123
|
+
if (done !== null)
|
|
124
|
+
return done[0].length;
|
|
125
|
+
return /^[ -/]*$/.test(text) ? null : 0;
|
|
126
|
+
}
|
|
127
|
+
if (carry === "E") {
|
|
128
|
+
if (text === "")
|
|
129
|
+
return null;
|
|
130
|
+
if (text[0] === "[" || text[0] === "]") {
|
|
131
|
+
const rest = escapeEnd(text.slice(1), text[0] === "[" ? "C" : "O");
|
|
132
|
+
return rest === null ? null : rest + 1;
|
|
133
|
+
}
|
|
134
|
+
const done = /^[ -/]*[0-~]/.exec(text);
|
|
135
|
+
if (done !== null)
|
|
136
|
+
return done[0].length;
|
|
137
|
+
return /^[ -/]*$/.test(text) ? null : 0;
|
|
138
|
+
}
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
/** The escape `text` ends inside, read as cleanLiveText reads escapes: escapeLeftOpen's, or ESC with intermediate
|
|
142
|
+
* bytes ("I") that a final byte still has to finish. */
|
|
143
|
+
function heldEscape(text) {
|
|
144
|
+
const open = escapeLeftOpen(text);
|
|
145
|
+
if (open !== "")
|
|
146
|
+
return open;
|
|
147
|
+
return /\x1b[ -/]+$/.test(text) ? "I" : "";
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The ways one line can be read when judging where a key block begins or
|
|
151
|
+
* ends: exactly as it will be shown (cleaned, with the escape the previous
|
|
152
|
+
* line left open finished first), with only colour sequences removed, and
|
|
153
|
+
* raw with every escape's payload kept. A block opens if ANY reading shows a
|
|
154
|
+
* BEGIN, and closes only if EVERY reading agrees it has ended — so a marker
|
|
155
|
+
* hidden in, split by, or faked inside an escape never lets a key body
|
|
156
|
+
* through. Over-hiding is the accepted cost.
|
|
157
|
+
*/
|
|
158
|
+
const boundaryForms = (line, carry) => {
|
|
159
|
+
const finished = finishEscape(line, carry).rest;
|
|
160
|
+
return [
|
|
161
|
+
reduceForm(cleanLiveText(finished)),
|
|
162
|
+
reduceForm(finished.replace(CSI_ANY, "")),
|
|
163
|
+
reduceForm(line),
|
|
164
|
+
];
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Every BEGIN and END marker in a line, in order. BEGIN counts with any label (or none); END only as a whole END line
|
|
168
|
+
* — its label and closing dashes — so a header's own trailing dashes never read as one.
|
|
169
|
+
*/
|
|
170
|
+
const KEY_MARKERS = /-{5}(BEGIN|END[A-Z0-9-]*?-{5})/g;
|
|
171
|
+
/** Cut to at most `cap` UTF-8 bytes on a character boundary, "…" when cut. */
|
|
172
|
+
export function capLive(text, cap) {
|
|
173
|
+
if (Buffer.byteLength(text, "utf8") <= cap)
|
|
174
|
+
return text;
|
|
175
|
+
let out = "";
|
|
176
|
+
let bytes = 0;
|
|
177
|
+
for (const char of text) {
|
|
178
|
+
const size = Buffer.byteLength(char, "utf8");
|
|
179
|
+
if (bytes + size > cap - 3)
|
|
180
|
+
break;
|
|
181
|
+
out += char;
|
|
182
|
+
bytes += size;
|
|
183
|
+
}
|
|
184
|
+
return `${out}…`;
|
|
185
|
+
}
|
|
186
|
+
/** One line for the view: redacted whole when it hits, then cleaned and cut. */
|
|
187
|
+
export function liveLine(raw, cap = LINE_CAP) {
|
|
188
|
+
const clean = cleanLiveText(raw);
|
|
189
|
+
if (liveSecretHit(raw) || liveSecretHit(clean))
|
|
190
|
+
return HIDDEN_LINE;
|
|
191
|
+
return capLive(clean, cap);
|
|
192
|
+
}
|
|
193
|
+
/** A target: `[hidden]` when it hits, else cleaned and cut. Empty → null. */
|
|
194
|
+
function liveTarget(raw, cap) {
|
|
195
|
+
const clean = cleanLiveText(raw).trim();
|
|
196
|
+
if (clean === "")
|
|
197
|
+
return null;
|
|
198
|
+
if (liveSecretHit(raw) || liveSecretHit(clean))
|
|
199
|
+
return HIDDEN_TARGET;
|
|
200
|
+
return capLive(clean, cap);
|
|
201
|
+
}
|
|
202
|
+
const BEGIN_WORD = "-----BEGIN";
|
|
203
|
+
/** The longest end of `text` that starts "-----BEGIN" without completing it — all a split opener needs carried. */
|
|
204
|
+
function beginPrefix(text) {
|
|
205
|
+
for (let size = Math.min(text.length, BEGIN_WORD.length - 1); size > 0; size -= 1) {
|
|
206
|
+
const end = text.slice(-size);
|
|
207
|
+
if (BEGIN_WORD.startsWith(end))
|
|
208
|
+
return end;
|
|
209
|
+
}
|
|
210
|
+
return "";
|
|
211
|
+
}
|
|
212
|
+
/** Whether a carried tail is one this code could have produced: three BEGIN prefixes and an escape flag, or nothing. */
|
|
213
|
+
export function validKeyTail(tail) {
|
|
214
|
+
if (tail === "")
|
|
215
|
+
return true;
|
|
216
|
+
const parts = tail.split("|");
|
|
217
|
+
if (parts.length !== 4)
|
|
218
|
+
return false;
|
|
219
|
+
return parts.slice(0, 3).every(part => part.length < BEGIN_WORD.length && BEGIN_WORD.startsWith(part)) && ["", "C", "O", "E"].includes(parts[3]);
|
|
220
|
+
}
|
|
221
|
+
export function redactLiveLines(lines, state = { inKey: false }) {
|
|
222
|
+
const out = [];
|
|
223
|
+
for (const line of lines) {
|
|
224
|
+
// Each reading is one stream across line breaks (and reads): markers are found in order wherever they are split,
|
|
225
|
+
// an END then a BEGIN on one line opens a block again, a block opens if ANY reading opens it and closes only if
|
|
226
|
+
// EVERY reading closes it.
|
|
227
|
+
const startedInKey = state.inKey;
|
|
228
|
+
const carried = validKeyTail(state.tail ?? "") && (state.tail ?? "") !== "" ? state.tail.split("|") : ["", "", "", ""];
|
|
229
|
+
const escape = carried[3];
|
|
230
|
+
const nextTails = [];
|
|
231
|
+
let opened = false, stillIn = false;
|
|
232
|
+
boundaryForms(line, escape).forEach((form, reading) => {
|
|
233
|
+
const prefix = carried[reading] ?? "";
|
|
234
|
+
const stream = `${prefix}${form}`;
|
|
235
|
+
let inKey = startedInKey;
|
|
236
|
+
for (const marker of stream.matchAll(KEY_MARKERS)) {
|
|
237
|
+
const end = marker.index + marker[0].length;
|
|
238
|
+
if (end <= prefix.length)
|
|
239
|
+
continue;
|
|
240
|
+
// A BEGIN may be split across lines; an END counts only within this line.
|
|
241
|
+
if (marker[1] === "BEGIN") {
|
|
242
|
+
inKey = true;
|
|
243
|
+
opened = true;
|
|
244
|
+
}
|
|
245
|
+
else if (marker.index >= prefix.length)
|
|
246
|
+
inKey = false;
|
|
247
|
+
}
|
|
248
|
+
stillIn ||= inKey;
|
|
249
|
+
nextTails.push(beginPrefix(stream));
|
|
250
|
+
});
|
|
251
|
+
const leftOpen = escape === "" ? escapeLeftOpen(line) : finishEscape(line, escape).carry || escapeLeftOpen(finishEscape(line, escape).rest);
|
|
252
|
+
nextTails.push(leftOpen);
|
|
253
|
+
state.tail = nextTails.join("|");
|
|
254
|
+
state.inKey = stillIn;
|
|
255
|
+
if (startedInKey)
|
|
256
|
+
continue; // a line inside a block is never shown, whatever it holds
|
|
257
|
+
if (line === HIDDEN_KEY_BLOCK || line === HIDDEN_LINE) {
|
|
258
|
+
out.push(line);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (opened) {
|
|
262
|
+
out.push(HIDDEN_KEY_BLOCK);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
out.push(liveLine(line));
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Output for a command step: every line redacted first (a key block hidden
|
|
271
|
+
* from its BEGIN line to its END, or to the end), then the last 20 lines
|
|
272
|
+
* within 4 KiB — truncation never comes before redaction.
|
|
273
|
+
*/
|
|
274
|
+
export function liveOutput(raw, keys = { inKey: false }) {
|
|
275
|
+
const redacted = redactLiveLines(raw.split(/\r?\n/), keys);
|
|
276
|
+
while (redacted.length > 0 && redacted[redacted.length - 1].trim() === "")
|
|
277
|
+
redacted.pop();
|
|
278
|
+
const kept = [];
|
|
279
|
+
let bytes = 0;
|
|
280
|
+
for (let index = redacted.length - 1; index >= 0 && kept.length < OUT_LINES; index -= 1) {
|
|
281
|
+
const one = redacted[index];
|
|
282
|
+
const size = Buffer.byteLength(one, "utf8") + 1;
|
|
283
|
+
if (bytes + size > OUT_BYTES)
|
|
284
|
+
break;
|
|
285
|
+
kept.unshift(one);
|
|
286
|
+
bytes += size;
|
|
287
|
+
}
|
|
288
|
+
return kept;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* A target under the key-block rule: hidden while a block is open. A target is one argument (a search pattern, a
|
|
292
|
+
* path), never part of the conversation's text, so it neither opens nor closes a block — searching for "-----BEGIN"
|
|
293
|
+
* is a search, not a key — and a target that itself looks like a marker or a secret is hidden.
|
|
294
|
+
*/
|
|
295
|
+
function keyedTarget(target, keys) {
|
|
296
|
+
if (target === null || target === HIDDEN_TARGET)
|
|
297
|
+
return target;
|
|
298
|
+
if (keys.inKey)
|
|
299
|
+
return HIDDEN_TARGET;
|
|
300
|
+
const [shown] = redactLiveLines([target], { ...keys });
|
|
301
|
+
return shown === HIDDEN_KEY_BLOCK || shown === HIDDEN_LINE ? HIDDEN_TARGET : target;
|
|
302
|
+
}
|
|
303
|
+
/* ---------- targets ---------- */
|
|
304
|
+
/**
|
|
305
|
+
* A path as the view shows it: relative to the run's own checkout when it
|
|
306
|
+
* lies inside it, judged lexically — nothing is opened or resolved on disk.
|
|
307
|
+
*/
|
|
308
|
+
export function livePath(base, given) {
|
|
309
|
+
if (typeof given !== "string" || given.trim() === "")
|
|
310
|
+
return null;
|
|
311
|
+
if (base === null)
|
|
312
|
+
return SOME_FILE;
|
|
313
|
+
const full = resolve(base, given);
|
|
314
|
+
const shown = relative(base, full);
|
|
315
|
+
if (shown === "" || isAbsolute(shown) || shown === ".." || shown.startsWith(`..${sep}`))
|
|
316
|
+
return OUTSIDE_PROJECT;
|
|
317
|
+
return liveTarget(shown.split(sep).join("/"), LINE_CAP);
|
|
318
|
+
}
|
|
319
|
+
/** `bash -lc '…'` and friends, unwrapped for display only. */
|
|
320
|
+
function unwrapShell(command) {
|
|
321
|
+
const wrapped = /^(?:\/(?:usr\/)?bin\/)?(?:ba|z)?sh\s+-l?c\s+(['"])([\s\S]*)\1\s*$/.exec(command.trim());
|
|
322
|
+
return wrapped === null ? command : wrapped[2];
|
|
323
|
+
}
|
|
324
|
+
/** A command's first line, "…" when more followed or it was cut. */
|
|
325
|
+
export function liveCommand(given) {
|
|
326
|
+
const text = Array.isArray(given) ? given.filter(one => typeof one === "string").join(" ") : given;
|
|
327
|
+
if (typeof text !== "string")
|
|
328
|
+
return null;
|
|
329
|
+
const lines = unwrapShell(text).split(/\r?\n/).filter(one => one.trim() !== "");
|
|
330
|
+
if (lines.length === 0)
|
|
331
|
+
return null;
|
|
332
|
+
if (lines.some(one => liveSecretHit(one)))
|
|
333
|
+
return HIDDEN_TARGET;
|
|
334
|
+
const first = liveTarget(lines[0], COMMAND_CAP);
|
|
335
|
+
if (first === null || first === HIDDEN_TARGET)
|
|
336
|
+
return first;
|
|
337
|
+
return lines.length > 1 && !first.endsWith("…") ? `${first} …` : first;
|
|
338
|
+
}
|
|
339
|
+
/** A URL's host only: no scheme, userinfo, port, path or query. */
|
|
340
|
+
function liveHost(given) {
|
|
341
|
+
if (typeof given !== "string")
|
|
342
|
+
return null;
|
|
343
|
+
try {
|
|
344
|
+
const host = new URL(given).hostname;
|
|
345
|
+
return host === "" ? null : liveTarget(host, SHORT_CAP);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const short = (given) => (typeof given === "string" ? liveTarget(given.split(/\r?\n/)[0] ?? "", SHORT_CAP) : null);
|
|
352
|
+
/** Claude tools: our kind, and whether it is a command. */
|
|
353
|
+
const CLAUDE_KINDS = new Map([
|
|
354
|
+
["Edit", "edit"], ["Write", "edit"], ["MultiEdit", "edit"], ["NotebookEdit", "edit"],
|
|
355
|
+
["Bash", "command"], ["Read", "read"], ["LS", "read"], ["Glob", "search"], ["Grep", "search"],
|
|
356
|
+
["WebFetch", "lookup"], ["WebSearch", "lookup"], ["TodoWrite", "plan"], ["Task", "delegate"],
|
|
357
|
+
]);
|
|
358
|
+
function claudeTarget(name, input, base, commands) {
|
|
359
|
+
switch (name) {
|
|
360
|
+
case "Edit":
|
|
361
|
+
case "Write":
|
|
362
|
+
case "MultiEdit":
|
|
363
|
+
case "Read":
|
|
364
|
+
return livePath(base, input["file_path"]);
|
|
365
|
+
case "NotebookEdit":
|
|
366
|
+
return livePath(base, input["notebook_path"]);
|
|
367
|
+
case "Grep":
|
|
368
|
+
case "Glob":
|
|
369
|
+
return short(input["pattern"]);
|
|
370
|
+
case "WebFetch":
|
|
371
|
+
return liveHost(input["url"]);
|
|
372
|
+
case "WebSearch":
|
|
373
|
+
return short(input["query"]);
|
|
374
|
+
case "Task":
|
|
375
|
+
return short(input["description"]);
|
|
376
|
+
case "Bash":
|
|
377
|
+
return commands ? liveCommand(input["command"]) : null;
|
|
378
|
+
default:
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
383
|
+
const providerId = (value) => (typeof value === "string" && value !== "" && value.length <= 200 ? value : null);
|
|
384
|
+
/** A Claude tool_result's text, whatever its shape. */
|
|
385
|
+
function resultText(content) {
|
|
386
|
+
if (typeof content === "string")
|
|
387
|
+
return content;
|
|
388
|
+
if (!Array.isArray(content))
|
|
389
|
+
return "";
|
|
390
|
+
return content
|
|
391
|
+
.map(part => object(part))
|
|
392
|
+
.filter((part) => part !== null && part["type"] === "text" && typeof part["text"] === "string")
|
|
393
|
+
.map(part => String(part["text"]))
|
|
394
|
+
.join("\n");
|
|
395
|
+
}
|
|
396
|
+
export function createLiveRecorder(options) {
|
|
397
|
+
const clock = options.clock ?? (() => new Date());
|
|
398
|
+
let sequence = 0;
|
|
399
|
+
/** A key block the agent began in one message stays hidden through the next, until it ends or a new session starts. */
|
|
400
|
+
const keyState = { inKey: false };
|
|
401
|
+
/** Provider call id → our step, while it is open. */
|
|
402
|
+
const open = new Map();
|
|
403
|
+
const stamp = () => clock().toISOString();
|
|
404
|
+
const closeAll = (at) => {
|
|
405
|
+
const closed = [...open.values()].map(step => ({ k: "done", at, id: step.id, ok: null }));
|
|
406
|
+
open.clear();
|
|
407
|
+
return closed;
|
|
408
|
+
};
|
|
409
|
+
const openStep = (at, call, kind, target) => {
|
|
410
|
+
if (call !== null && open.has(call))
|
|
411
|
+
return []; // a duplicate open is ignored
|
|
412
|
+
const id = `s${++sequence}`;
|
|
413
|
+
const keyed = keyedTarget(target, keyState);
|
|
414
|
+
const step = { k: "step", at, id, kind, ...(keyed === null ? {} : { target: keyed }) };
|
|
415
|
+
if (call === null)
|
|
416
|
+
return [{ ...step, once: true }];
|
|
417
|
+
if (open.size >= OPEN_STEPS_CAP)
|
|
418
|
+
return [{ ...step, once: true }];
|
|
419
|
+
open.set(call, { id, kind });
|
|
420
|
+
return [step];
|
|
421
|
+
};
|
|
422
|
+
const say = (at, text) => {
|
|
423
|
+
const records = [];
|
|
424
|
+
let chunk = [];
|
|
425
|
+
let bytes = 0;
|
|
426
|
+
for (const line of redactLiveLines(text.split(/\r?\n/).filter(raw => raw.trim() !== ""), keyState)) {
|
|
427
|
+
const size = Buffer.byteLength(line, "utf8") + 4;
|
|
428
|
+
if (chunk.length > 0 && (bytes + size > SAY_CHUNK || chunk.length >= SAY_LINES)) {
|
|
429
|
+
records.push({ k: "say", at, t: chunk });
|
|
430
|
+
chunk = [];
|
|
431
|
+
bytes = 0;
|
|
432
|
+
}
|
|
433
|
+
chunk.push(line);
|
|
434
|
+
bytes += size;
|
|
435
|
+
}
|
|
436
|
+
if (chunk.length > 0)
|
|
437
|
+
records.push({ k: "say", at, t: chunk });
|
|
438
|
+
return records;
|
|
439
|
+
};
|
|
440
|
+
const claude = (event, at) => {
|
|
441
|
+
const type = event["type"];
|
|
442
|
+
// A subagent's words, tools, turns and sessions are not this run's conversation — and never close its steps.
|
|
443
|
+
const parent = event["parent_tool_use_id"];
|
|
444
|
+
if (parent !== undefined && parent !== null)
|
|
445
|
+
return [];
|
|
446
|
+
if (type === "system")
|
|
447
|
+
return event["subtype"] === "init" ? (keyState.inKey = false, [...closeAll(at), { k: "start", at }]) : [];
|
|
448
|
+
if (type === "result")
|
|
449
|
+
return closeAll(at);
|
|
450
|
+
const content = object(event["message"])?.["content"];
|
|
451
|
+
if (!Array.isArray(content))
|
|
452
|
+
return [];
|
|
453
|
+
const records = [];
|
|
454
|
+
for (const part of content) {
|
|
455
|
+
const block = object(part);
|
|
456
|
+
if (block === null)
|
|
457
|
+
continue;
|
|
458
|
+
if (type === "assistant" && block["type"] === "text" && typeof block["text"] === "string") {
|
|
459
|
+
records.push(...say(at, block["text"]));
|
|
460
|
+
}
|
|
461
|
+
else if (type === "assistant" && block["type"] === "tool_use") {
|
|
462
|
+
const name = typeof block["name"] === "string" ? block["name"] : "";
|
|
463
|
+
const kind = CLAUDE_KINDS.get(name) ?? "tool";
|
|
464
|
+
records.push(...openStep(at, providerId(block["id"]), kind, claudeTarget(name, object(block["input"]) ?? {}, options.base, options.commands)));
|
|
465
|
+
}
|
|
466
|
+
else if (type === "user" && block["type"] === "tool_result") {
|
|
467
|
+
const call = providerId(block["tool_use_id"]);
|
|
468
|
+
const step = call === null ? undefined : open.get(call);
|
|
469
|
+
if (step === undefined)
|
|
470
|
+
continue; // a close for an unknown id is dropped
|
|
471
|
+
open.delete(call);
|
|
472
|
+
const out = options.commands && step.kind === "command" ? liveOutput(resultText(block["content"]), keyState) : [];
|
|
473
|
+
records.push({ k: "done", at, id: step.id, ok: block["is_error"] !== true, ...(out.length > 0 ? { out } : {}) });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return records;
|
|
477
|
+
};
|
|
478
|
+
const codexKind = (type) => type === "command_execution" ? "command" : type === "file_change" ? "edit" : type === "web_search" ? "lookup" : type === "mcp_tool_call" ? "tool" : null;
|
|
479
|
+
const codexTarget = (item) => {
|
|
480
|
+
switch (item["type"]) {
|
|
481
|
+
case "command_execution":
|
|
482
|
+
return options.commands ? liveCommand(item["command"]) : null;
|
|
483
|
+
case "web_search":
|
|
484
|
+
return short(item["query"]);
|
|
485
|
+
case "file_change": {
|
|
486
|
+
const changes = Array.isArray(item["changes"]) ? item["changes"].map(object).filter(one => one !== null) : [];
|
|
487
|
+
const first = changes.length === 0 ? null : livePath(options.base, changes[0]["path"]);
|
|
488
|
+
if (first === null)
|
|
489
|
+
return null;
|
|
490
|
+
return changes.length > 1 ? `${first} and ${changes.length - 1} more` : first;
|
|
491
|
+
}
|
|
492
|
+
default:
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
const codexOk = (item) => {
|
|
497
|
+
if (item["type"] === "command_execution") {
|
|
498
|
+
const code = item["exit_code"];
|
|
499
|
+
return typeof code === "number" ? code === 0 : null;
|
|
500
|
+
}
|
|
501
|
+
return item["status"] === "completed" ? true : item["status"] === "failed" ? false : null;
|
|
502
|
+
};
|
|
503
|
+
const codex = (event, at) => {
|
|
504
|
+
const type = event["type"];
|
|
505
|
+
if (type === "thread.started")
|
|
506
|
+
return [...closeAll(at), { k: "start", at }];
|
|
507
|
+
if (type === "turn.completed" || type === "turn.failed")
|
|
508
|
+
return closeAll(at);
|
|
509
|
+
const item = object(event["item"]);
|
|
510
|
+
if (item === null)
|
|
511
|
+
return [];
|
|
512
|
+
if (type === "item.completed" && item["type"] === "agent_message") {
|
|
513
|
+
return typeof item["text"] === "string" ? say(at, item["text"]) : [];
|
|
514
|
+
}
|
|
515
|
+
const kind = codexKind(item["type"]);
|
|
516
|
+
if (kind === null)
|
|
517
|
+
return [];
|
|
518
|
+
const call = providerId(item["id"]);
|
|
519
|
+
if (type === "item.started")
|
|
520
|
+
return openStep(at, call, kind, codexTarget(item));
|
|
521
|
+
if (type !== "item.completed")
|
|
522
|
+
return [];
|
|
523
|
+
const records = [];
|
|
524
|
+
let step = call === null ? undefined : open.get(call);
|
|
525
|
+
// An item that completes without having started (a file change) opens and closes at once.
|
|
526
|
+
if (step === undefined) {
|
|
527
|
+
if (call === null || open.size >= OPEN_STEPS_CAP)
|
|
528
|
+
return openStep(at, null, kind, codexTarget(item));
|
|
529
|
+
records.push(...openStep(at, call, kind, codexTarget(item)));
|
|
530
|
+
step = open.get(call);
|
|
531
|
+
if (step === undefined)
|
|
532
|
+
return records;
|
|
533
|
+
}
|
|
534
|
+
open.delete(call);
|
|
535
|
+
const out = options.commands && kind === "command" && typeof item["aggregated_output"] === "string" ? liveOutput(item["aggregated_output"], keyState) : [];
|
|
536
|
+
records.push({ k: "done", at, id: step.id, ok: codexOk(item), ...(out.length > 0 ? { out } : {}) });
|
|
537
|
+
return records;
|
|
538
|
+
};
|
|
539
|
+
/** Gemini: whole messages and tool headlines, no targets, nothing to pair. */
|
|
540
|
+
const gemini = (event, at) => {
|
|
541
|
+
const type = event["type"];
|
|
542
|
+
if (type === "init")
|
|
543
|
+
return [{ k: "start", at }];
|
|
544
|
+
if (type === "message") {
|
|
545
|
+
return event["role"] === "assistant" && event["delta"] !== true && typeof event["content"] === "string" ? say(at, event["content"]) : [];
|
|
546
|
+
}
|
|
547
|
+
if (type === "tool_call" || type === "tool_use") {
|
|
548
|
+
const name = event["name"] ?? object(event["tool"])?.["name"];
|
|
549
|
+
return openStep(at, null, CLAUDE_KINDS.get(typeof name === "string" ? name : "") ?? "tool", null);
|
|
550
|
+
}
|
|
551
|
+
return null;
|
|
552
|
+
};
|
|
553
|
+
/**
|
|
554
|
+
* ACP (acp-client.ts; the runner hands each session update over as `{acp: update}`). The record format is the one
|
|
555
|
+
* every dialect writes: a tool call is held — bounded — until it has a target, starts running or ends, and is then
|
|
556
|
+
* shown once (OpenCode names its command only in a later update); the words of a message, which arrive in chunks,
|
|
557
|
+
* are shown when the message ends (a tool call, or the end of the run).
|
|
558
|
+
*/
|
|
559
|
+
const ACP_KINDS = { read: "read", edit: "edit", delete: "edit", move: "edit", search: "search", execute: "command", fetch: "lookup", think: "plan" };
|
|
560
|
+
const ACP_HELD_CAP = 64;
|
|
561
|
+
/** A message's words are shown in one piece when it ends — or, past this many characters, in pieces as they come:
|
|
562
|
+
* every word passes the shared redaction in the order the agent wrote it, none is dropped unseen. */
|
|
563
|
+
const ACP_WORDS_CAP = 16 * 1024;
|
|
564
|
+
/** A line with no end in sight is flushed whole past this (it is cut to a line's length on the way out anyway). */
|
|
565
|
+
const ACP_LINE_HARD_CAP = 256 * 1024;
|
|
566
|
+
const held = new Map();
|
|
567
|
+
/** The latest output an open command showed, already redacted when it arrived (a later update may carry none). */
|
|
568
|
+
const outputs = new Map();
|
|
569
|
+
let acpWords = "";
|
|
570
|
+
/** A step came between the held unfinished line and the words after it: they are joined with one space, so a token
|
|
571
|
+
* keeps the word boundary a scanner needs ("credentials ghp_…") and `key= value` still reads as one. */
|
|
572
|
+
let acpSeparated = false;
|
|
573
|
+
/** The message the held words belong to, when the agent names it: a new id is a new message, and a word break. */
|
|
574
|
+
let acpMessageId = null;
|
|
575
|
+
/** A new message began (its id changed) and its break isn't written yet: owed until it is, whatever chunks come. */
|
|
576
|
+
let acpNewMessage = false;
|
|
577
|
+
/** Past the hard cap, the rest of that one line is not shown; each piece is still read for the key-block state, in
|
|
578
|
+
* order — the state itself carries a split BEGIN across pieces, and an END split across two simply keeps the block
|
|
579
|
+
* hidden (never the other way). Nothing is read twice. */
|
|
580
|
+
let acpSkipping = false;
|
|
581
|
+
const skipRead = (text) => { redactLiveLines([text], keyState); };
|
|
582
|
+
/** Before anything else is written, the message's complete lines are; a partial last line waits for its own end (a
|
|
583
|
+
* `key=` and its value are never split by an update arriving between them), or for the end of the run. */
|
|
584
|
+
/** A message's complete lines, said in order; its unfinished last line waits for its own end (or the run's): a line
|
|
585
|
+
* is only ever judged — for secrets, for key blocks — whole, as the agent wrote it. */
|
|
586
|
+
const flushLines = (at) => {
|
|
587
|
+
const cut = acpWords.lastIndexOf("\n");
|
|
588
|
+
if (cut < 0)
|
|
589
|
+
return [];
|
|
590
|
+
const whole = acpWords.slice(0, cut + 1);
|
|
591
|
+
acpWords = acpWords.slice(cut + 1);
|
|
592
|
+
return whole.trim() === "" ? [] : say(at, whole);
|
|
593
|
+
};
|
|
594
|
+
/** Everything left, at the end of the run. */
|
|
595
|
+
const flushWords = (at) => {
|
|
596
|
+
const words = acpWords;
|
|
597
|
+
acpWords = "";
|
|
598
|
+
return words.trim() === "" ? [] : say(at, words);
|
|
599
|
+
};
|
|
600
|
+
/** Whether the message's unfinished line opens a key block it hasn't finished saying: until that line is judged,
|
|
601
|
+
* what comes between (a step's target) is hidden — never shown on the strength of a state not yet read. */
|
|
602
|
+
const heldKeyOpen = () => {
|
|
603
|
+
if (/BEGIN/i.test(cleanLiveText(acpWords).replace(/[^A-Za-z]/g, "")))
|
|
604
|
+
return true;
|
|
605
|
+
// Read as redaction will read it — every reading, from the state carried so far — on a copy of that state: a block
|
|
606
|
+
// it would open, or an opener it has begun and not finished, hides the target.
|
|
607
|
+
const probe = { inKey: keyState.inKey, ...(keyState.tail === undefined ? {} : { tail: keyState.tail }) };
|
|
608
|
+
redactLiveLines(acpWords === "" ? [] : acpWords.split(/\r?\n/), probe);
|
|
609
|
+
return probe.inKey || (probe.tail ?? "").split("|").slice(0, 3).some(prefix => prefix !== "");
|
|
610
|
+
};
|
|
611
|
+
const acpTarget = (kind, update, before) => {
|
|
612
|
+
if (kind === "command") {
|
|
613
|
+
const command = object(update["rawInput"])?.["command"];
|
|
614
|
+
return typeof command === "string" ? (options.commands ? liveCommand(command) : null) : before;
|
|
615
|
+
}
|
|
616
|
+
const first = Array.isArray(update["locations"]) ? object(update["locations"][0]) : null;
|
|
617
|
+
return first !== null && typeof first["path"] === "string" ? livePath(options.base, first["path"]) : before;
|
|
618
|
+
};
|
|
619
|
+
/** An update's command output, redacted now — in stream order, through the shared key state — or null when it has none. */
|
|
620
|
+
const acpOutput = (update) => {
|
|
621
|
+
if (!options.commands || !Array.isArray(update["content"]))
|
|
622
|
+
return null;
|
|
623
|
+
const text = update["content"].map(object).filter(one => one !== null && one["type"] === "content")
|
|
624
|
+
.map(one => object(one["content"])).filter(block => block !== null && block["type"] === "text" && typeof block["text"] === "string")
|
|
625
|
+
.map(block => block["text"]).join("\n");
|
|
626
|
+
// ACP sends a call's whole output again with each update — a snapshot, not a stream: each is judged on its own,
|
|
627
|
+
// from a fresh key state, and never moves the message's (key bodies are hidden by their shape either way).
|
|
628
|
+
return text === "" ? null : liveOutput(text, { inKey: false });
|
|
629
|
+
};
|
|
630
|
+
const acp = (update, at) => {
|
|
631
|
+
const kind = update["sessionUpdate"];
|
|
632
|
+
if (kind === "agent_message_chunk") {
|
|
633
|
+
const content = object(update["content"]);
|
|
634
|
+
if (content === null || content["type"] !== "text" || typeof content["text"] !== "string")
|
|
635
|
+
return [];
|
|
636
|
+
let text = content["text"];
|
|
637
|
+
if (acpSkipping) {
|
|
638
|
+
// The rest of a line too long to show: read for the key state, never written, until it ends.
|
|
639
|
+
const end = text.indexOf("\n");
|
|
640
|
+
skipRead(end < 0 ? text : text.slice(0, end));
|
|
641
|
+
if (end < 0)
|
|
642
|
+
return [];
|
|
643
|
+
acpSkipping = false;
|
|
644
|
+
text = text.slice(end + 1);
|
|
645
|
+
}
|
|
646
|
+
// A step between two messages is a word break, always written: whatever the next chunk starts with (a CR, a BOM,
|
|
647
|
+
// an escape) may be cleaned away, and the break with it.
|
|
648
|
+
const messageId = typeof update["messageId"] === "string" ? update["messageId"] : null;
|
|
649
|
+
// The agent's own word on what is one message wins: a new id is a break, owed until written; the same id
|
|
650
|
+
// cancels only the break a step implied (a tool's update arriving mid-sentence doesn't split `api_` from `key=`).
|
|
651
|
+
// With no id, a step is a break.
|
|
652
|
+
if (messageId !== null && acpMessageId !== null) {
|
|
653
|
+
if (messageId !== acpMessageId)
|
|
654
|
+
acpNewMessage = true;
|
|
655
|
+
else
|
|
656
|
+
acpSeparated = false;
|
|
657
|
+
}
|
|
658
|
+
if (messageId !== null)
|
|
659
|
+
acpMessageId = messageId;
|
|
660
|
+
if ((acpSeparated || acpNewMessage) && text !== "") {
|
|
661
|
+
if (acpWords === "" || acpWords.endsWith("\n"))
|
|
662
|
+
acpSeparated = acpNewMessage = false;
|
|
663
|
+
else {
|
|
664
|
+
// The break goes where cleaning can't take it: after any escape the held words left open, once it is over
|
|
665
|
+
// (written inside it, it would be cleaned away with it). Still open, the break waits for the next chunk.
|
|
666
|
+
const at = escapeEnd(text, heldEscape(acpWords));
|
|
667
|
+
if (at !== null) {
|
|
668
|
+
text = `${text.slice(0, at)} ${text.slice(at)}`;
|
|
669
|
+
acpSeparated = acpNewMessage = false;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
acpWords += text;
|
|
674
|
+
if (acpWords.length <= ACP_WORDS_CAP)
|
|
675
|
+
return [];
|
|
676
|
+
// Flushed only at a line's end, so nothing the redaction reads (a key block's BEGIN, a `key=` and its value) is
|
|
677
|
+
// ever split across two pieces; the partial last line waits for the rest of itself.
|
|
678
|
+
const cut = acpWords.lastIndexOf("\n");
|
|
679
|
+
if (cut >= 0) {
|
|
680
|
+
const whole = acpWords.slice(0, cut + 1);
|
|
681
|
+
acpWords = acpWords.slice(cut + 1);
|
|
682
|
+
return whole.trim() === "" ? [] : say(at, whole);
|
|
683
|
+
}
|
|
684
|
+
if (acpWords.length <= ACP_LINE_HARD_CAP)
|
|
685
|
+
return [];
|
|
686
|
+
// One line with no end in sight: it is not shown — said once — and its rest is only read.
|
|
687
|
+
acpSkipping = true;
|
|
688
|
+
skipRead(acpWords);
|
|
689
|
+
acpWords = "";
|
|
690
|
+
return [{ k: "cut", at }];
|
|
691
|
+
}
|
|
692
|
+
// A tool call or a permission answer is where the agent's message ends: its words go first, whole. Any other
|
|
693
|
+
// update (usage, a plan, a mode) is not: only the message's complete lines go before it, so a line in progress —
|
|
694
|
+
// a `key=` and the value still to come — is never split by one.
|
|
695
|
+
// Anything else comes after the message's complete lines; an unfinished line waits for its end, whatever arrives.
|
|
696
|
+
const records = flushLines(at);
|
|
697
|
+
if (kind === "tool_call" || kind === "tool_call_update" || kind === "permission")
|
|
698
|
+
acpSeparated = true;
|
|
699
|
+
if (kind === "permission") {
|
|
700
|
+
// A request's title is free text (a command, a path anywhere): only the answer is shown.
|
|
701
|
+
return [...records, ...openStep(at, null, "tool", update["granted"] === true ? "allowed once" : "refused")];
|
|
702
|
+
}
|
|
703
|
+
if (kind !== "tool_call" && kind !== "tool_call_update")
|
|
704
|
+
return records;
|
|
705
|
+
const call = providerId(update["toolCallId"]);
|
|
706
|
+
if (call === null)
|
|
707
|
+
return records;
|
|
708
|
+
const status = update["status"];
|
|
709
|
+
const ended = status === "completed" || status === "failed";
|
|
710
|
+
const shown = open.get(call);
|
|
711
|
+
const before = held.get(call);
|
|
712
|
+
const stepKind = typeof update["kind"] === "string" ? ACP_KINDS[update["kind"]] ?? "tool" : shown?.kind ?? before?.kind ?? "tool";
|
|
713
|
+
// Output is read (and so redacted, in order) whatever the call, but kept only for a call shown or held — bounded
|
|
714
|
+
// by them — so an untracked or overflowing call leaves nothing behind.
|
|
715
|
+
const output = stepKind === "command" ? acpOutput(update) : null;
|
|
716
|
+
if (output !== null && (shown !== undefined || before !== undefined || kind === "tool_call"))
|
|
717
|
+
outputs.set(call, output);
|
|
718
|
+
const done = (step) => {
|
|
719
|
+
const out = outputs.get(call) ?? [];
|
|
720
|
+
outputs.delete(call);
|
|
721
|
+
return { k: "done", at, id: step.id, ok: status === "completed", ...(out.length > 0 ? { out } : {}) };
|
|
722
|
+
};
|
|
723
|
+
if (shown !== undefined) {
|
|
724
|
+
if (!ended)
|
|
725
|
+
return records;
|
|
726
|
+
open.delete(call);
|
|
727
|
+
return [...records, done(shown)];
|
|
728
|
+
}
|
|
729
|
+
if (before === undefined && kind === "tool_call_update") {
|
|
730
|
+
outputs.delete(call);
|
|
731
|
+
return records;
|
|
732
|
+
}
|
|
733
|
+
const named = acpTarget(stepKind, update, before?.target ?? null);
|
|
734
|
+
const target = named !== null && heldKeyOpen() ? HIDDEN_TARGET : named;
|
|
735
|
+
if (target === null && status !== "in_progress" && !ended) {
|
|
736
|
+
if (before !== undefined || held.size < ACP_HELD_CAP)
|
|
737
|
+
held.set(call, { kind: stepKind, target });
|
|
738
|
+
else {
|
|
739
|
+
outputs.delete(call);
|
|
740
|
+
records.push(...openStep(at, null, stepKind, null));
|
|
741
|
+
}
|
|
742
|
+
return records;
|
|
743
|
+
}
|
|
744
|
+
held.delete(call);
|
|
745
|
+
const opened = openStep(at, call, stepKind, target);
|
|
746
|
+
// A step shown "once" (past the open-step cap) is never closed by id: nothing of it is kept.
|
|
747
|
+
if (!open.has(call))
|
|
748
|
+
outputs.delete(call);
|
|
749
|
+
if (!ended)
|
|
750
|
+
return [...records, ...opened];
|
|
751
|
+
// Shown and ended at once: a step and its result.
|
|
752
|
+
const step = open.get(call);
|
|
753
|
+
if (step === undefined) {
|
|
754
|
+
outputs.delete(call);
|
|
755
|
+
return [...records, ...opened];
|
|
756
|
+
}
|
|
757
|
+
open.delete(call);
|
|
758
|
+
return [...records, ...opened, done(step)];
|
|
759
|
+
};
|
|
760
|
+
const acpEnd = (at) => {
|
|
761
|
+
const records = flushWords(at);
|
|
762
|
+
for (const [, one] of held)
|
|
763
|
+
records.push(...openStep(at, null, one.kind, one.target));
|
|
764
|
+
held.clear();
|
|
765
|
+
outputs.clear();
|
|
766
|
+
acpSkipping = false;
|
|
767
|
+
return records;
|
|
768
|
+
};
|
|
769
|
+
return {
|
|
770
|
+
observe(event) {
|
|
771
|
+
try {
|
|
772
|
+
const at = stamp();
|
|
773
|
+
const acpUpdate = object(event["acp"]);
|
|
774
|
+
if (acpUpdate !== null)
|
|
775
|
+
return acp(acpUpdate, at);
|
|
776
|
+
const type = event["type"];
|
|
777
|
+
if (type === "system" || type === "assistant" || type === "user" || type === "result")
|
|
778
|
+
return claude(event, at);
|
|
779
|
+
if (typeof type === "string" && (type.startsWith("thread.") || type.startsWith("turn.") || type.startsWith("item.")))
|
|
780
|
+
return codex(event, at);
|
|
781
|
+
return gemini(event, at) ?? [];
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
return [];
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
end() {
|
|
788
|
+
try {
|
|
789
|
+
const at = stamp();
|
|
790
|
+
return [...acpEnd(at), ...closeAll(at)];
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
return [];
|
|
794
|
+
}
|
|
795
|
+
},
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
/** One record as its file line; an oversized one becomes `cut`. */
|
|
799
|
+
export function serializeLiveRecord(record) {
|
|
800
|
+
const line = JSON.stringify(record);
|
|
801
|
+
return Buffer.byteLength(line, "utf8") <= LIVE_RECORD_CAP ? line : JSON.stringify({ k: "cut", at: record.at });
|
|
802
|
+
}
|
|
803
|
+
/* ---------- reading back ---------- */
|
|
804
|
+
const KINDS = new Set(LIVE_ACTIVITY_KINDS);
|
|
805
|
+
const STEP_ID = /^s[1-9][0-9]{0,8}$/;
|
|
806
|
+
const strings = (value, max) => Array.isArray(value) && value.length <= max && value.every(one => typeof one === "string") ? value : null;
|
|
807
|
+
/**
|
|
808
|
+
* One file line back into a record, strictly: a known shape within its caps,
|
|
809
|
+
* every string redacted and cleaned again. Anything else is null — skipped,
|
|
810
|
+
* never shown raw.
|
|
811
|
+
*/
|
|
812
|
+
export function parseLiveRecord(line, keys = { inKey: false }) {
|
|
813
|
+
if (line === "" || Buffer.byteLength(line, "utf8") > LIVE_RECORD_CAP)
|
|
814
|
+
return null;
|
|
815
|
+
let value;
|
|
816
|
+
try {
|
|
817
|
+
value = JSON.parse(line);
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
const record = object(value);
|
|
823
|
+
if (record === null)
|
|
824
|
+
return null;
|
|
825
|
+
const at = record["at"];
|
|
826
|
+
if (typeof at !== "string" || at.length > 40 || (at !== "" && Number.isNaN(Date.parse(at))))
|
|
827
|
+
return null;
|
|
828
|
+
switch (record["k"]) {
|
|
829
|
+
case "start":
|
|
830
|
+
case "cut":
|
|
831
|
+
case "cap":
|
|
832
|
+
return { k: record["k"], at };
|
|
833
|
+
case "open":
|
|
834
|
+
return record["detail"] === "base" || record["detail"] === "commands" ? { k: "open", at, detail: record["detail"] } : null;
|
|
835
|
+
case "say": {
|
|
836
|
+
const t = strings(record["t"], SAY_LINES);
|
|
837
|
+
if (t === null || t.length === 0)
|
|
838
|
+
return null;
|
|
839
|
+
return { k: "say", at, t: redactLiveLines(t, keys) };
|
|
840
|
+
}
|
|
841
|
+
case "step": {
|
|
842
|
+
const id = record["id"], kind = record["kind"], target = record["target"];
|
|
843
|
+
if (typeof id !== "string" || !STEP_ID.test(id) || typeof kind !== "string" || !KINDS.has(kind))
|
|
844
|
+
return null;
|
|
845
|
+
if (target !== undefined && typeof target !== "string")
|
|
846
|
+
return null;
|
|
847
|
+
const shown = target === undefined ? null : keyedTarget(target === HIDDEN_TARGET ? HIDDEN_TARGET : liveTarget(target, COMMAND_CAP + 4), keys);
|
|
848
|
+
return { k: "step", at, id, kind: kind, ...(shown === null ? {} : { target: shown }), ...(record["once"] === true ? { once: true } : {}) };
|
|
849
|
+
}
|
|
850
|
+
case "done": {
|
|
851
|
+
const id = record["id"], ok = record["ok"];
|
|
852
|
+
if (typeof id !== "string" || !STEP_ID.test(id) || !(ok === true || ok === false || ok === null))
|
|
853
|
+
return null;
|
|
854
|
+
const out = record["out"] === undefined ? [] : strings(record["out"], OUT_LINES);
|
|
855
|
+
if (out === null)
|
|
856
|
+
return null;
|
|
857
|
+
const shown = redactLiveLines(out, keys);
|
|
858
|
+
return { k: "done", at, id, ok, ...(shown.length > 0 ? { out: shown } : {}) };
|
|
859
|
+
}
|
|
860
|
+
default:
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
/** Complete file lines (a window's text) into records; bad lines are skipped. */
|
|
865
|
+
export function parseLiveRecords(text, keys = { inKey: false }) {
|
|
866
|
+
const records = [];
|
|
867
|
+
for (const line of text.split("\n")) {
|
|
868
|
+
const record = parseLiveRecord(line, keys);
|
|
869
|
+
if (record !== null)
|
|
870
|
+
records.push(record);
|
|
871
|
+
}
|
|
872
|
+
return records;
|
|
873
|
+
}
|
|
874
|
+
/* ---------- the text projection ---------- */
|
|
875
|
+
export const LIVE_CAP_WORDS = "[the live view stopped at its size cap — the run record continues]";
|
|
876
|
+
export const LIVE_CUT_WORDS = "[a line was too long to show]";
|
|
877
|
+
const TARGET_WORDS = {
|
|
878
|
+
session: "the agent session started",
|
|
879
|
+
message: "",
|
|
880
|
+
edit: "→ editing",
|
|
881
|
+
command: "→ running",
|
|
882
|
+
read: "→ reading",
|
|
883
|
+
search: "→ searching for",
|
|
884
|
+
lookup: "→ looking up",
|
|
885
|
+
plan: "→ organizing its plan",
|
|
886
|
+
delegate: "→ delegating:",
|
|
887
|
+
tool: "→ using a tool",
|
|
888
|
+
};
|
|
889
|
+
export const LIVE_KIND_HEADLINES = {
|
|
890
|
+
session: "the agent session started",
|
|
891
|
+
message: "",
|
|
892
|
+
edit: "→ editing files",
|
|
893
|
+
command: "→ running a command",
|
|
894
|
+
read: "→ reading the code",
|
|
895
|
+
search: "→ searching the code",
|
|
896
|
+
lookup: "→ looking something up",
|
|
897
|
+
plan: "→ organizing its plan",
|
|
898
|
+
delegate: "→ delegating to a helper",
|
|
899
|
+
tool: "→ using a tool",
|
|
900
|
+
};
|
|
901
|
+
/** A step's one line of words, with its target when it has one. */
|
|
902
|
+
export function stepWords(step) {
|
|
903
|
+
if (step.target === undefined || step.kind === "plan" || step.kind === "tool")
|
|
904
|
+
return LIVE_KIND_HEADLINES[step.kind];
|
|
905
|
+
return `${TARGET_WORDS[step.kind]} ${step.kind === "delegate" ? step.target : `\`${step.target}\``}`;
|
|
906
|
+
}
|
|
907
|
+
/** Records as the plain lines the run page and `toolroll peek` show. */
|
|
908
|
+
export function projectLiveText(records) {
|
|
909
|
+
const lines = [];
|
|
910
|
+
for (const record of records) {
|
|
911
|
+
switch (record.k) {
|
|
912
|
+
case "open": break;
|
|
913
|
+
case "start":
|
|
914
|
+
lines.push("the agent session started");
|
|
915
|
+
break;
|
|
916
|
+
case "say":
|
|
917
|
+
lines.push(...record.t);
|
|
918
|
+
break;
|
|
919
|
+
case "step":
|
|
920
|
+
lines.push(stepWords(record));
|
|
921
|
+
break;
|
|
922
|
+
case "done":
|
|
923
|
+
if (record.ok === false)
|
|
924
|
+
lines.push(" ✗ failed");
|
|
925
|
+
break;
|
|
926
|
+
case "cut":
|
|
927
|
+
lines.push(LIVE_CUT_WORDS);
|
|
928
|
+
break;
|
|
929
|
+
case "cap":
|
|
930
|
+
lines.push(LIVE_CAP_WORDS);
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return lines;
|
|
935
|
+
}
|