@uchibeke/gallivant 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CONTRIBUTING.md +26 -0
  2. package/LICENSE +204 -0
  3. package/README.md +201 -2
  4. package/SECURITY.md +19 -0
  5. package/bin/gallivant.mjs +257 -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 +70 -0
  12. package/docs/agent-setup.md +214 -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 +620 -0
  18. package/docs/config.md +430 -0
  19. package/docs/execution.md +796 -0
  20. package/docs/guardrails.md +311 -0
  21. package/docs/install.md +338 -0
  22. package/docs/launch.md +42 -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 +140 -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 +457 -0
  43. package/lib/commands/init.mjs +34 -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 +195 -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 +100 -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 +631 -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 +133 -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 +1635 -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
package/lib/time.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // Time helpers. The daemon and the tests both go through `clock` so a test can move time forward without sleeping.
2
+
3
+ export const nowIso = (clock = Date) => new Date(clock.now()).toISOString();
4
+
5
+ /** Filesystem-safe timestamp for run folder names: 2026-09-24T02-04-00-123 */
6
+ export const stamp = (clock = Date) => new Date(clock.now()).toISOString().replace(/[:.]/g, "-").replace("Z", "");
7
+
8
+ export const minutes = n => n * 60_000;
9
+ export const hours = n => n * 3_600_000;
10
+
11
+ export const isAfter = (iso, sinceIso) => !!iso && (!sinceIso || Date.parse(iso) > Date.parse(sinceIso));
12
+
13
+ /**
14
+ * Inclusive of the boundary itself. GitHub stamps events to the second, so two that arrive in the same second
15
+ * carry the same timestamp; a cursor set to one of them would hide the other under a strict comparison. Callers
16
+ * that use this pair the cursor with a list of ids already handled at that instant.
17
+ */
18
+ export const isAtOrAfter = (iso, sinceIso) => !!iso && (!sinceIso || Date.parse(iso) >= Date.parse(sinceIso));
19
+
20
+ /** An ISO instant with its milliseconds dropped, so it never sorts after a GitHub timestamp in the same second. */
21
+ export const floorSecond = iso => (iso ? new Date(Math.floor(Date.parse(iso) / 1000) * 1000).toISOString() : iso);
22
+
23
+ export const later = (a, b) => (!a ? b : !b ? a : Date.parse(a) >= Date.parse(b) ? a : b);
24
+ export const earlier = (a, b) => (!a ? b : !b ? a : Date.parse(a) <= Date.parse(b) ? a : b);
25
+
26
+ /** "3m ago", "2h ago", "just now". */
27
+ export function ago(iso, clock = Date) {
28
+ if (!iso) return "never";
29
+ const ms = clock.now() - Date.parse(iso);
30
+ if (ms < 45_000) return "just now";
31
+ const m = Math.round(ms / 60_000);
32
+ if (m < 60) return `${m}m ago`;
33
+ const h = Math.round(m / 60);
34
+ if (h < 48) return `${h}h ago`;
35
+ return `${Math.round(h / 24)}d ago`;
36
+ }
@@ -0,0 +1,43 @@
1
+ // The tracker's static files, read from templates/tracker/ once per start.
2
+ //
3
+ // They are files rather than strings in a module so that the CSS is CSS and the script is JavaScript: an editor
4
+ // highlights them, and the CSP can be `script-src 'self'` and `style-src 'self'` with no nonce and no hash to keep
5
+ // in step. Read once, because they cannot change under a running server and re-reading per request would be a file
6
+ // system call on every page load.
7
+ import { readFileSync } from "node:fs";
8
+ import { join, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const HERE = dirname(fileURLToPath(import.meta.url));
12
+ const DIR = join(HERE, "..", "..", "templates", "tracker");
13
+
14
+ const load = name => readFileSync(join(DIR, name), "utf8");
15
+
16
+ /**
17
+ * lib/harness_log.mjs, as a classic script the served file can carry.
18
+ *
19
+ * The per-harness event reader lives beside the adapters, because that is where knowing a harness's stream shape
20
+ * already lives. The browser needs the same reader, and a second copy of it in templates/ would drift: the reason
21
+ * there is one is that the five log panels command-center replaced were five copies of one parse. So the module's
22
+ * source is spliced in with its `export` keywords dropped, which is all that separates it from a script: it imports
23
+ * nothing, by design, and a test asserts the spliced copy behaves as the imported one does.
24
+ */
25
+ const inlined = () => readFileSync(join(HERE, "..", "harness_log.mjs"), "utf8").replace(/^export (const|function) /gm, "$1 ");
26
+
27
+ /** Path to content type, for the two files the pages ask for. Anything else is not an asset and is a 404. */
28
+ export const ASSETS = Object.freeze({
29
+ "/tracker.css": { body: load("tracker.css"), type: "text/css; charset=utf-8" },
30
+ // Strict mode is asserted here rather than inside either file: a "use strict" directive only counts as the first
31
+ // statement of the script, and the spliced module goes in ahead of the script that used to carry it.
32
+ "/tracker.js": { body: `"use strict";\n${inlined()}\n${load("tracker.js")}`, type: "text/javascript; charset=utf-8" },
33
+ });
34
+
35
+ /**
36
+ * The palette a page should force, from `?theme=`.
37
+ *
38
+ * `prefers-color-scheme` inside an iframe follows the browser, not the page that framed it, so a dark parent gets a
39
+ * light frame and it looks broken. A parent that knows its own theme passes it on the iframe src and this stamps it
40
+ * on the root element, where the stylesheet's `[data-theme]` rules pick it up. Anything but dark or light is
41
+ * ignored rather than rejected: a bad value should fall back to the system setting, not fail the page.
42
+ */
43
+ export const themeOf = value => (value === "dark" || value === "light" ? value : null);
@@ -0,0 +1,320 @@
1
+ // The pages: plain semantic HTML, with the stylesheet and the script served as their own files.
2
+ //
3
+ // Everything interpolated goes through `esc`. PR titles, branch names, reasons, run names and log lines come from
4
+ // GitHub or from an agent, and an agent that prints `<script>` into its log must not be able to run it in the
5
+ // reader's browser. There is one escape function and every template uses it.
6
+ import { ago } from "../time.mjs";
7
+ import { contract } from "../fs.mjs";
8
+ import { controlPending, CONTROL_LABELS, MAX_NOTE, lastMessage } from "../control.mjs";
9
+ import { probeStatus } from "../probe.mjs";
10
+ import { needsAgentResume, networkRecoveryDetail } from "../network_recovery.mjs";
11
+
12
+ /** The five characters that matter in HTML text and in a double-quoted attribute. Nothing else is interpolated. */
13
+ export const esc = s => String(s ?? "")
14
+ .replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
15
+ .replaceAll('"', "&quot;").replaceAll("'", "&#39;");
16
+
17
+ /**
18
+ * The stylesheet and the script are served from their own routes rather than inlined, so `script-src 'self'` needs
19
+ * neither 'unsafe-inline' nor a per-response nonce. A nonce would mean the HTML could never be cached and every page
20
+ * would have to thread the value through; a hash would have to be recomputed whenever the file changes and would
21
+ * drift silently. Two files on two routes is the version that cannot rot.
22
+ */
23
+ export const CSP = [
24
+ "default-src 'none'",
25
+ "script-src 'self'",
26
+ "style-src 'self'",
27
+ "connect-src 'self'",
28
+ "img-src 'self' data:",
29
+ "form-action 'self'",
30
+ "base-uri 'none'",
31
+ // Framing is the point: this is meant to sit in a dashboard. It is bound to 127.0.0.1, so the only page that can
32
+ // frame it is one already running on this machine.
33
+ "frame-ancestors 'self' http://localhost:* http://127.0.0.1:*",
34
+ ].join("; ");
35
+
36
+ /**
37
+ * One page shell. `theme` is dark, light or null; null leaves the choice to `prefers-color-scheme`.
38
+ *
39
+ * `base` is the path this tracker is mounted at, empty when it owns the origin and something like `/gallivant` when
40
+ * a host server delegates a prefix to it. It is published on <body data-base> so the script can build its own URLs
41
+ * from it: the script is one static file served from one route, so it cannot be told at build time where it lives.
42
+ */
43
+ export function page({ title, body, theme = null, base = "" }) {
44
+ return `<!doctype html>
45
+ <html lang="en"${theme ? ` data-theme="${esc(theme)}"` : ""}><head><meta charset="utf-8">
46
+ <meta name="viewport" content="width=device-width, initial-scale=1">
47
+ <title>${esc(title)}</title><link rel="stylesheet" href="${esc(base)}/tracker.css"></head>
48
+ <body data-base="${esc(base)}">${body}<script src="${esc(base)}/tracker.js" defer></script></body></html>
49
+ `;
50
+ }
51
+
52
+ /**
53
+ * Every internal URL goes through here: the mount path, then the page, then the query the reader is carrying.
54
+ *
55
+ * Emitting absolute paths (`/run?id=`) would work only when the tracker owns the origin, and would 404 the moment a
56
+ * host server mounted it under a prefix. One builder means a mount point is a parameter rather than a rewrite.
57
+ */
58
+ const at = (base, path, params = {}, extra = {}) => {
59
+ const q = new URLSearchParams();
60
+ for (const [k, v] of Object.entries({ ...params, ...extra })) if (v) q.set(k, v);
61
+ const s = q.toString();
62
+ return `${base}${path}${s ? `?${s}` : ""}`;
63
+ };
64
+
65
+ /**
66
+ * `home` is a link back out to whatever is hosting the tracker, so a mounted tracker is not a one-way trip. The host
67
+ * passes its own label and URL; standalone there is nowhere to go back to and the link is simply absent.
68
+ */
69
+ const header = (base, heading, here, ctxq, home) => `<header class="top"><h1>${esc(heading)}</h1>
70
+ <nav class="views">${home ? `<a class="home" href="${esc(home.url)}">${esc(home.label)}</a>` : ""}
71
+ <a href="${esc(at(base, "/", ctxq))}"${here === "dash" ? ' aria-current="page"' : ""}>entries</a>
72
+ <a href="${esc(at(base, "/runs", ctxq))}"${here === "runs" ? ' aria-current="page"' : ""}>runs</a></nav></header>`;
73
+
74
+ /** The filter box, the grouping switch and the match count. One row, the same on both views. */
75
+ const controls = (by, groupings) => `<div class="controls">
76
+ <input type="search" id="filter" placeholder="filter" aria-label="filter the list" autocomplete="off" spellcheck="false">
77
+ ${groupings.length > 1 ? `<label for="groupby">group by</label><select id="groupby" aria-label="group by">
78
+ ${groupings.map(g => `<option value="${esc(g)}"${g === by ? " selected" : ""}>${esc(g)}</option>`).join("")}</select>` : ""}
79
+ </div><p class="hint" id="matches" aria-live="polite"></p>`;
80
+
81
+ /**
82
+ * The sidebar: one collapsible group per key, each holding rows. `view` names the localStorage bucket the collapsed
83
+ * state lives in, so the two views remember their own groups. `hay` is the text a filter matches beyond what the row
84
+ * shows, so searching for a branch or a reason finds a row that displays neither in full.
85
+ */
86
+ const sidebar = (view, groups, controlsHtml) => `<aside id="side" data-view="${esc(view)}">
87
+ ${controlsHtml}
88
+ ${groups.map(g => `<div class="group" data-group="${esc(g.key)}">
89
+ <h3><i class="caret"></i><span class="n">${esc(g.label)}</span><small>${g.rows.length}</small></h3>
90
+ <div class="rows">${g.rows.map(r => `<a class="row" href="${esc(r.href)}"${r.current ? ' aria-current="page"' : ""} data-hay="${esc(r.hay || "")}" title="${esc(r.title || r.label)}">
91
+ <i class="dot ${esc(r.tone || "")}"></i><span class="n">${esc(r.label)}</span><small>${esc(r.badge || "")}</small></a>`).join("")}</div>
92
+ </div>`).join("\n")}
93
+ ${groups.length ? "" : `<p class="empty">Nothing registered yet. <code>gallivant start</code> puts a PRD in the loop.</p>`}
94
+ </aside>`;
95
+
96
+ /** needs-you first, then running, then done: the order `gallivant status` prints, so the two agree on sight. */
97
+ const SECTIONS = [["needs_you", "needs you"], ["running", "running"], ["done", "done"]];
98
+ const TONE = { needs_you: "needs", running: "running", done: "" };
99
+
100
+ /** How a state group's rows are built. `by=harness` regroups the same rows; nothing else about them changes. */
101
+ function entryGroups(base, m, by, current, ctxq) {
102
+ const rows = SECTIONS.flatMap(([key]) => (m[key] || []).map(r => ({
103
+ section: key,
104
+ href: at(base, "/run", ctxq, { id: r.id }),
105
+ label: r.handle,
106
+ badge: r.label,
107
+ tone: r.section === "running" && r.run?.running ? "running" : TONE[key],
108
+ title: `${r.handle} ${r.label}${r.detail ? ` ${r.detail}` : ""}`,
109
+ // What a filter searches beyond the visible text: the id, the reason, the harness and the round.
110
+ hay: [r.id, r.detail, r.reason, r.harness, r.round].filter(Boolean).join(" "),
111
+ harness: r.harness || "unknown",
112
+ current: r.id === current,
113
+ })));
114
+ if (by === "harness") {
115
+ const names = [...new Set(rows.map(r => r.harness))].sort();
116
+ return names.map(h => ({ key: `harness:${h}`, label: h, rows: rows.filter(r => r.harness === h) }));
117
+ }
118
+ return SECTIONS.map(([key, label]) => ({ key, label, rows: rows.filter(r => r.section === key) })).filter(g => g.rows.length);
119
+ }
120
+
121
+ export const GROUPINGS = ["state", "harness"];
122
+
123
+ /** The dashboard: the sidebar plus the same groups as a table, so a wide frame shows detail and a narrow one a list. */
124
+ export function dashboard(m, { clock = Date, by = "state", theme = null, query = {}, base = "", home = null } = {}) {
125
+ const ctxq = { theme: query.theme, by: by === "state" ? "" : by };
126
+ const groups = entryGroups(base, m, by, null, ctxq);
127
+ const table = (title, rows) => `<h2>${esc(title)}</h2>` + (rows.length ? `<table>
128
+ <thead><tr><th>entry</th><th>state</th><th>detail</th><th>round</th><th>since</th><th></th></tr></thead><tbody>
129
+ ${rows.map(r => `<tr><td><a href="${esc(at(base, "/run", ctxq, { id: r.id }))}">${esc(r.handle)}</a></td>
130
+ <td><span class="tag${r.section === "needs_you" ? " bad" : r.run?.running ? " live" : ""}">${esc(r.label)}</span></td><td>${esc(r.detail)}</td><td class="dim">${esc(r.round)}</td>
131
+ <td class="dim">${esc(ago(r.since, clock))}</td><td>${r.url ? `<a href="${esc(r.url)}">PR</a>` : ""}</td></tr>`).join("\n")}
132
+ </tbody></table>` : `<p class="empty">(none)</p>`);
133
+ const d = m.daemon;
134
+ // A dead daemon is the first thing a reader needs, and "run gallivant up" on its own does not say how long it has
135
+ // been dead. daemon.json survives the process, so when it was last seen is on disk and worth showing.
136
+ const probes = Object.entries(d?.probes || {}).map(([k, p]) => `, ${k} ${probeStatus(p)}`).join("");
137
+ const state = d?.alive
138
+ ? `daemon up since ${ago(d.startedAt, clock)}, last tick ${ago(d.lastTick, clock)}${probes}`
139
+ : d
140
+ ? `daemon DOWN, last tick ${ago(d.lastTick, clock)}. Run: gallivant up`
141
+ : "daemon has never run here. Run: gallivant up";
142
+ return page({ title: "gallivant", theme, base, body: `${header(base, "gallivant", "dash", ctxq, home)}
143
+ <div class="layout">${sidebar("entries", groups, controls(by, GROUPINGS))}
144
+ <section class="main">
145
+ <p class="${d?.alive ? "dim" : "warn"}">${esc(state)}</p>
146
+ ${m.latestError ? `<p class="warn mono">${esc(m.latestError)}</p>` : ""}
147
+ ${SECTIONS.map(([key, label]) => table(label, m[key] || [])).join("")}
148
+ </section></div>` });
149
+ }
150
+
151
+ /**
152
+ * The log pane, with the control that reaches back into the log above it.
153
+ *
154
+ * The stream sends the last 400 lines on open and an `event: skipped` frame saying how many it did not send, because
155
+ * delivering thousands of lines as individual server-sent events stalls the browser until it drops the connection.
156
+ * The button is how the reader gets the rest: it fetches the window before what the pane holds, from
157
+ * `/api/run/history`, and keeps going back until the first line of the run.
158
+ *
159
+ * It is rendered disabled and says nothing, and the script fills in the count and enables it once the `skipped` frame
160
+ * arrives. A run whose whole log fitted in the tail has nothing older, so the control never appears for it; rendering
161
+ * it here rather than creating it in the script keeps the markup in the templates where the rest of it is.
162
+ */
163
+ const logPane = attrs => `<p class="loadmore"><button type="button" id="earlier" disabled hidden>load earlier lines</button>
164
+ <span class="hint" id="earlierstate"></span></p>
165
+ <div id="log" ${attrs} aria-live="polite" aria-label="run log" tabindex="0"></div>
166
+ <p class="note" id="logstate">connecting</p>`;
167
+
168
+ /**
169
+ * The brief moved under this entry: above the facts, because it is the one thing on the page that says the work may be
170
+ * aimed at the wrong target, and a reader who scrolls past it has been told nothing useful by the rest. Every difference
171
+ * is listed rather than just the first, which is the whole reason this page has room the `status` row does not.
172
+ *
173
+ * Every field goes through `esc` like everything else here: the criteria text and the file path come from a markdown
174
+ * file the operator wrote, which is not hostile but is not this module's to trust either.
175
+ */
176
+ const briefMoved = d => `<div class="warn">
177
+ <p><strong>${esc(d.reason)}</strong>: ${esc(d.why)}</p>
178
+ <ul>${d.diff.map(l => `<li>${esc(l)}</li>`).join("")}</ul>
179
+ ${d.verdict === "changed" ? `<p>The completion gate is judging neither brief. Resolve it: <code>gallivant resume ${esc(d.handle || "")} --resnapshot</code> makes the edited brief the one that counts, or put the brief back the way it was.</p>` : ""}
180
+ ${d.verdict === "illegal" ? `<p><code>${esc(d.path)}</code> would be refused by <code>gallivant start</code> as it stands. Fix it, then <code>gallivant ready</code> it again.</p>` : ""}
181
+ ${d.verdict === "gone" ? `<p>Restore the file, or <code>gallivant remove</code> the entry if the work is no longer wanted.</p>` : ""}
182
+ </div>`;
183
+
184
+ /** One entry: its facts, then the live log of the run it last dispatched, with the sidebar alongside. */
185
+ export function runPage({ id, entry, handle, run, initialLaunch = false, maxIterations, model, divergence = null, controlToken = null, by = "state", theme = null, query = {}, base = "", home = null }) {
186
+ const canResume = !!entry.session || initialLaunch;
187
+ const canRecheck = !!entry.pr && !needsAgentResume(entry);
188
+ const recovering = entry.network_retry?.phase === "waiting" && !entry.dispatch_stopped;
189
+ const canMessage = canResume || !!(run?.running && run.id === entry.last?.run);
190
+ const savedMessage = lastMessage(entry);
191
+ const ctxq = { theme: query.theme, by: by === "state" ? "" : by };
192
+ const fact = (k, v) => (v ? `<dt>${esc(k)}</dt><dd>${v}</dd>` : "");
193
+ const facts = [
194
+ fact("state", `<span class="tag${recovering ? "" : entry.state === "needs-human" ? " bad" : run?.running ? " live" : ""}">${esc(recovering ? "retrying" : entry.state)}</span>${entry.reason ? ` <span class="dim">${esc(entry.reason)}</span>` : ""}`),
195
+ fact("network recovery", entry.network_retry ? esc(networkRecoveryDetail(entry.network_retry)) : ""),
196
+ fact("harness", esc(entry.harness)),
197
+ fact("round", esc(`round ${entry.iteration}/${maxIterations}`)),
198
+ fact("PR", entry.url ? `<a href="${esc(entry.url)}">${esc(entry.url)}</a>` : entry.pr ? esc(`#${entry.pr}`) : ""),
199
+ fact("branch", `<code>${esc(entry.branch)}</code>`),
200
+ fact("worktree", `<code>${esc(contract(entry.worktree))}</code>`),
201
+ fact("session", `<code>${esc(entry.session)}</code>`),
202
+ fact("provider state", esc(entry.execution?.providerState)),
203
+ fact("observed / reconciled", entry.execution ? esc(`${entry.execution.observedAt ?? "unknown"} / ${entry.execution.reconciledAt ?? "never"}`) : ""),
204
+ fact("recovery action", esc(entry.execution?.recoveryAction)),
205
+ fact("run", run ? `<code>${esc(run.id)}</code> ${run.running ? `<span class="live">running</span>` : `exit ${esc(String(run.exit ?? "not recorded yet"))}`}` : ""),
206
+ fact("runs", `<a href="${esc(at(base, "/runs", ctxq, { entry: id }))}">every run for this entry</a>`),
207
+ fact("result evidence", run ? `<a href="${esc(at(base, "/api/run/result", {}, { id, run: run.id }))}">saved result and verification</a>` : ""),
208
+ ].join("");
209
+ // The log pane is driven by tracker.js, which reads these attributes. Passing the ids through the DOM rather than
210
+ // through the script keeps the script one static file the CSP can allow by origin. `data-harness` is among them
211
+ // because a log line's shape is the harness's, so the script needs the name to pick the right stream reader; the
212
+ // registry is what knows it, and an entry with none renders every line as itself.
213
+ return page({
214
+ title: `${handle} - gallivant`,
215
+ theme,
216
+ base,
217
+ body: `${header(base, handle, "run", ctxq, home)}
218
+ <div class="layout">${sidebar("entries", entryGroups(base, model, by, id, ctxq), controls(by, GROUPINGS))}
219
+ <section class="main">
220
+ ${divergence ? briefMoved(divergence) : ""}
221
+ <dl class="facts">${facts}</dl>
222
+ ${controlToken && entry.state !== "done" ? `<section id="run-controls" class="run-controls" aria-labelledby="control-heading" data-entry="${esc(id)}" data-token="${esc(controlToken)}" data-pending="${controlPending(entry.control)}">
223
+ <h2 id="control-heading">Run actions</h2>
224
+ <div class="run-actions">
225
+ <div><button type="button" data-action="recheck" data-available="${canRecheck}" aria-describedby="recheck-help"${!canRecheck || controlPending(entry.control) ? " disabled" : ""}>Recheck PR</button>
226
+ <p id="recheck-help" class="note">${needsAgentResume(entry) ? "An agent failure needs Resume agent, not PR recheck. Any scheduled recovery is shown above." : entry.pr ? "Read the latest checks and review. Keeps the round budget; does not rerun CI." : "Available once this task has a PR."}</p></div>
227
+ <div><button type="button" data-action="resume" data-available="${canResume}" aria-describedby="resume-help"${!canResume || controlPending(entry.control) ? " disabled" : ""}>Resume agent</button>
228
+ <p id="resume-help" class="note">${initialLaunch ? "Start the first run from the approved brief after repairing launch prerequisites." : entry.session ? "Continue the same session on its current instructions." : "Available once this task has an agent session."}</p></div>
229
+ <div><button type="button" data-action="cancel" data-available="true" aria-describedby="cancel-help"${controlPending(entry.control) ? " disabled" : ""}>Cancel task</button>
230
+ <p id="cancel-help" class="note">Stops further dispatch and requests cancellation. Remote execution may continue until the provider confirms.</p></div>
231
+ </div>
232
+ <form id="message-form" class="message-composer">
233
+ <label for="run-note">Message agent</label>
234
+ <textarea id="run-note" rows="3" maxlength="${MAX_NOTE}" required aria-describedby="message-help" placeholder="Share an update or give the agent direction…"${!canMessage ? " disabled" : ""}></textarea>
235
+ ${run?.running && entry.execution?.controls?.interrupt !== false ? `<label class="interrupt-option"><input id="interrupt-run" type="checkbox" aria-describedby="interrupt-help"><span>Interrupt the active run to apply this message</span></label><p id="interrupt-help" class="note">At the next daemon check, stops the current process, preserves files and resumes the same session. In-progress edits may need repair.</p>` : ""}
236
+ <div class="message-footer"><p id="message-help" class="note">${initialLaunch ? "Start the first run from the approved brief with your clarification after repairing launch prerequisites." : canMessage ? `Continues the same session with your correction, even after it finished.${run?.running ? " Leave the option unchecked to queue until this run ends." : ""}` : "Available once this task starts an agent run."}</p>
237
+ <button type="submit" data-action="message" data-available="${canMessage}" disabled>Send message</button></div>
238
+ </form>
239
+ <p class="note control-help">Resume and messages use your agent plan and keep the approved brief and guardrails. Requests need the daemon running.</p>
240
+ <p id="control-status" role="status" aria-live="polite">${esc(entry.control ? `${CONTROL_LABELS[entry.control.action] || "Request"} — ${entry.control.status}: ${entry.control.message || "Waiting for the daemon and any active run."}` : "")}</p>
241
+ <a id="control-refresh" href="${esc(at(base, "/run", ctxq, { id }))}" hidden>View updated run</a>
242
+ <details id="last-message"${savedMessage ? "" : " hidden"}><summary>Last submitted message</summary><pre>${esc(savedMessage?.note || "")}</pre><button id="restore-message" type="button">Edit and resend</button></details>
243
+ </section>` : entry.state === "done" ? `<p class="note">This PR is closed or merged. Start a follow-up PRD for further changes.</p>` : ""}
244
+ <h2>Log</h2>
245
+ ${run ? logPane(`data-entry="${esc(id)}" data-run="${esc(run.id)}" data-harness="${esc(entry.harness || "")}"`) : `<p class="empty">No run has been dispatched for this entry yet.</p>`}
246
+ </section></div>` });
247
+ }
248
+
249
+ /** The runs tab: every run folder on disk, newest first, grouped in the sidebar and listed in full beside it. */
250
+ export function runsPage(rows, { entry = "", by = "state", theme = null, query = {}, base = "", home = null, open = null } = {}) {
251
+ const ctxq = { theme: query.theme, by: by === "state" ? "" : by, entry };
252
+ const groups = runGroups(base, rows, by, entry, ctxq, open);
253
+ const body = rows.length ? `<table>
254
+ <thead><tr><th>started</th><th>entry</th><th>run</th><th>state</th><th class="num">duration</th><th></th></tr></thead><tbody>
255
+ ${rows.map(r => `<tr><td class="dim">${esc(when(r.startedAt))}</td>
256
+ ${/* A run from another tool has no registry entry, so there is nothing to link to: it is listed, not opened. */ ""}
257
+ <td>${r.entry ? `<a href="${esc(at(base, "/run", ctxq, { id: r.entry }))}">${esc(r.handle)}</a>` : `${esc(r.handle)} <span class="tag">${esc(r.source || "")}</span>`}</td>
258
+ <td class="mono">${esc(r.run)}</td>
259
+ <td>${r.running ? `<span class="tag live">running</span>` : r.exit === 0 ? `<span class="tag">exit 0</span>` : `<span class="tag bad">exit ${esc(String(r.exit ?? "not recorded"))}</span>`}</td>
260
+ <td class="num dim">${esc(duration(r))}</td>
261
+ <td><a href="${esc(at(base, "/runs", ctxq, { open: r.run, source: r.entry ? "" : r.source }))}">log</a></td></tr>`).join("\n")}
262
+ </tbody></table>` : `<p class="empty">${entry ? "No runs for that entry." : "No runs on disk yet."}</p>`;
263
+ return page({ title: "runs - gallivant", theme, base, body: `${header(base, "runs", "runs", ctxq, home)}
264
+ <div class="layout">${sidebar("runs", groups, controls(by, GROUPINGS))}
265
+ <section class="main">
266
+ ${entry ? `<p class="dim">Filtered to <code>${esc(entry)}</code>. <a href="${esc(at(base, "/runs", ctxq, { entry: "" }))}">every entry</a></p>` : ""}
267
+ ${open ? detail(base, open, ctxq) : ""}
268
+ <h2>${esc(open ? "every run" : "runs")}</h2>
269
+ ${body}<p class="note">${esc(`${rows.length} run${rows.length === 1 ? "" : "s"}, newest first.`)}</p>
270
+ </section></div>` });
271
+ }
272
+
273
+ /**
274
+ * The selected run, with its log tailing. The whole point of the sidebar: clicking a row fills this rather than
275
+ * navigating away to the entry page, which is a different view answering a different question.
276
+ */
277
+ const detail = (base, r, ctxq) => `<h2>${esc(r.handle)}</h2>
278
+ <dl class="facts">
279
+ <dt>run</dt><dd><code>${esc(r.run)}</code></dd>
280
+ <dt>${esc(r.entry ? "entry" : "source")}</dt><dd>${r.entry
281
+ ? `<a href="${esc(at(base, "/run", ctxq, { id: r.entry }))}">${esc(r.entry)}</a>`
282
+ : `<span class="tag">${esc(r.source || "")}</span>`}</dd>
283
+ <dt>started</dt><dd>${esc(when(r.startedAt))}</dd>
284
+ <dt>state</dt><dd>${r.running ? `<span class="tag live">running</span>` : r.exit === 0 ? `<span class="tag">exit 0</span>` : `<span class="tag bad">exit ${esc(String(r.exit ?? "not recorded"))}</span>`}${r.durationMs == null ? "" : ` <span class="dim">${esc(duration(r))}</span>`}</dd>
285
+ </dl>
286
+ ${/* A foreign run's `harness` is its source label, which names no reader, so its lines render as themselves. That is
287
+ the right answer for them: command-center's own trees hold prose lines, which the renderer already reads. */ ""}
288
+ ${logPane(`data-entry="${esc(r.entry || "")}" data-source="${esc(r.source || "")}" data-run="${esc(r.run)}" data-harness="${esc(r.entry ? r.harness || "" : "")}"`)}`;
289
+
290
+ /** Runs group by state (running, failed, done) or by harness, the same two choices the entries view offers. */
291
+ function runGroups(base, rows, by, entry, ctxq, open = null) {
292
+ // A row opens the run here rather than navigating to the entry page: the sidebar and the pane beside it are one
293
+ // view. Every run can be opened this way, including one from another tool, which has no entry page to go to.
294
+ const row = r => ({
295
+ href: at(base, "/runs", ctxq, { open: r.run, source: r.entry ? "" : r.source }),
296
+ current: open ? open.run === r.run && (open.source || "") === (r.source || "") : false,
297
+ label: r.handle,
298
+ badge: r.running ? "running" : duration(r) || `exit ${r.exit ?? "?"}`,
299
+ tone: r.running ? "running" : r.exit === 0 ? "" : "failed",
300
+ title: `${r.handle} ${r.run}`,
301
+ hay: [r.entry, r.run, r.name, when(r.startedAt), r.harness, r.source].filter(Boolean).join(" "),
302
+ });
303
+ if (by === "harness") {
304
+ const names = [...new Set(rows.map(r => r.harness || "unknown"))].sort();
305
+ return names.map(h => ({ key: `harness:${h}`, label: h, rows: rows.filter(r => (r.harness || "unknown") === h).map(row) }));
306
+ }
307
+ const bucket = r => (r.running ? "running" : r.exit === 0 ? "done" : "failed");
308
+ return [["running", "running"], ["failed", "failed"], ["done", "done"]]
309
+ .map(([key, label]) => ({ key: `runs:${key}`, label, rows: rows.filter(r => bucket(r) === key).map(row) }))
310
+ .filter(g => g.rows.length);
311
+ }
312
+
313
+ const when = ms => (ms ? new Date(ms).toISOString().replace("T", " ").slice(0, 19) : "unknown");
314
+
315
+ const duration = r => {
316
+ if (r.running) return "running";
317
+ if (r.durationMs == null) return "";
318
+ const s = Math.round(r.durationMs / 1000);
319
+ return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.round((s % 3600) / 60)}m`;
320
+ };
@@ -0,0 +1,102 @@
1
+ // Every run on disk, newest first. The history is already there: `~/.gallivant/runs/<entry slug>/<run id>/` holds
2
+ // job.json and job.log for each dispatch the daemon ever made, and nothing deletes them. This module is the index
3
+ // over that tree, plus the id validation every route goes through before it touches a path.
4
+ //
5
+ // Two shapes of tree, one reader. Gallivant nests runs under the entry that dispatched them, because one run per
6
+ // entry is its lock. Command-center keeps them flat in `<project>/.content-pipeline/runs/`, because a run there
7
+ // belongs to a project rather than to a row. The folder inside is identical either way (job.json, job.log, the same
8
+ // field names), which is what `lib/run.mjs` was adapted from, so `readJob` and the row shape are shared and only
9
+ // the walk differs.
10
+ import { readdirSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { idSlug, handleFor } from "../state.mjs";
13
+
14
+ /** Run ids are stamp-plus-slug, the same character class `run.mjs`'s `dirOf` enforces. Anything else is not an id. */
15
+ export const validRunId = id => typeof id === "string" && /^[A-Za-z0-9_-]+$/.test(id);
16
+
17
+ /**
18
+ * Resolve `<entry id>/<run id>` to a directory, or null.
19
+ *
20
+ * Two separate checks, because the two ids have different shapes. A run id is validated by pattern. An entry id
21
+ * contains slashes, so a pattern is the wrong tool: it is checked against the registry instead, and only the slug
22
+ * of an id that is actually registered is ever joined to a path. A query string can therefore name no directory
23
+ * that the registry does not already name, whatever it contains.
24
+ */
25
+ export function runDir(home, entries, entryId, runId) {
26
+ if (!validRunId(runId)) return null;
27
+ if (!entries.some(([id]) => id === entryId)) return null;
28
+ return join(home, "runs", idSlug(entryId), runId);
29
+ }
30
+
31
+ /**
32
+ * Resolve `<source label>/<run id>` to a directory in one of the configured flat trees, or null.
33
+ *
34
+ * The same two-part rule as `runDir`, for a tree that has no registry: the run id is validated by pattern, and the
35
+ * source is matched against the configured list rather than trusted, so only a directory the operator already named
36
+ * in config is ever joined to a path. A query string cannot reach a tree nobody configured.
37
+ */
38
+ export function sourceRunDir(sources = [], label, runId) {
39
+ if (!validRunId(runId)) return null;
40
+ const source = sources.find(s => s.label === label);
41
+ return source ? join(source.dir, runId) : null;
42
+ }
43
+
44
+ const readJob = dir => { try { return JSON.parse(readFileSync(join(dir, "job.json"), "utf8")); } catch { return null; } };
45
+ const ls = dir => { try { return readdirSync(dir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name); } catch { return []; } };
46
+
47
+ /** One run folder as a row. The same shape whichever tree it came from, so one table renders both. */
48
+ const rowOf = (job, runId, { entry, handle, harness, running }) => ({
49
+ entry, handle, run: job.id || runId, name: job.name || "", harness,
50
+ startedAt: job.startedAt ?? null, endedAt: job.endedAt ?? null,
51
+ // A killed or crashed run whose exit was never recorded is not a success; it is unknown until something settles
52
+ // it, and saying so beats printing "0".
53
+ exit: running ? null : job.exit ?? null, running,
54
+ // A run still going has no duration yet; one whose end was never recorded has none either, and a made-up number
55
+ // would read as fact.
56
+ durationMs: job.startedAt && job.endedAt ? job.endedAt - job.startedAt : null,
57
+ });
58
+
59
+ /** Newest first. Run ids start with an ISO stamp, so the id is the tiebreak when startedAt is missing. */
60
+ const newestFirst = (a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0) || b.run.localeCompare(a.run);
61
+
62
+ /**
63
+ * A flat tree of run folders, as command-center writes under `<project>/.content-pipeline/runs/`. Same folder
64
+ * contents, no entry to key them by, so the source's name stands in for one and the rows are read-only: there is no
65
+ * registry here to validate an id against, so nothing addresses these by id and they are listed, not opened.
66
+ */
67
+ export function listFlatRuns(dir, { source = "runs", isRunning = () => false, limit = 200 } = {}) {
68
+ const rows = [];
69
+ for (const runId of ls(dir)) {
70
+ const job = readJob(join(dir, runId));
71
+ if (!job) continue;
72
+ const running = job.exit === null && isRunning(job);
73
+ rows.push({ ...rowOf(job, runId, { entry: null, handle: job.name || runId, harness: source, running }), source });
74
+ }
75
+ return rows.sort(newestFirst).slice(0, limit);
76
+ }
77
+
78
+ /**
79
+ * Every run, newest first, with the entry it belongs to. The registry is the list of entries, so a run folder left
80
+ * behind by an entry that was removed is skipped rather than shown with no context: `runDir` would refuse to serve
81
+ * it anyway, and a row nothing can open is worse than no row.
82
+ *
83
+ * `isRunning` is the runner's liveness check, so a run whose daemon died is listed as ended rather than as live.
84
+ */
85
+ export function listRuns(home, entries, { isRunning = () => false, entry: only = null, limit = 200 } = {}) {
86
+ const rows = [];
87
+ for (const [id, e] of entries) {
88
+ if (only && id !== only) continue;
89
+ const root = join(home, "runs", idSlug(id));
90
+ for (const runId of ls(root)) {
91
+ const job = readJob(join(root, runId));
92
+ if (!job) continue;
93
+ // The harness is the entry's, not the run's: job.json records the binary it spawned, and the registry is what
94
+ // says which adapter that was. A grouping by harness needs the name, not the path.
95
+ rows.push(rowOf(job, runId, { entry: id, handle: handleOf(id, e), harness: e?.harness || "unknown", running: job.exit === null && isRunning(job) }));
96
+ }
97
+ }
98
+ return rows.sort(newestFirst).slice(0, limit);
99
+ }
100
+
101
+ // The canonical handle, from state.mjs. An entry the index has no registry row for is `id` on its own.
102
+ const handleOf = (id, e) => (e ? handleFor(id, e) : id);
@@ -0,0 +1,74 @@
1
+ // SSE and history share the bounded public projection reader. Only the
2
+ // coordinator reconciles providers; these routes read files and clean up on close.
3
+ import { readFileSync, watch } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { logWindow, LOG_LIMITS } from "../run_log.mjs";
6
+ export const TAIL = LOG_LIMITS.lines;
7
+ const jobOf = dir => { try { return JSON.parse(readFileSync(join(dir, "job.json"), "utf8")); } catch { return null; } };
8
+ export function sliceRun(dir, { end = null, count = TAIL, meter } = {}) {
9
+ const tail = end === null ? logWindow(dir, { meter }) : null;
10
+ return logWindow(dir, { before: end === null ? tail.from : end, count, meter });
11
+ }
12
+ export function streamRun(res, dir, { isRunning, req = res.req, pollMs = 1000, beatMs = 20_000, meter } = {}) {
13
+ res.setTimeout?.(0);
14
+ req?.setTimeout?.(0); req?.socket?.setTimeout?.(0);
15
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", "x-accel-buffering": "no" });
16
+ if (!jobOf(dir)) { res.write(`data: ${JSON.stringify("[no run]")}\n\n`); return res.end(); }
17
+ const initial = logWindow(dir, { meter });
18
+ const resume = Number(req?.headers?.["last-event-id"]);
19
+ const resuming = Number.isSafeInteger(resume) && resume >= 0;
20
+ let sent = resuming ? resume : Math.max(0, initial.from - 1);
21
+ let generation = initial.generation, blocked = false, closing = false, pending = [], buffered = 0;
22
+ let watcher, timer, beat;
23
+ const stop = () => {
24
+ closing = true; clearInterval(timer); clearInterval(beat); watcher?.close();
25
+ res.off?.("drain", drain); res.off?.("close", stop); res.off?.("error", stop);
26
+ pending = []; buffered = 0;
27
+ };
28
+ const write = frame => {
29
+ if (blocked) return false;
30
+ if (res.write(frame) === false) blocked = true;
31
+ return true;
32
+ };
33
+ const enqueue = frame => { pending.push(frame); buffered += Buffer.byteLength(frame); };
34
+ if (!resuming && sent > 0) enqueue(`event: skipped\ndata: ${sent}\n\n`);
35
+ if (initial.gap) enqueue(`event: gap\ndata: ${JSON.stringify(initial.gap)}\n\n`);
36
+ const drain = () => { if (closing) return; blocked = false; tick(); };
37
+ const flush = () => {
38
+ while (pending.length && !blocked) {
39
+ const frame = pending.shift(); buffered -= Buffer.byteLength(frame);
40
+ write(frame);
41
+ }
42
+ };
43
+ const tick = () => {
44
+ if (closing || blocked) return;
45
+ flush(); if (blocked) return;
46
+ const window = logWindow(dir, { after: sent, meter });
47
+ if (generation !== window.generation || (sent > window.total && !window.byteMode)) {
48
+ // Never renumber IDs after retention or rotate a reconnect to a new file.
49
+ write(`event: gap\ndata: ${JSON.stringify("log rotated; history unavailable after the recorded cursor")}\n\n`);
50
+ stop(); res.end(); return;
51
+ }
52
+ generation = window.generation;
53
+ if (window.gap && window.from > sent + 1) enqueue(`event: gap\ndata: ${JSON.stringify(window.gap)}\n\n`);
54
+ for (let i = 0; i < window.lines.length; i++) {
55
+ const frame = `id: ${window.ids[i]}\ndata: ${JSON.stringify(window.lines[i])}\n\n`;
56
+ if (buffered + Buffer.byteLength(frame) > LOG_LIMITS.bufferedBytes) break;
57
+ enqueue(frame); sent = window.ids[i];
58
+ }
59
+ flush();
60
+ if (blocked || pending.length || sent < window.total && window.lines.length) return;
61
+ const job = jobOf(dir);
62
+ if (!isRunning(job)) {
63
+ const message = job?.exit == null ? "[ended, waiting for the next tick to record the exit]" : `[exit ${job.exit}]`;
64
+ write(`data: ${JSON.stringify(message)}\n\n`);
65
+ if (!blocked) { stop(); res.end(); }
66
+ }
67
+ };
68
+ res.on("close", stop); res.on?.("error", stop); res.on?.("drain", drain);
69
+ timer = setInterval(tick, pollMs);
70
+ beat = setInterval(() => { if (!closing && !blocked && !pending.length) write(": keepalive\n\n"); }, beatMs);
71
+ try { watcher = watch(join(dir, "job.log"), { persistent: false }, tick); } catch { /* timer is the recovery path */ }
72
+ tick();
73
+ return { close: stop, bufferedBytes: () => buffered, blocked: () => blocked };
74
+ }