@uchibeke/gallivant 0.0.0-stage → 0.2.1
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/CONTRIBUTING.md +26 -0
- package/LICENSE +204 -0
- package/README.md +239 -2
- package/SECURITY.md +19 -0
- package/bin/gallivant.mjs +261 -0
- package/bin/package-plugin.mjs +16 -0
- package/bin/release-live.mjs +29 -0
- package/bin/release-package.mjs +23 -0
- package/bin/session-runtime.mjs +13 -0
- package/bin/task-delivery-state.mjs +12 -0
- package/docs/README.md +72 -0
- package/docs/agent-setup.md +228 -0
- package/docs/app.md +111 -0
- package/docs/architecture.md +265 -0
- package/docs/chains.md +102 -0
- package/docs/codex-cloud.md +284 -0
- package/docs/commands.md +649 -0
- package/docs/config.md +437 -0
- package/docs/execution.md +796 -0
- package/docs/guardrails.md +311 -0
- package/docs/install.md +398 -0
- package/docs/launch.md +45 -0
- package/docs/learning.md +369 -0
- package/docs/network-recovery.md +101 -0
- package/docs/openai-agents.md +457 -0
- package/docs/prd.md +246 -0
- package/docs/preparation.md +110 -0
- package/docs/release-qualification.md +196 -0
- package/docs/releasing.md +149 -0
- package/docs/runtime-environment.md +113 -0
- package/docs/task-delivery.md +476 -0
- package/docs/tasks.md +739 -0
- package/lib/aport.mjs +95 -0
- package/lib/attribution.mjs +95 -0
- package/lib/block.mjs +89 -0
- package/lib/capabilities.mjs +3 -0
- package/lib/chains.mjs +222 -0
- package/lib/codex_cloud.mjs +160 -0
- package/lib/codex_cloud_config.mjs +20 -0
- package/lib/codex_cloud_input.mjs +49 -0
- package/lib/codex_cloud_result.mjs +74 -0
- package/lib/commands/doctor.mjs +466 -0
- package/lib/commands/init.mjs +245 -0
- package/lib/commands/learn.mjs +54 -0
- package/lib/commands/new.mjs +47 -0
- package/lib/commands/ready.mjs +45 -0
- package/lib/commands/register.mjs +249 -0
- package/lib/commands/resume.mjs +224 -0
- package/lib/commands/serve.mjs +349 -0
- package/lib/commands/start.mjs +247 -0
- package/lib/commands/start_services.mjs +22 -0
- package/lib/commands/status.mjs +150 -0
- package/lib/commands/task.mjs +73 -0
- package/lib/commands/up.mjs +228 -0
- package/lib/commands/why.mjs +143 -0
- package/lib/config.mjs +461 -0
- package/lib/context.mjs +102 -0
- package/lib/control.mjs +202 -0
- package/lib/control_contract.mjs +16 -0
- package/lib/coordinator.mjs +19 -0
- package/lib/credential.mjs +161 -0
- package/lib/daemon.mjs +156 -0
- package/lib/deliverable.mjs +357 -0
- package/lib/events.mjs +261 -0
- package/lib/executable.mjs +31 -0
- package/lib/execution.mjs +707 -0
- package/lib/execution_contract.mjs +15 -0
- package/lib/execution_input.mjs +4 -0
- package/lib/execution_io.mjs +94 -0
- package/lib/execution_journal.mjs +69 -0
- package/lib/execution_preparation.mjs +29 -0
- package/lib/execution_result.mjs +266 -0
- package/lib/fs.mjs +140 -0
- package/lib/gh.mjs +289 -0
- package/lib/git.mjs +434 -0
- package/lib/harness.mjs +521 -0
- package/lib/harness_log.mjs +432 -0
- package/lib/hash.mjs +16 -0
- package/lib/identity.mjs +98 -0
- package/lib/learning.mjs +654 -0
- package/lib/learning_contract.mjs +2 -0
- package/lib/learning_data.mjs +135 -0
- package/lib/learning_input.mjs +111 -0
- package/lib/learning_qualification.mjs +67 -0
- package/lib/learning_signer.mjs +151 -0
- package/lib/learning_store.mjs +88 -0
- package/lib/log.mjs +53 -0
- package/lib/network_diagnostics.mjs +64 -0
- package/lib/network_recovery.mjs +42 -0
- package/lib/notify.mjs +58 -0
- package/lib/openai_agents.mjs +373 -0
- package/lib/openai_agents_config.mjs +14 -0
- package/lib/openai_agents_http.mjs +87 -0
- package/lib/openai_agents_input.mjs +15 -0
- package/lib/openai_agents_sources.mjs +112 -0
- package/lib/owner_delivery.mjs +443 -0
- package/lib/plugin_package.mjs +340 -0
- package/lib/prd.mjs +635 -0
- package/lib/preparation_agents.mjs +33 -0
- package/lib/preparation_codex.mjs +212 -0
- package/lib/probe.mjs +126 -0
- package/lib/prompt.mjs +238 -0
- package/lib/provider_health.mjs +55 -0
- package/lib/public-format.mjs +31 -0
- package/lib/public_output.mjs +49 -0
- package/lib/release_package.mjs +154 -0
- package/lib/repository_files.mjs +86 -0
- package/lib/run.mjs +385 -0
- package/lib/run_log.mjs +94 -0
- package/lib/runtime-environment.mjs +87 -0
- package/lib/runtime_dependencies.mjs +15 -0
- package/lib/runtime_node.mjs +107 -0
- package/lib/runtime_state.mjs +174 -0
- package/lib/sources/index.mjs +27 -0
- package/lib/sources/prd.mjs +50 -0
- package/lib/start_contract.mjs +37 -0
- package/lib/state.mjs +246 -0
- package/lib/task_allowance.mjs +31 -0
- package/lib/task_completion.mjs +61 -0
- package/lib/task_contract.mjs +450 -0
- package/lib/task_delivery.mjs +219 -0
- package/lib/task_delivery_cancel.mjs +34 -0
- package/lib/task_delivery_contract.mjs +61 -0
- package/lib/task_delivery_mcp.mjs +40 -0
- package/lib/task_delivery_store.mjs +43 -0
- package/lib/task_delivery_view.mjs +88 -0
- package/lib/task_environment.mjs +281 -0
- package/lib/task_prepare.mjs +224 -0
- package/lib/task_store.mjs +38 -0
- package/lib/tasks.mjs +513 -0
- package/lib/tick.mjs +1654 -0
- package/lib/time.mjs +36 -0
- package/lib/tracker/assets.mjs +43 -0
- package/lib/tracker/pages.mjs +320 -0
- package/lib/tracker/runs.mjs +102 -0
- package/lib/tracker/stream.mjs +74 -0
- package/lib/tracker_process.mjs +107 -0
- package/lib/verification_sandbox.mjs +181 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +289 -0
- package/node_modules/smol-toml/dist/index.cjs +770 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/npm-shrinkwrap.json +1823 -0
- package/package.json +105 -4
- package/templates/claude-settings.json +18 -0
- package/templates/passport.gallivant.json +83 -0
- package/templates/plugin/bundled-runtime.json +26 -0
- package/templates/plugin/schemas/mcp.schema.json +120 -0
- package/templates/plugin/schemas/plugin.schema.json +65 -0
- package/templates/plugin/task-delivery.SKILL.md +60 -0
- package/templates/prd.md +32 -0
- package/templates/tracker/tracker.css +301 -0
- package/templates/tracker/tracker.js +721 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
// The tracker's one script, served as a file so the CSP is `script-src 'self'` with no nonce and no hash, and so
|
|
2
|
+
// nothing here needs an inline event handler.
|
|
3
|
+
//
|
|
4
|
+
// Two jobs: the sidebar (filter, collapse, persist) and the log pane (stream, auto-scroll). Both are wired from
|
|
5
|
+
// data attributes the server rendered, so this file knows no ids or paths of its own.
|
|
6
|
+
//
|
|
7
|
+
// lib/harness_log.mjs is spliced in ahead of this file by lib/tracker/assets.mjs, so `harnessLine` is in scope here
|
|
8
|
+
// and strict mode is declared there, at the top of the served script where a directive counts.
|
|
9
|
+
|
|
10
|
+
// Where this tracker is mounted: empty when it owns the origin, something like "/gallivant" when a host server
|
|
11
|
+
// delegates a prefix to it. The server publishes it on <body data-base> because this file is served from one route
|
|
12
|
+
// and cannot be told at build time where it lives.
|
|
13
|
+
const BASE = document.body.dataset.base || "";
|
|
14
|
+
|
|
15
|
+
// Fixed actions and messages share delivery, but only Send message reads the composer's draft.
|
|
16
|
+
const runControls = document.getElementById("run-controls");
|
|
17
|
+
if (runControls) {
|
|
18
|
+
const buttons = [...runControls.querySelectorAll("button[data-action]")];
|
|
19
|
+
const note = document.getElementById("run-note");
|
|
20
|
+
const form = document.getElementById("message-form");
|
|
21
|
+
const status = document.getElementById("control-status");
|
|
22
|
+
const refresh = document.getElementById("control-refresh");
|
|
23
|
+
const lastMessage = document.getElementById("last-message");
|
|
24
|
+
const interrupt = document.getElementById("interrupt-run");
|
|
25
|
+
document.getElementById("restore-message")?.addEventListener("click", () => {
|
|
26
|
+
if (!note.value.trim()) note.value = lastMessage.querySelector("pre").textContent;
|
|
27
|
+
note.focus(); syncButtons();
|
|
28
|
+
});
|
|
29
|
+
const endpoint = `${BASE}/api/run/control?id=${encodeURIComponent(runControls.dataset.entry)}`;
|
|
30
|
+
let pending = runControls.dataset.pending === "true";
|
|
31
|
+
let closed = false;
|
|
32
|
+
const syncButtons = () => buttons.forEach(button => {
|
|
33
|
+
button.disabled = pending || closed || button.dataset.available !== "true"
|
|
34
|
+
|| (button.dataset.action === "message" && !note.value.trim());
|
|
35
|
+
});
|
|
36
|
+
const label = action => buttons.find(b => b.dataset.action === action)?.textContent || "Request";
|
|
37
|
+
const poll = async () => {
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch(endpoint);
|
|
40
|
+
if (!response.ok) throw new Error("Could not check the request. Reload to see its status.");
|
|
41
|
+
const data = await response.json();
|
|
42
|
+
const control = data.control;
|
|
43
|
+
if (!control) throw new Error("Request status is unavailable. Reload before retrying.");
|
|
44
|
+
status.textContent = `${label(control.action)} — ${control.status}: ${control.message || "Waiting for the daemon and any active run."}`;
|
|
45
|
+
if (control.status === "queued" || control.status === "applying") { setTimeout(poll, 3000); return; }
|
|
46
|
+
pending = false;
|
|
47
|
+
closed = data.state === "done";
|
|
48
|
+
if (data.lastMessage?.note) {
|
|
49
|
+
lastMessage.querySelector("pre").textContent = data.lastMessage.note;
|
|
50
|
+
lastMessage.hidden = false;
|
|
51
|
+
}
|
|
52
|
+
if (control.status === "failed" && control.action === "message" && !note.value) note.value = control.note || lastMessage.querySelector("pre").textContent;
|
|
53
|
+
syncButtons();
|
|
54
|
+
// Follow the newest run unless that would discard a draft or interrupt typing.
|
|
55
|
+
if (!note.value && document.activeElement !== note) { location.replace(refresh.href); return; }
|
|
56
|
+
refresh.hidden = false;
|
|
57
|
+
} catch (e) { status.textContent = e.message; setTimeout(poll, 5000); }
|
|
58
|
+
};
|
|
59
|
+
const submit = async action => {
|
|
60
|
+
const button = buttons.find(b => b.dataset.action === action);
|
|
61
|
+
if (!button || button.disabled) return;
|
|
62
|
+
const draft = action === "message" ? note.value : "";
|
|
63
|
+
pending = true; syncButtons(); refresh.hidden = true;
|
|
64
|
+
status.textContent = `${label(action)} — queuing…`;
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetch(endpoint, {
|
|
67
|
+
method: "POST", headers: { "Content-Type": "application/json", "X-Gallivant-Token": runControls.dataset.token },
|
|
68
|
+
body: JSON.stringify(action === "message" ? { action, note: draft, ...(interrupt?.checked ? { interrupt: true } : {}) } : { action }),
|
|
69
|
+
});
|
|
70
|
+
const data = await response.json();
|
|
71
|
+
if (!response.ok) throw new Error(data.error || "Request failed. Reload to check its status before retrying.");
|
|
72
|
+
if (action === "message") {
|
|
73
|
+
// A user can keep composing while delivery is pending. Never erase newer edits.
|
|
74
|
+
if (note.value === draft) note.value = "";
|
|
75
|
+
lastMessage.querySelector("pre").textContent = draft;
|
|
76
|
+
lastMessage.hidden = false;
|
|
77
|
+
}
|
|
78
|
+
status.textContent = `${label(action)} — queued. Waiting for the daemon and any active run.`;
|
|
79
|
+
setTimeout(poll, 1000);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
status.textContent = `${label(action)} — ${e.message}`;
|
|
82
|
+
pending = false; syncButtons();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
for (const button of buttons.filter(b => b.dataset.action !== "message")) {
|
|
86
|
+
button.addEventListener("click", () => submit(button.dataset.action));
|
|
87
|
+
}
|
|
88
|
+
form.addEventListener("submit", event => { event.preventDefault(); submit("message"); });
|
|
89
|
+
note.addEventListener("input", syncButtons);
|
|
90
|
+
syncButtons();
|
|
91
|
+
if (pending) setTimeout(poll, 1000);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// localStorage can throw, not only return null: a private window, a browser set to block site data, or a frame whose
|
|
95
|
+
// storage the embedder partitioned away. Every read and write is guarded and the page works with no stored value.
|
|
96
|
+
const LS = {
|
|
97
|
+
get: (k, d) => { try { const v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); } catch { return d; } },
|
|
98
|
+
set: (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* the page works without it */ } },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ---- sidebar --------------------------------------------------------------------
|
|
102
|
+
// Groups collapse, a filter searches every row's text, and collapsed state survives a reload. The one detail that
|
|
103
|
+
// makes a filter feel right, taken from command-center's explorer: while a filter is active, collapsed state is
|
|
104
|
+
// ignored and every match shows expanded. Otherwise a match hidden inside a closed group reads as "no results".
|
|
105
|
+
const side = document.getElementById("side");
|
|
106
|
+
if (side) {
|
|
107
|
+
const key = "gallivant.tracker.collapsed:" + (side.dataset.view || "default");
|
|
108
|
+
const closed = () => LS.get(key, {});
|
|
109
|
+
const groups = [...side.querySelectorAll(".group")];
|
|
110
|
+
const q = document.getElementById("filter");
|
|
111
|
+
const count = document.getElementById("matches");
|
|
112
|
+
|
|
113
|
+
const render = () => {
|
|
114
|
+
const term = (q?.value || "").trim().toLowerCase();
|
|
115
|
+
const shut = term ? {} : closed(); // a filter ignores collapsed state, so every match is visible
|
|
116
|
+
let shown = 0;
|
|
117
|
+
for (const g of groups) {
|
|
118
|
+
const rows = [...g.querySelectorAll("a.row")];
|
|
119
|
+
let hits = 0;
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
// The row's own text is what is searched: its handle, its detail and its badge, whatever the server put
|
|
122
|
+
// there. `hay` is filled in by the server so a filter matches fields the row does not display in full.
|
|
123
|
+
const hay = ((row.dataset.hay || "") + " " + row.textContent).toLowerCase();
|
|
124
|
+
const hit = !term || hay.includes(term);
|
|
125
|
+
row.hidden = !hit;
|
|
126
|
+
if (hit) hits++;
|
|
127
|
+
}
|
|
128
|
+
g.hidden = rows.length > 0 && hits === 0;
|
|
129
|
+
g.classList.toggle("closed", !!shut[g.dataset.group]);
|
|
130
|
+
const tally = g.querySelector("h3 small");
|
|
131
|
+
if (tally) tally.textContent = term ? hits + "/" + rows.length : String(rows.length);
|
|
132
|
+
shown += hits;
|
|
133
|
+
}
|
|
134
|
+
if (count) count.textContent = term ? shown + (shown === 1 ? " match" : " matches") : "";
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
for (const g of groups) {
|
|
138
|
+
const head = g.querySelector("h3");
|
|
139
|
+
if (!head) continue;
|
|
140
|
+
head.setAttribute("role", "button");
|
|
141
|
+
head.tabIndex = 0;
|
|
142
|
+
const toggle = () => {
|
|
143
|
+
// A toggle while a filter is active would be invisible (the filter overrides collapse), so it only records
|
|
144
|
+
// the intent for when the filter is cleared.
|
|
145
|
+
const c = closed();
|
|
146
|
+
if (c[g.dataset.group]) delete c[g.dataset.group]; else c[g.dataset.group] = true;
|
|
147
|
+
LS.set(key, c);
|
|
148
|
+
render();
|
|
149
|
+
};
|
|
150
|
+
head.addEventListener("click", toggle);
|
|
151
|
+
head.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } });
|
|
152
|
+
}
|
|
153
|
+
q?.addEventListener("input", render);
|
|
154
|
+
// Escape clears the filter, which is what every filter box does and costs one line.
|
|
155
|
+
q?.addEventListener("keydown", e => { if (e.key === "Escape") { q.value = ""; render(); } });
|
|
156
|
+
render();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- grouping switch ------------------------------------------------------------
|
|
160
|
+
// Two groupings, so this is a navigation, not a state machine: the server renders the grouping the URL asks for and
|
|
161
|
+
// the page reloads into it. Nothing to keep in sync, and a grouped view is a link somebody can bookmark or frame.
|
|
162
|
+
const by = document.getElementById("groupby");
|
|
163
|
+
if (by) by.addEventListener("change", () => {
|
|
164
|
+
const u = new URL(location.href);
|
|
165
|
+
u.searchParams.set("by", by.value);
|
|
166
|
+
location.assign(u.pathname + "?" + u.searchParams.toString()); // same document, never the top-level window
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---- log pane -------------------------------------------------------------------
|
|
170
|
+
const pane = document.getElementById("log"), state = document.getElementById("logstate");
|
|
171
|
+
// The most LIVE rows the pane keeps. Beyond this the oldest live rows are dropped; the complete log is on disk and the
|
|
172
|
+
// "load earlier" control fetches any of it back.
|
|
173
|
+
//
|
|
174
|
+
// Explicitly loaded history has its own cap. Live rows never evict that window;
|
|
175
|
+
// another page can replace older history without growing browser memory forever.
|
|
176
|
+
const MAX_LINES = 2000;
|
|
177
|
+
const MAX_HISTORY_LINES = 2000;
|
|
178
|
+
if (pane) {
|
|
179
|
+
// Auto-scroll follows the reader: the pane jumps to the bottom only when it was already at the bottom, so
|
|
180
|
+
// scrolling up to read something does not yank you back down on the next line.
|
|
181
|
+
const atBottom = () => pane.scrollHeight - pane.scrollTop - pane.clientHeight < 40;
|
|
182
|
+
// Lines arrive in bursts: a reconnect delivers hundreds at once, and a busy harness writes faster than the screen
|
|
183
|
+
// refreshes. Appending per line stalled the main thread badly enough that the browser dropped the stream and
|
|
184
|
+
// retried, which replayed the burst, which stalled it again. Buffer and paint once per frame instead.
|
|
185
|
+
let pending = [], painting = false, closed = false, disconnected = false;
|
|
186
|
+
// Reaching back: `oldest` is the id of the oldest line the pane holds (what the history route takes as
|
|
187
|
+
// `?before=`), `loaded` is how many rows the reader explicitly loaded, which the cap exempts, and `busy`
|
|
188
|
+
// keeps two presses of the button from fetching the same window twice. Declared with the rest of the pane's
|
|
189
|
+
// state because `trim` reads `loaded` and runs from `paint`.
|
|
190
|
+
let oldest = null, loaded = 0, busy = false, historyRequest = null;
|
|
191
|
+
// Coalesce missing display ranges into two IDs, rather than an unbounded queue.
|
|
192
|
+
// This cursor can recover a burst between rows already painted on the page.
|
|
193
|
+
let recoveryBefore = null, recoveryFloor = null;
|
|
194
|
+
const replayId = value => Number.isSafeInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
|
|
195
|
+
const eventHtml = (row, id, index) => {
|
|
196
|
+
const html = renderParsed(row, { harness });
|
|
197
|
+
return id === null ? html : html.replace(/^(<div class="[^"]*")/, '$1 data-replay="' + id + '" data-replay-row="' + index + '"');
|
|
198
|
+
};
|
|
199
|
+
const rememberDropped = (id, before) => {
|
|
200
|
+
if (id === null || before === null) return;
|
|
201
|
+
recoveryFloor = Math.min(recoveryFloor ?? id, id);
|
|
202
|
+
recoveryBefore = Math.max(recoveryBefore ?? before, before);
|
|
203
|
+
offer(recoveryBefore - 1);
|
|
204
|
+
};
|
|
205
|
+
// Which harness wrote this log, so the right stream reader is used on each line. The server knows it from the
|
|
206
|
+
// registry and publishes it on the pane; an unknown or absent value means every line renders as itself.
|
|
207
|
+
const harness = pane.dataset.harness || "";
|
|
208
|
+
/**
|
|
209
|
+
* Drop the oldest LIVE rows past the cap, leaving loaded history alone.
|
|
210
|
+
*
|
|
211
|
+
* Two decisions here, and the second is the one that took a rewrite.
|
|
212
|
+
*
|
|
213
|
+
* The budget is MAX_LINES plus whatever the reader explicitly loaded. Raising MAX_LINES for everyone would put the
|
|
214
|
+
* DOM cost of a long history onto every pane whether or not it asked, and leaving the budget alone would mean the
|
|
215
|
+
* next burst of live output silently undoes the click.
|
|
216
|
+
*
|
|
217
|
+
* And the trim comes off the row after the history block, not off the top. Trimming from the top looks right and is
|
|
218
|
+
* wrong: history sits above the live tail, so the top IS the history, and a growing budget that still trims from the
|
|
219
|
+
* top just delays eating the thing it was meant to protect. Dropping the oldest live rows instead keeps both ends
|
|
220
|
+
* the reader cares about, which is the history they asked for and the output arriving now.
|
|
221
|
+
*
|
|
222
|
+
* `loaded` is the count of history rows at the front of the pane, so it is also the index the trim starts at.
|
|
223
|
+
*/
|
|
224
|
+
const trim = () => {
|
|
225
|
+
while (pane.childElementCount > MAX_LINES + loaded) {
|
|
226
|
+
const victim = pane.children[loaded];
|
|
227
|
+
// Only when the pane holds nothing but history, which the budget makes impossible: it always leaves MAX_LINES of
|
|
228
|
+
// room above `loaded`. Belt and braces, because an unbounded loop here would hang the tab.
|
|
229
|
+
if (!victim) return;
|
|
230
|
+
const id = replayId(victim.dataset.replay);
|
|
231
|
+
rememberDropped(id, id === null ? null : id + 1);
|
|
232
|
+
victim.remove();
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A heartbeat, applied to the row its call already has.
|
|
238
|
+
*
|
|
239
|
+
* Claude Code sends one every 30 seconds for a tool that has not returned. Twenty-one of them arrived in the run
|
|
240
|
+
* this was built against and twenty-one rows would have been noise, so the row counts up instead: the `Bash` line
|
|
241
|
+
* the reader is already looking at gains "running 4m 30s" and keeps it current. Returns whether the row was found;
|
|
242
|
+
* a heartbeat for a row that is gone falls through to a row of its own, since a call alive for four minutes with
|
|
243
|
+
* nothing on screen is the complaint this answers.
|
|
244
|
+
*/
|
|
245
|
+
const beat = p => {
|
|
246
|
+
if (!p.of) return false;
|
|
247
|
+
// The id is read off the DOM and compared, never spliced into a selector: it comes out of a log file, so a
|
|
248
|
+
// querySelector built by concatenation would be a selector injection waiting for the right bytes.
|
|
249
|
+
let row = null;
|
|
250
|
+
for (const el of pane.querySelectorAll("div.ll.t[data-id]")) if (el.dataset.id === p.of) row = el;
|
|
251
|
+
if (!row) return false;
|
|
252
|
+
let e = row.querySelector("span.e");
|
|
253
|
+
if (!e) {
|
|
254
|
+
e = document.createElement("span");
|
|
255
|
+
e.className = "e";
|
|
256
|
+
// Inside the argument cell, not beside it. `.ll.t` is a two-column grid, so a third child of the row would
|
|
257
|
+
// start a second grid row and the timer would land under the command instead of after it.
|
|
258
|
+
(row.querySelector("span.a") || row).append(e);
|
|
259
|
+
}
|
|
260
|
+
// textContent, never innerHTML: the only characters this writes are ones `elapsed` made out of a number.
|
|
261
|
+
e.textContent = elapsed(p.secs);
|
|
262
|
+
return true;
|
|
263
|
+
};
|
|
264
|
+
const paint = () => {
|
|
265
|
+
painting = false;
|
|
266
|
+
if (closed || !pending.length) return;
|
|
267
|
+
const stick = atBottom();
|
|
268
|
+
// Markup goes in first, heartbeats are applied second. That order matters: a burst can carry the call and its
|
|
269
|
+
// first heartbeat together, and applying the beat before the row existed would have drawn a duplicate row.
|
|
270
|
+
//
|
|
271
|
+
// The markup is appended to the pane itself rather than inside a wrapper div. The cap below counts the pane's
|
|
272
|
+
// children, and a wrapper per frame made one paint of three hundred lines count as a single child, so the cap
|
|
273
|
+
// never fired and the DOM grew without bound on a long run.
|
|
274
|
+
const rows = pending.flatMap(event => renderRows(event.line, { harness }).map((row, index) => ({ row, id: event.id, index })));
|
|
275
|
+
pending = [];
|
|
276
|
+
const html = rows.filter(r => r.row.kind !== "beat").map(r => eventHtml(r.row, r.id, r.index)).join("");
|
|
277
|
+
if (html) { const t = document.createElement("template"); t.innerHTML = html; pane.append(t.content); }
|
|
278
|
+
// A heartbeat whose row is not on the pane (trimmed by the cap, or the pane opened after the call started) still
|
|
279
|
+
// deserves to be said: a Bash step alive for four minutes with nothing on screen is the complaint this answers.
|
|
280
|
+
const orphans = rows.filter(r => r.row.kind === "beat" && !beat(r.row));
|
|
281
|
+
if (orphans.length) {
|
|
282
|
+
const t = document.createElement("template");
|
|
283
|
+
t.innerHTML = orphans.map(r => eventHtml(r.row, r.id, r.index)).join("");
|
|
284
|
+
pane.append(t.content);
|
|
285
|
+
}
|
|
286
|
+
if (stick) pane.scrollTop = pane.scrollHeight;
|
|
287
|
+
// A very long pane costs memory and scroll performance for lines nobody scrolls back to. The whole log is on
|
|
288
|
+
// disk, and the "load earlier" control above can fetch any of it back, so dropping the oldest rows loses nothing.
|
|
289
|
+
trim();
|
|
290
|
+
};
|
|
291
|
+
const push = (line, id) => {
|
|
292
|
+
if (oldest === null && id !== null) oldest = id;
|
|
293
|
+
if (pending.length >= 400) {
|
|
294
|
+
const dropped = pending.shift();
|
|
295
|
+
rememberDropped(dropped.id, pending[0]?.id ?? id);
|
|
296
|
+
tell("display buffer full; older events remain in paginated history");
|
|
297
|
+
}
|
|
298
|
+
pending.push({ line, id }); if (!painting) { painting = true; requestAnimationFrame(paint); }
|
|
299
|
+
};
|
|
300
|
+
const note = (text, cls) => { const el = document.createElement("div"); el.className = "ll " + cls; el.textContent = text; pane.append(el); trim(); };
|
|
301
|
+
// A run from another tool has no registry entry and is addressed by its source label instead; one of Gallivant's
|
|
302
|
+
// own is addressed by entry. The server validates whichever arrives.
|
|
303
|
+
const q = new URLSearchParams({ run: pane.dataset.run });
|
|
304
|
+
if (pane.dataset.source) q.set("source", pane.dataset.source); else q.set("id", pane.dataset.entry);
|
|
305
|
+
const url = BASE + "/api/run/output?" + q.toString();
|
|
306
|
+
|
|
307
|
+
// ---- reaching back ------------------------------------------------------------
|
|
308
|
+
// `oldest` is the id of the oldest line the pane holds, which is what the history route wants as `?before=`. The
|
|
309
|
+
// stream's `skipped` frame is the count of lines it did not send, so the first line the pane holds is that count
|
|
310
|
+
// plus one, and the window before it is the one the reader gets first.
|
|
311
|
+
const earlier = document.getElementById("earlier"), earlierState = document.getElementById("earlierstate");
|
|
312
|
+
const tell = text => { if (earlierState) earlierState.textContent = text; };
|
|
313
|
+
const offer = n => {
|
|
314
|
+
if (!earlier) return;
|
|
315
|
+
earlier.hidden = n <= 0;
|
|
316
|
+
earlier.disabled = n <= 0 || busy;
|
|
317
|
+
if (n > 0) tell(n + (n === 1 ? " earlier line" : " earlier lines") + " not shown yet");
|
|
318
|
+
};
|
|
319
|
+
/**
|
|
320
|
+
* Fetch the window before what the pane holds and prepend it, without moving what the reader is looking at.
|
|
321
|
+
*
|
|
322
|
+
* Prepending to a scrolled container is what makes this kind of control feel broken: the browser keeps scrollTop,
|
|
323
|
+
* which is measured from the top, so inserting content above it slides everything the reader was reading downward
|
|
324
|
+
* out of view. The fix is to measure the height before and after and add the difference back to scrollTop, which
|
|
325
|
+
* leaves the same pixels under the same line of the reader's eye. scrollHeight is read after the insert and before
|
|
326
|
+
* anything else touches the pane, because a later reflow would make the difference wrong.
|
|
327
|
+
*/
|
|
328
|
+
const loadEarlier = async () => {
|
|
329
|
+
if (disconnected || busy || (recoveryBefore ?? oldest ?? 0) <= 1) return;
|
|
330
|
+
const recovering = recoveryBefore !== null, cursor = recoveryBefore ?? oldest;
|
|
331
|
+
busy = true;
|
|
332
|
+
historyRequest = new AbortController();
|
|
333
|
+
if (earlier) earlier.disabled = true;
|
|
334
|
+
tell("loading");
|
|
335
|
+
try {
|
|
336
|
+
const h = new URLSearchParams(q);
|
|
337
|
+
h.set("before", String(recovering ? cursor : oldest));
|
|
338
|
+
const r = await fetch(BASE + "/api/run/history?" + h.toString(), { headers: { accept: "application/json" }, signal: historyRequest.signal });
|
|
339
|
+
if (!r.ok) throw new Error("history " + r.status);
|
|
340
|
+
const got = await r.json();
|
|
341
|
+
if (disconnected) return;
|
|
342
|
+
const lines = Array.isArray(got.lines) ? got.lines : [];
|
|
343
|
+
const present = new Set([...pane.children].filter(row => replayId(row.dataset.replay) !== null).map(row => `${row.dataset.replay}:${row.dataset.replayRow ?? 0}`));
|
|
344
|
+
const queued = new Set(pending.map(event => event.id).filter(id => id !== null));
|
|
345
|
+
const html = lines.flatMap((line, i) => {
|
|
346
|
+
const id = replayId(got.ids?.[i] ?? (Number.isSafeInteger(got.from) ? got.from + i : null));
|
|
347
|
+
if (id !== null && queued.has(id)) return [];
|
|
348
|
+
return renderRows(line, { harness }).flatMap((row, index) => id !== null && present.has(`${id}:${index}`) ? [] : eventHtml(row, id, index));
|
|
349
|
+
}).join("");
|
|
350
|
+
const before = pane.scrollHeight, top = pane.scrollTop;
|
|
351
|
+
if (html) {
|
|
352
|
+
const t = document.createElement("template");
|
|
353
|
+
t.innerHTML = html;
|
|
354
|
+
// How many rows this actually added, counted from the template rather than from `lines`: one event can be
|
|
355
|
+
// several rows and a clean tool result is none, so the line count is not the row count and the cap needs the
|
|
356
|
+
// row count.
|
|
357
|
+
if (recovering) {
|
|
358
|
+
// A gap may lie between painted rows. Merge by stable replay ID,
|
|
359
|
+
// keeping the existing tool rows and heartbeat state intact.
|
|
360
|
+
for (const row of [...t.content.children]) {
|
|
361
|
+
const id = replayId(row.dataset.replay);
|
|
362
|
+
const index = Number(row.dataset.replayRow ?? 0);
|
|
363
|
+
const next = [...pane.children].find(el => replayId(el.dataset.replay) > id || (replayId(el.dataset.replay) === id && Number(el.dataset.replayRow ?? 0) > index));
|
|
364
|
+
pane.insertBefore(row, next ?? null);
|
|
365
|
+
}
|
|
366
|
+
const firstLive = [...pane.children].findIndex(row => (replayId(row.dataset.replay) ?? 0) >= cursor);
|
|
367
|
+
loaded = Math.max(loaded, firstLive < 0 ? pane.childElementCount : firstLive);
|
|
368
|
+
} else {
|
|
369
|
+
loaded += t.content.childElementCount;
|
|
370
|
+
pane.prepend(t.content);
|
|
371
|
+
}
|
|
372
|
+
// Keep the older page just requested; evict the newest history beside
|
|
373
|
+
// the live tail so another click can actually reach earlier output.
|
|
374
|
+
while (loaded > MAX_HISTORY_LINES && pane.children[loaded - 1]) { pane.children[loaded - 1].remove(); loaded--; }
|
|
375
|
+
trim();
|
|
376
|
+
}
|
|
377
|
+
pane.scrollTop = top + (pane.scrollHeight - before);
|
|
378
|
+
if (Number.isSafeInteger(got.from) && got.from > 0) oldest = Math.min(oldest ?? got.from, got.from);
|
|
379
|
+
if (recovering && recoveryBefore === cursor) {
|
|
380
|
+
if ((got.from ?? cursor) <= recoveryFloor || (got.before ?? 0) <= 0) recoveryBefore = recoveryFloor = null;
|
|
381
|
+
else recoveryBefore = got.from;
|
|
382
|
+
}
|
|
383
|
+
offer(recoveryBefore !== null ? recoveryBefore - 1 : Math.min(got.before ?? 0, (oldest ?? 1) - 1));
|
|
384
|
+
if (got.gap) { note(got.gap, "skip"); tell("some history is unavailable; saved results are separate from logs"); }
|
|
385
|
+
else if (recoveryBefore === null && (got.before ?? 0) <= 0) tell("this is the first line of the run");
|
|
386
|
+
} catch {
|
|
387
|
+
// The pane is the only place a reader sees this, so it says what happened rather than going quiet. The button
|
|
388
|
+
// stays available: a failed fetch is worth retrying and nothing was consumed.
|
|
389
|
+
tell("could not load earlier lines");
|
|
390
|
+
} finally {
|
|
391
|
+
busy = false; historyRequest = null;
|
|
392
|
+
if (earlier) earlier.disabled = (recoveryBefore ?? oldest ?? 0) <= 1;
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
earlier?.addEventListener("click", loadEarlier);
|
|
396
|
+
|
|
397
|
+
const es = new EventSource(url);
|
|
398
|
+
globalThis.addEventListener?.("pagehide", () => { disconnected = closed = true; es.close(); pending = []; historyRequest?.abort(); }, { once: true });
|
|
399
|
+
es.addEventListener("gap", e => { note(JSON.parse(e.data), "skip"); tell("some history is unavailable; saved results are separate from logs"); });
|
|
400
|
+
const say = text => { if (state) state.textContent = text; };
|
|
401
|
+
es.addEventListener("skipped", e => {
|
|
402
|
+
const n = Number(e.data);
|
|
403
|
+
if (!Number.isFinite(n) || n <= 0) return;
|
|
404
|
+
note(n + ' earlier lines not shown; "load earlier lines" above fetches them, and the whole log is in the run folder', "skip");
|
|
405
|
+
// The tail starts at line n+1, so the oldest line the pane holds is n+1 and the window before it starts at n.
|
|
406
|
+
oldest ??= n + 1;
|
|
407
|
+
offer(recoveryBefore !== null ? recoveryBefore - 1 : n);
|
|
408
|
+
});
|
|
409
|
+
es.onmessage = e => {
|
|
410
|
+
const text = JSON.parse(e.data);
|
|
411
|
+
// A terminal frame is the one thing the stream sends that is not a log line, and it is where the pane stops
|
|
412
|
+
// reconnecting. Every one of them is bracketed and nothing else the server sends is.
|
|
413
|
+
if (/^\[(exit|no run|ended)\b/.test(text)) {
|
|
414
|
+
// Once, however many times it arrives. A reconnect resumes from Last-Event-ID and the server re-sends the
|
|
415
|
+
// terminal frame for a run that is already over, so a pane that had reconnected printed the closing line
|
|
416
|
+
// twice. `closed` is the record that the end was already drawn, and it is checked before anything is drawn.
|
|
417
|
+
if (closed) return;
|
|
418
|
+
paint();
|
|
419
|
+
closed = true;
|
|
420
|
+
es.close();
|
|
421
|
+
note(text.slice(1, -1), "d " + (text === "[exit 0]" ? "ok" : "bad"));
|
|
422
|
+
say(text === "[no run]" ? "no run on disk" : text === "[exit 0]" ? "finished" : text.slice(1, -1));
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (!closed) push(text, replayId(e.lastEventId));
|
|
426
|
+
};
|
|
427
|
+
es.onopen = () => say("live");
|
|
428
|
+
// readyState 2 is closed, which is the normal end of a finished run and not a failure to report.
|
|
429
|
+
es.onerror = () => { if (es.readyState !== 2) say("reconnecting"); };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ---- log lines ------------------------------------------------------------------
|
|
433
|
+
// The renderer is command-center's public/logline.js, unchanged apart from the escaper, which is inlined here
|
|
434
|
+
// because Gallivant has no md.js. Two copies that drift are how the five log panels it replaced went wrong in the
|
|
435
|
+
// first place, so a fix to the regexes belongs in both.
|
|
436
|
+
//
|
|
437
|
+
// What differs is the input, and only the input. Command-center's harness reducer hands the panel an indented
|
|
438
|
+
// "Tool: argument" line; Gallivant's runs write the harness's own stream-json straight to job.log, so a line here is
|
|
439
|
+
// an event object. `harnessLine` (lib/harness_log.mjs, spliced in above) turns one of those into the same
|
|
440
|
+
// { kind, tool, arg } this renderer already took, and every regex below is then the fallback for a line that is not
|
|
441
|
+
// an event of a shape the harness's reader claims. Parser per harness, one renderer.
|
|
442
|
+
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
// Tool calls arrive indented by the harness reducer in lib/harness.mjs: " WebSearch: quiet hotels in Lisbon".
|
|
446
|
+
const TOOL = /^ {2}([A-Za-z_][\w.]*):\s?(.*)$/;
|
|
447
|
+
// A terminal line the runner appends: "done in 42s (harness exit 0)" or "failed after 271s: ...".
|
|
448
|
+
const DONE = /^(done in |wrote |failed after )/;
|
|
449
|
+
// The runner's own opening line, written by lib/harness_run.mjs before the harness says anything.
|
|
450
|
+
const HEAD = /^harness \S+.*prompt \d+ chars/;
|
|
451
|
+
// Bare URLs. Stops before trailing punctuation so a sentence-final period is not swallowed into the link.
|
|
452
|
+
const URL_RE = /\bhttps?:\/\/[^\s<>"')\]]+[^\s<>"')\].,;:!?]/g;
|
|
453
|
+
// A project-relative path with an extension: ideas/2026-09-25-trip.md, gtm/posts/one.md.
|
|
454
|
+
const PATH_RE = /\b(?:[\w.-]+\/)+[\w.-]+\.[a-z]{1,5}\b/g;
|
|
455
|
+
|
|
456
|
+
/** Which tools are worth colouring apart. Everything else is a neutral tool call. */
|
|
457
|
+
const TOOL_KIND = {
|
|
458
|
+
WebSearch: "search", WebFetch: "fetch", Read: "read", Write: "write", Edit: "write",
|
|
459
|
+
Bash: "shell", Agent: "agent", Task: "agent", ToolSearch: "meta", ListAgents: "meta",
|
|
460
|
+
SendMessage: "meta", Monitor: "meta", Glob: "read", Grep: "read",
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Classify one line.
|
|
465
|
+
* { kind: "tool", tool, arg } a tool call
|
|
466
|
+
* { kind: "deny", tool, arg } a guardrail hook that blocked one
|
|
467
|
+
* { kind: "done", text, ok } the run's own closing line
|
|
468
|
+
* { kind: "text", text } anything else the model said
|
|
469
|
+
*/
|
|
470
|
+
function parseLine(line, { harness } = {}) {
|
|
471
|
+
const raw = String(line);
|
|
472
|
+
// The harness's own stream first. It answers for every line it recognises; anything else falls through to the
|
|
473
|
+
// prose rules below, which is what keeps a runner line, a stderr spill or a crash message readable.
|
|
474
|
+
const ev = harness ? harnessLine(harness, raw) : null;
|
|
475
|
+
if (ev) return ev;
|
|
476
|
+
const m = TOOL.exec(raw);
|
|
477
|
+
if (m) return { kind: "tool", tool: m[1], arg: m[2].trim(), tone: TOOL_KIND[m[1]] || "" };
|
|
478
|
+
const t = raw.trim();
|
|
479
|
+
if (HEAD.test(t)) return { kind: "head", text: t };
|
|
480
|
+
if (DONE.test(t)) return { kind: "done", text: t, ok: !t.startsWith("failed") };
|
|
481
|
+
// Nothing here claimed it, so it is not prose the model wrote and it must not be read as prose.
|
|
482
|
+
//
|
|
483
|
+
// This is the case the real defect was in. A harness's own stderr fell through to `{ kind: "text" }`, and a prose
|
|
484
|
+
// row runs the markdown pass, which is correct for a model's paragraph and wrong for a message nobody wrote as
|
|
485
|
+
// markdown. `Error: --add-dir *cannot* be used with __proto__` came out as `Error: --add-dir <i>cannot</i> be used
|
|
486
|
+
// with <b>proto</b>`: the asterisks and the underscores were taken as emphasis and DELETED, so the identifier the
|
|
487
|
+
// reader needed was not on the page at all. On the run this was read off, the only line that said why the run
|
|
488
|
+
// failed (`Error: Input must be provided either through stdin or as a prompt argument when using --print`) was the
|
|
489
|
+
// most important line in the log and was being handed to a formatter that could eat parts of it.
|
|
490
|
+
//
|
|
491
|
+
// `raw` is the answer: the line as itself, escaped, with URLs still linked because a URL in a crash message is
|
|
492
|
+
// worth clicking, and with no other pass over it. So every character the harness printed is on screen.
|
|
493
|
+
return { kind: "raw", text: t };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The markdown a model actually writes into a log line, and nothing else.
|
|
498
|
+
*
|
|
499
|
+
* No library, for the reason this whole file has none: the subset that shows up in a harness's prose is inline code,
|
|
500
|
+
* bold, italic and a link, and the block level is a heading or a list item, which the reader has already split onto
|
|
501
|
+
* its own row. That is five regexes. A markdown library is tens of kilobytes and its job is the ninety percent of
|
|
502
|
+
* CommonMark (tables, reference links, HTML passthrough, nested blockquotes) that never appears here, and the
|
|
503
|
+
* passthrough part is the one thing this must not have: the input is agent output.
|
|
504
|
+
*
|
|
505
|
+
* Order matters. Inline code is taken out FIRST and put back last, so a `*` or an `_` inside a code span is not read
|
|
506
|
+
* as emphasis, which is the bug every naive markdown regex has. And every one of these runs on already-escaped text,
|
|
507
|
+
* so `<b>` in the log stays the characters `<b>` and the only `<b>` on the page is one this function wrote.
|
|
508
|
+
*/
|
|
509
|
+
function md(html, done) {
|
|
510
|
+
// Inline code first, parked behind a placeholder that cannot occur in escaped text (it holds no & < > " and the
|
|
511
|
+
// backtick pairs are gone by the time anything else looks at the string).
|
|
512
|
+
const code = [];
|
|
513
|
+
let out = html.replace(/`([^`]+)`/g, (_, c) => `\u0000${code.push(c) - 1}\u0000`);
|
|
514
|
+
// Bold before italic, so ** is not read as two separate * runs.
|
|
515
|
+
out = out.replace(/\*\*([^*\n]+)\*\*/g, "<b>$1</b>").replace(/__([^_\n]+)__/g, "<b>$1</b>");
|
|
516
|
+
// Italic only on a `*` that is not part of a `**` and not a bare bullet, and on `_` only between word characters,
|
|
517
|
+
// so a snake_case identifier in prose is not turned into emphasis.
|
|
518
|
+
out = out.replace(/(^|[^*\w])\*([^*\n]+)\*(?![*\w])/g, "$1<i>$2</i>")
|
|
519
|
+
.replace(/(^|\W)_([^_\n]+)_(?=\W|$)/g, "$1<i>$2</i>");
|
|
520
|
+
// A markdown link. The href is checked against http(s) before it is written: a javascript: or data: target is left
|
|
521
|
+
// as the text it was, because this function's whole safety property is that it writes no attribute it did not vet.
|
|
522
|
+
//
|
|
523
|
+
// The finished anchor is PARKED, not left in the string. The bare-URL pass runs after this and would otherwise find
|
|
524
|
+
// the href it just wrote and linkify the URL inside the attribute, nesting one anchor inside another's href. Parking
|
|
525
|
+
// it means the URL pass never sees a character this function already turned into markup.
|
|
526
|
+
out = out.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
|
527
|
+
(m, label, href) => done(`<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>`));
|
|
528
|
+
// A leading heading or blockquote marker is structure, not text: the row IS the heading, so the hashes go.
|
|
529
|
+
out = out.replace(/^(#{1,6})\s+(.*)$/, (m, h, rest) => `<b>${rest}</b>`).replace(/^>\s+/, "");
|
|
530
|
+
return out.replace(/\u0000(\d+)\u0000/g, (m, i) => done(`<code>${code[Number(i)]}</code>`));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Escape text, then turn URLs, file paths and markdown inside it into markup. Escaping first is what keeps this safe:
|
|
534
|
+
* every replacement operates on already-escaped text and inserts only markup this function wrote. */
|
|
535
|
+
function linkify(text, { fileHref, markdown } = {}) {
|
|
536
|
+
let html = esc(text);
|
|
537
|
+
// Markup this function has finished with, parked so no later pass can look inside it. A URL in an href and a `*` in
|
|
538
|
+
// a code span are both cases where a later regex would otherwise edit markup rather than text.
|
|
539
|
+
const built = [];
|
|
540
|
+
const done = markup => `\u0001${built.push(markup) - 1}\u0001`;
|
|
541
|
+
if (markdown) html = md(html, done);
|
|
542
|
+
html = html.replace(URL_RE, u => `<a href="${u}" target="_blank" rel="noopener noreferrer">${short(u)}</a>`);
|
|
543
|
+
if (fileHref) {
|
|
544
|
+
html = html.replace(PATH_RE, p => {
|
|
545
|
+
const href = fileHref(p);
|
|
546
|
+
return href ? `<a href="${href}" class="fl">${p}</a>` : p;
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
return html.replace(/\u0001(\d+)\u0001/g, (m, i) => built[Number(i)]);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---- blocks ---------------------------------------------------------------------
|
|
553
|
+
// The block level of a model's markdown. `report` in lib/harness_log.mjs did the scanning, because that is where the
|
|
554
|
+
// whole multi-line string exists before it is split into rows; these five functions only turn a scanned block into
|
|
555
|
+
// markup. Same discipline as `md`: escape first, write only tags this file wrote, vet every attribute.
|
|
556
|
+
//
|
|
557
|
+
// A block is ONE row. That keeps every existing property of the pane: `paint` counts rows for the MAX_LINES cap,
|
|
558
|
+
// `trim` indexes rows past the loaded history, and a heartbeat finds its row by `data-id`. A table that rendered as
|
|
559
|
+
// six rows would make the cap eat a table's header and leave its body, which reads worse than either.
|
|
560
|
+
|
|
561
|
+
/** A prose cell: escaped, then the inline pass, then links. The one entry point a block's text may use. */
|
|
562
|
+
const inline = (text, opts) => linkify(text, { ...opts, markdown: true });
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* A fenced code block, verbatim.
|
|
566
|
+
*
|
|
567
|
+
* This is the fix with the most value in it. Every line of a fence used to go through the inline pass, which reads a
|
|
568
|
+
* `*` as emphasis and DELETES the asterisks: a shell glob or a pointer dereference inside a code block came out with
|
|
569
|
+
* characters missing. Inside a `<pre>` nothing is markdown, so the body is escaped and nothing else touches it.
|
|
570
|
+
*
|
|
571
|
+
* `lang` is the model's own word off the fence line, so it is not written into a class until it is vetted: letters,
|
|
572
|
+
* digits, `+`, `-` and `#` only, lowercased, and capped. A language nobody styles is harmless; a language that could
|
|
573
|
+
* close the attribute is not, which is why the test is a whitelist and not an escape. No highlighter is loaded: the
|
|
574
|
+
* CSP is `script-src 'self'` and a highlighter would be both a fetch and tens of kilobytes for prettier keywords.
|
|
575
|
+
*/
|
|
576
|
+
function fence(p) {
|
|
577
|
+
const lang = /^[A-Za-z0-9+#-]{1,20}$/.test(p.lang || "") ? ` data-lang="${p.lang.toLowerCase()}"` : "";
|
|
578
|
+
// An unclosed fence is marked so the pane can say the block is still arriving rather than looking like a finished
|
|
579
|
+
// one that happens to end mid-statement. It renders as the <pre> it already is.
|
|
580
|
+
const open = p.open ? " open" : "";
|
|
581
|
+
return `<div class="ll fence${open}"${lang}><pre>${esc(p.code || "")}</pre></div>`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* A list, as a real `<ul>` or `<ol>`.
|
|
586
|
+
*
|
|
587
|
+
* Nesting is by depth rather than by recursion: `report` already measured each item's indent, and a list three levels
|
|
588
|
+
* deep in a log pane is rare enough that a flat list with an indent class reads the same and cannot go wrong on a
|
|
589
|
+
* ragged indent. That is the deliberate limit, not an oversight: real nesting would need a tree and the failure mode
|
|
590
|
+
* of a tree built from agent output is a list that swallows its siblings.
|
|
591
|
+
*/
|
|
592
|
+
function list(p, opts) {
|
|
593
|
+
const tag = p.ordered ? "ol" : "ul";
|
|
594
|
+
const items = (p.items || []).map(it => {
|
|
595
|
+
const d = Number(it.depth) || 0;
|
|
596
|
+
const cls = d > 0 ? ` class="d${Math.min(3, d)}"` : "";
|
|
597
|
+
return `<li${cls}>${inline(it.text || "", opts)}</li>`;
|
|
598
|
+
}).join("");
|
|
599
|
+
return `<div class="ll blk"><${tag} class="md">${items}</${tag}></div>`;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* A pipe table.
|
|
604
|
+
*
|
|
605
|
+
* The header's width is the table's width. Agent tables are ragged often enough that trusting each row's own cell
|
|
606
|
+
* count would build a table the browser reflows per row; padding short rows and dropping cells past the header keeps
|
|
607
|
+
* the columns aligned, which is the only reason to draw a table rather than the lines it was written as.
|
|
608
|
+
*
|
|
609
|
+
* The wrapper scrolls on its own. A wide table inside the pane must not widen the pane, because the pane sits in the
|
|
610
|
+
* layout grid and a table that pushes it out moves the whole column instead of scrolling inside it.
|
|
611
|
+
*/
|
|
612
|
+
function table(p, opts) {
|
|
613
|
+
const head = Array.isArray(p.head) ? p.head : [];
|
|
614
|
+
const width = head.length;
|
|
615
|
+
if (!width) return "";
|
|
616
|
+
const th = head.map(c => `<th>${inline(c, opts)}</th>`).join("");
|
|
617
|
+
const body = (Array.isArray(p.rows) ? p.rows : []).map(row => {
|
|
618
|
+
const cs = Array.isArray(row) ? row : [];
|
|
619
|
+
// Exactly `width` cells, every time: short rows gain empty ones and long rows lose the overflow.
|
|
620
|
+
const tds = [];
|
|
621
|
+
for (let i = 0; i < width; i++) tds.push(`<td>${inline(cs[i] ?? "", opts)}</td>`);
|
|
622
|
+
return `<tr>${tds.join("")}</tr>`;
|
|
623
|
+
}).join("");
|
|
624
|
+
return `<div class="ll blk"><div class="tw"><table class="md"><thead><tr>${th}</tr></thead><tbody>${body}</tbody></table></div></div>`;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** A run of `>` lines as one blockquote, rather than the marker stripped off each line and the rows left apart. */
|
|
628
|
+
function quote(p, opts) {
|
|
629
|
+
const lines = (Array.isArray(p.lines) ? p.lines : []).map(l => String(l));
|
|
630
|
+
// A blank line inside the quote is a paragraph break in it, so runs are joined and the breaks kept.
|
|
631
|
+
const paras = [];
|
|
632
|
+
let held = [];
|
|
633
|
+
for (const l of lines) {
|
|
634
|
+
if (!l.trim()) { if (held.length) { paras.push(held.join(" ")); held = []; } continue; }
|
|
635
|
+
held.push(l.trim());
|
|
636
|
+
}
|
|
637
|
+
if (held.length) paras.push(held.join(" "));
|
|
638
|
+
const body = paras.map(t => `<p>${inline(t, opts)}</p>`).join("");
|
|
639
|
+
return `<div class="ll blk"><blockquote>${body}</blockquote></div>`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** A URL short enough to read in a narrow panel: host plus the last meaningful segment. */
|
|
643
|
+
function short(url) {
|
|
644
|
+
try {
|
|
645
|
+
const u = new URL(url);
|
|
646
|
+
const parts = u.pathname.split("/").filter(Boolean);
|
|
647
|
+
const tail = parts.length ? "/" + parts[parts.length - 1] : "";
|
|
648
|
+
const host = u.hostname.replace(/^www\./, "");
|
|
649
|
+
const s = host + (tail.length > 28 ? tail.slice(0, 27) + "…" : tail);
|
|
650
|
+
return esc(s.length > 48 ? s.slice(0, 47) + "…" : s);
|
|
651
|
+
} catch { return esc(url.length > 48 ? url.slice(0, 47) + "…" : url); }
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** How long a call has been running, as the shortest thing that reads: "30s", "4m 30s". */
|
|
655
|
+
function elapsed(secs) {
|
|
656
|
+
// null and "" both become 0 through Number(), and "running 0s" reads as a fact the harness did not state. Only a
|
|
657
|
+
// number the harness actually sent gets a time on it.
|
|
658
|
+
const s = typeof secs === "number" ? secs : NaN;
|
|
659
|
+
if (!Number.isFinite(s) || s < 0) return "running";
|
|
660
|
+
return s < 60 ? `running ${Math.round(s)}s` : `running ${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* One line as the rows it means, always a list.
|
|
665
|
+
*
|
|
666
|
+
* One event can be several rows: a harness that batches parallel tool calls into one message writes one line and
|
|
667
|
+
* means three. The pane needs the rows rather than their markup, because a heartbeat row is applied to a row already
|
|
668
|
+
* on screen rather than appended, and that decision cannot be made once the row is a string.
|
|
669
|
+
*/
|
|
670
|
+
function renderRows(line, opts = {}) {
|
|
671
|
+
const p = parseLine(line, opts);
|
|
672
|
+
return Array.isArray(p) ? p : [p];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** One line as HTML. Paths are left as text here: the tracker has no file browser to open them in. */
|
|
676
|
+
function renderLine(line, opts = {}) {
|
|
677
|
+
return renderRows(line, opts).map(one => renderParsed(one, opts)).join("");
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** One classified row as HTML. */
|
|
681
|
+
function renderParsed(p, opts) {
|
|
682
|
+
// The reader recognised the event and there is nothing to say about it: a tool result that succeeded. Only a
|
|
683
|
+
// reader may ask for this; a line that failed to parse renders as itself instead.
|
|
684
|
+
if (p.kind === "skip") return "";
|
|
685
|
+
// A blocked tool call is the one line in a run an operator must not scroll past, so it is not a tool row with a
|
|
686
|
+
// colour: it keeps the closing line's weight and border, in the failure colour, which is the loudest thing this
|
|
687
|
+
// stylesheet has. `.ll.d.bad` already exists for the run that failed; a denial is the same class of news.
|
|
688
|
+
if (p.kind === "deny") {
|
|
689
|
+
return `<div class="ll d bad"><span class="n">${esc(p.tool || "BLOCKED")}</span> ${linkify(p.arg || "", opts)}</div>`;
|
|
690
|
+
}
|
|
691
|
+
// A heartbeat for a call that is still running. It belongs on the row that call already has, which `paint` does by
|
|
692
|
+
// finding `data-id` and writing the elapsed time into the row's own `.e` span. Reached here only when the row is
|
|
693
|
+
// gone (the cap trimmed it, or the pane opened after the call started), and then a row of its own is better than
|
|
694
|
+
// nothing: a Bash step alive for four minutes is the thing the reader was waiting to be told.
|
|
695
|
+
if (p.kind === "beat") {
|
|
696
|
+
return `<div class="ll t meta"><span class="n">${esc(p.tool || "tool")}</span><span class="a">${esc(elapsed(p.secs))}</span></div>`;
|
|
697
|
+
}
|
|
698
|
+
if (p.kind === "tool") {
|
|
699
|
+
const arg = p.arg ? `<span class="a">${linkify(p.arg, opts)}</span>` : "";
|
|
700
|
+
// `data-id` is the call's tool_use_id when the harness gave one, so a heartbeat can find this row and count up on
|
|
701
|
+
// it. Absent otherwise, and the row is then byte for byte what it always was.
|
|
702
|
+
const id = p.id ? ` data-id="${esc(p.id)}"` : "";
|
|
703
|
+
return `<div class="ll t${p.tone ? " " + p.tone : ""}"${id}><span class="n">${esc(p.tool)}</span>${arg}</div>`;
|
|
704
|
+
}
|
|
705
|
+
if (p.kind === "head") return `<div class="ll h">${linkify(p.text, opts)}</div>`;
|
|
706
|
+
if (p.kind === "done") return `<div class="ll d ${p.ok ? "ok" : "bad"}">${linkify(p.text, opts)}</div>`;
|
|
707
|
+
// A line no reader claimed: the harness's own stderr, a truncated write, a runner line. Escaped, linkified, and
|
|
708
|
+
// nothing else: no markdown pass, because nobody wrote this as markdown and the pass can delete characters from it.
|
|
709
|
+
if (p.kind === "raw") return `<div class="ll raw">${linkify(p.text, opts)}</div>`;
|
|
710
|
+
// The block level of the markdown a model wrote, which `report` in lib/harness_log.mjs has already scanned into
|
|
711
|
+
// blocks. Each of these is one row, so the pane's cap and its heartbeat lookup still count rows the way they did.
|
|
712
|
+
if (p.kind === "fence") return fence(p);
|
|
713
|
+
if (p.kind === "list") return list(p, opts);
|
|
714
|
+
if (p.kind === "table") return table(p, opts);
|
|
715
|
+
if (p.kind === "quote") return quote(p, opts);
|
|
716
|
+
if (p.kind === "rule") return `<div class="ll hr"></div>`;
|
|
717
|
+
// Markdown is read on prose rows only. A tool argument is a shell command or a path, where a `*` is a glob and an
|
|
718
|
+
// `_` is part of a name, so reading either as emphasis would corrupt the one thing that row exists to show. The
|
|
719
|
+
// closing line is generated text with no markdown in it. Prose is the only place the model was writing markdown.
|
|
720
|
+
return `<div class="ll">${linkify(p.text, { ...opts, markdown: true })}</div>`;
|
|
721
|
+
}
|