@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.
Files changed (168) hide show
  1. package/CONTRIBUTING.md +26 -0
  2. package/LICENSE +204 -0
  3. package/README.md +239 -2
  4. package/SECURITY.md +19 -0
  5. package/bin/gallivant.mjs +261 -0
  6. package/bin/package-plugin.mjs +16 -0
  7. package/bin/release-live.mjs +29 -0
  8. package/bin/release-package.mjs +23 -0
  9. package/bin/session-runtime.mjs +13 -0
  10. package/bin/task-delivery-state.mjs +12 -0
  11. package/docs/README.md +72 -0
  12. package/docs/agent-setup.md +228 -0
  13. package/docs/app.md +111 -0
  14. package/docs/architecture.md +265 -0
  15. package/docs/chains.md +102 -0
  16. package/docs/codex-cloud.md +284 -0
  17. package/docs/commands.md +649 -0
  18. package/docs/config.md +437 -0
  19. package/docs/execution.md +796 -0
  20. package/docs/guardrails.md +311 -0
  21. package/docs/install.md +398 -0
  22. package/docs/launch.md +45 -0
  23. package/docs/learning.md +369 -0
  24. package/docs/network-recovery.md +101 -0
  25. package/docs/openai-agents.md +457 -0
  26. package/docs/prd.md +246 -0
  27. package/docs/preparation.md +110 -0
  28. package/docs/release-qualification.md +196 -0
  29. package/docs/releasing.md +149 -0
  30. package/docs/runtime-environment.md +113 -0
  31. package/docs/task-delivery.md +476 -0
  32. package/docs/tasks.md +739 -0
  33. package/lib/aport.mjs +95 -0
  34. package/lib/attribution.mjs +95 -0
  35. package/lib/block.mjs +89 -0
  36. package/lib/capabilities.mjs +3 -0
  37. package/lib/chains.mjs +222 -0
  38. package/lib/codex_cloud.mjs +160 -0
  39. package/lib/codex_cloud_config.mjs +20 -0
  40. package/lib/codex_cloud_input.mjs +49 -0
  41. package/lib/codex_cloud_result.mjs +74 -0
  42. package/lib/commands/doctor.mjs +466 -0
  43. package/lib/commands/init.mjs +245 -0
  44. package/lib/commands/learn.mjs +54 -0
  45. package/lib/commands/new.mjs +47 -0
  46. package/lib/commands/ready.mjs +45 -0
  47. package/lib/commands/register.mjs +249 -0
  48. package/lib/commands/resume.mjs +224 -0
  49. package/lib/commands/serve.mjs +349 -0
  50. package/lib/commands/start.mjs +247 -0
  51. package/lib/commands/start_services.mjs +22 -0
  52. package/lib/commands/status.mjs +150 -0
  53. package/lib/commands/task.mjs +73 -0
  54. package/lib/commands/up.mjs +228 -0
  55. package/lib/commands/why.mjs +143 -0
  56. package/lib/config.mjs +461 -0
  57. package/lib/context.mjs +102 -0
  58. package/lib/control.mjs +202 -0
  59. package/lib/control_contract.mjs +16 -0
  60. package/lib/coordinator.mjs +19 -0
  61. package/lib/credential.mjs +161 -0
  62. package/lib/daemon.mjs +156 -0
  63. package/lib/deliverable.mjs +357 -0
  64. package/lib/events.mjs +261 -0
  65. package/lib/executable.mjs +31 -0
  66. package/lib/execution.mjs +707 -0
  67. package/lib/execution_contract.mjs +15 -0
  68. package/lib/execution_input.mjs +4 -0
  69. package/lib/execution_io.mjs +94 -0
  70. package/lib/execution_journal.mjs +69 -0
  71. package/lib/execution_preparation.mjs +29 -0
  72. package/lib/execution_result.mjs +266 -0
  73. package/lib/fs.mjs +140 -0
  74. package/lib/gh.mjs +289 -0
  75. package/lib/git.mjs +434 -0
  76. package/lib/harness.mjs +521 -0
  77. package/lib/harness_log.mjs +432 -0
  78. package/lib/hash.mjs +16 -0
  79. package/lib/identity.mjs +98 -0
  80. package/lib/learning.mjs +654 -0
  81. package/lib/learning_contract.mjs +2 -0
  82. package/lib/learning_data.mjs +135 -0
  83. package/lib/learning_input.mjs +111 -0
  84. package/lib/learning_qualification.mjs +67 -0
  85. package/lib/learning_signer.mjs +151 -0
  86. package/lib/learning_store.mjs +88 -0
  87. package/lib/log.mjs +53 -0
  88. package/lib/network_diagnostics.mjs +64 -0
  89. package/lib/network_recovery.mjs +42 -0
  90. package/lib/notify.mjs +58 -0
  91. package/lib/openai_agents.mjs +373 -0
  92. package/lib/openai_agents_config.mjs +14 -0
  93. package/lib/openai_agents_http.mjs +87 -0
  94. package/lib/openai_agents_input.mjs +15 -0
  95. package/lib/openai_agents_sources.mjs +112 -0
  96. package/lib/owner_delivery.mjs +443 -0
  97. package/lib/plugin_package.mjs +340 -0
  98. package/lib/prd.mjs +635 -0
  99. package/lib/preparation_agents.mjs +33 -0
  100. package/lib/preparation_codex.mjs +212 -0
  101. package/lib/probe.mjs +126 -0
  102. package/lib/prompt.mjs +238 -0
  103. package/lib/provider_health.mjs +55 -0
  104. package/lib/public-format.mjs +31 -0
  105. package/lib/public_output.mjs +49 -0
  106. package/lib/release_package.mjs +154 -0
  107. package/lib/repository_files.mjs +86 -0
  108. package/lib/run.mjs +385 -0
  109. package/lib/run_log.mjs +94 -0
  110. package/lib/runtime-environment.mjs +87 -0
  111. package/lib/runtime_dependencies.mjs +15 -0
  112. package/lib/runtime_node.mjs +107 -0
  113. package/lib/runtime_state.mjs +174 -0
  114. package/lib/sources/index.mjs +27 -0
  115. package/lib/sources/prd.mjs +50 -0
  116. package/lib/start_contract.mjs +37 -0
  117. package/lib/state.mjs +246 -0
  118. package/lib/task_allowance.mjs +31 -0
  119. package/lib/task_completion.mjs +61 -0
  120. package/lib/task_contract.mjs +450 -0
  121. package/lib/task_delivery.mjs +219 -0
  122. package/lib/task_delivery_cancel.mjs +34 -0
  123. package/lib/task_delivery_contract.mjs +61 -0
  124. package/lib/task_delivery_mcp.mjs +40 -0
  125. package/lib/task_delivery_store.mjs +43 -0
  126. package/lib/task_delivery_view.mjs +88 -0
  127. package/lib/task_environment.mjs +281 -0
  128. package/lib/task_prepare.mjs +224 -0
  129. package/lib/task_store.mjs +38 -0
  130. package/lib/tasks.mjs +513 -0
  131. package/lib/tick.mjs +1654 -0
  132. package/lib/time.mjs +36 -0
  133. package/lib/tracker/assets.mjs +43 -0
  134. package/lib/tracker/pages.mjs +320 -0
  135. package/lib/tracker/runs.mjs +102 -0
  136. package/lib/tracker/stream.mjs +74 -0
  137. package/lib/tracker_process.mjs +107 -0
  138. package/lib/verification_sandbox.mjs +181 -0
  139. package/node_modules/smol-toml/LICENSE +24 -0
  140. package/node_modules/smol-toml/README.md +605 -0
  141. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  142. package/node_modules/smol-toml/dist/date.js +156 -0
  143. package/node_modules/smol-toml/dist/error.d.ts +37 -0
  144. package/node_modules/smol-toml/dist/error.js +67 -0
  145. package/node_modules/smol-toml/dist/extract.js +289 -0
  146. package/node_modules/smol-toml/dist/index.cjs +770 -0
  147. package/node_modules/smol-toml/dist/index.d.ts +44 -0
  148. package/node_modules/smol-toml/dist/index.js +34 -0
  149. package/node_modules/smol-toml/dist/parse.d.ts +73 -0
  150. package/node_modules/smol-toml/dist/parse.js +164 -0
  151. package/node_modules/smol-toml/dist/primitive.js +151 -0
  152. package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
  153. package/node_modules/smol-toml/dist/stringify.js +219 -0
  154. package/node_modules/smol-toml/dist/struct.js +156 -0
  155. package/node_modules/smol-toml/dist/util.d.ts +43 -0
  156. package/node_modules/smol-toml/dist/util.js +57 -0
  157. package/node_modules/smol-toml/package.json +68 -0
  158. package/npm-shrinkwrap.json +1823 -0
  159. package/package.json +105 -4
  160. package/templates/claude-settings.json +18 -0
  161. package/templates/passport.gallivant.json +83 -0
  162. package/templates/plugin/bundled-runtime.json +26 -0
  163. package/templates/plugin/schemas/mcp.schema.json +120 -0
  164. package/templates/plugin/schemas/plugin.schema.json +65 -0
  165. package/templates/plugin/task-delivery.SKILL.md +60 -0
  166. package/templates/prd.md +32 -0
  167. package/templates/tracker/tracker.css +301 -0
  168. 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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 `&lt;b&gt;` 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(/^&gt;\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
+ }