faberun 0.3.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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,299 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>Faberun</title>
8
+ <style>
9
+ :root {
10
+ --bg: #f4f6fa; --surface: #ffffff; --surface-2: #f0f2f5; --border: #e1e4ea;
11
+ --text: #1b1f27; --text-2: #5a6272; --text-3: #8a91a0;
12
+ --accent: #315cf4; --accent-soft: #e7edff;
13
+ --ok: #15803d; --ok-soft: #dcfce7; --warn: #b45309; --warn-soft: #fef3c7;
14
+ --bad: #b91c1c; --bad-soft: #fee2e2; --violet: #6d28d9; --violet-soft: #ede9fe;
15
+ --muted: #64748b; --muted-soft: #e7eaf0;
16
+ --shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 12px 28px rgba(16, 24, 40, 0.055);
17
+ --sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, system-ui, sans-serif;
18
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
19
+ }
20
+ @media (prefers-color-scheme: dark) {
21
+ :root {
22
+ --bg: #0b0f17; --surface: #121823; --surface-2: #18202d; --border: #253044;
23
+ --text: #eef2f8; --text-2: #aab4c5; --text-3: #748096;
24
+ --accent: #7aa2ff; --accent-soft: #24448466;
25
+ --ok: #4ade80; --ok-soft: #14532d66; --warn: #fbbf24; --warn-soft: #78350f66;
26
+ --bad: #f87171; --bad-soft: #7f1d1d66; --violet: #a78bfa; --violet-soft: #4c1d9566;
27
+ --muted: #94a3b8; --muted-soft: #33415566;
28
+ --shadow: 0 14px 36px rgba(0, 0, 0, 0.2);
29
+ }
30
+ }
31
+ * { box-sizing: border-box; }
32
+ [hidden] { display: none !important; }
33
+ body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 var(--sans); }
34
+ a { color: var(--accent); }
35
+ button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
36
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
37
+ .mono { font-family: var(--mono); font-size: 12.5px; }
38
+ .dim { color: var(--text-2); }
39
+ #top {
40
+ display: flex; align-items: center; gap: 14px; padding: 14px 20px;
41
+ background: var(--surface); border-bottom: 1px solid var(--border);
42
+ }
43
+ #top .brand { font-weight: 650; }
44
+ #top select { font: inherit; background: var(--surface-2); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 4px 8px; }
45
+ #top .spacer { flex: 1; }
46
+ #conn { font-size: 12px; color: var(--text-2); display: inline-flex; align-items: center; gap: 6px; }
47
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); display: inline-block; }
48
+ .dot.live { background: var(--ok); }
49
+ .dot.lost { background: var(--bad); }
50
+ #main { max-width: 1100px; margin: 0 auto; padding: 24px 20px 60px; }
51
+ section { margin-top: 22px; }
52
+ section > h2 { font-size: 12px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-3); margin: 0 0 10px; }
53
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); }
54
+ .pill { display: inline-flex; align-items: center; gap: 6px; padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
55
+ .pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
56
+ .pill.done { color: var(--ok); background: var(--ok-soft); }
57
+ .pill.active, .pill.running { color: var(--warn); background: var(--warn-soft); }
58
+ .pill.attention, .pill.blocked, .pill.failed, .pill.exhausted, .pill.stalled, .pill.canceled, .pill.orphaned { color: var(--bad); background: var(--bad-soft); }
59
+ .pill.idle, .pill.pending { color: var(--muted); background: var(--muted-soft); }
60
+ .pill.pass { color: var(--ok); background: var(--ok-soft); }
61
+ .pill.fail, .pill.invalid_judge_output { color: var(--bad); background: var(--bad-soft); }
62
+ #now { padding: 16px 18px; display: flex; flex-wrap: wrap; gap: 22px; align-items: baseline; }
63
+ #now .k { font-size: 11px; text-transform: uppercase; color: var(--text-3); }
64
+ #now .v { font-size: 15px; font-weight: 650; }
65
+ #needsyou { border: 1px solid color-mix(in srgb, var(--bad) 45%, transparent); background: var(--bad-soft); border-radius: 12px; padding: 4px 16px; }
66
+ #needsyou .item { padding: 8px 0; border-bottom: 1px solid color-mix(in srgb, var(--bad) 25%, transparent); font-size: 13px; }
67
+ #needsyou .item:last-child { border-bottom: none; }
68
+ #needsyou code { background: color-mix(in srgb, var(--text) 8%, transparent); border-radius: 6px; padding: 1px 6px; }
69
+ table { width: 100%; border-collapse: collapse; }
70
+ th { text-align: left; font-size: 11px; font-weight: 600; color: var(--text-3); padding: 8px 12px; border-bottom: 1px solid var(--border); text-transform: uppercase; }
71
+ td { padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
72
+ tr:last-child td { border-bottom: none; }
73
+ tr.clickable { cursor: pointer; }
74
+ tr.clickable:hover td { background: var(--surface-2); }
75
+ tr.selected td { background: var(--accent-soft); }
76
+ td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
77
+ td.goal { max-width: 42ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
78
+ #drawer { padding: 16px 18px; }
79
+ #drawer h3 { margin: 0 0 12px; font-size: 15px; }
80
+ .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 12px; }
81
+ .tabs button { padding: 7px 12px; font-size: 12.5px; border-radius: 8px 8px 0 0; color: var(--text-2); }
82
+ .tabs button.active { color: var(--accent); background: var(--accent-soft); font-weight: 600; }
83
+ pre { white-space: pre-wrap; word-break: break-word; background: var(--surface-2); border-radius: 8px; padding: 10px 12px; font-family: var(--mono); font-size: 12px; max-height: 420px; overflow: auto; }
84
+ .finding { padding: 8px 0; border-bottom: 1px solid var(--border); }
85
+ .finding:last-child { border-bottom: none; }
86
+ #handoff pre { max-height: 500px; }
87
+ .empty { color: var(--text-3); padding: 10px 0; }
88
+ </style>
89
+ </head>
90
+ <body>
91
+ <header id="top">
92
+ <span class="brand">Faberun</span>
93
+ <select id="campaignSelect" aria-label="Campaign"></select>
94
+ <span class="spacer"></span>
95
+ <span id="conn"><span class="dot"></span><span id="conntext">connecting…</span></span>
96
+ </header>
97
+ <main id="main"><p class="empty">loading .runs …</p></main>
98
+ <script>
99
+ "use strict";
100
+ const POLL_FALLBACK_MS = 3000;
101
+ const state = { snapshot: null, campaignId: null, runId: null, nodeId: null, tab: "log", source: null, pollTimer: null, transport: "none" };
102
+ const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
103
+ const fmtUsd = (value) => typeof value === "number" ? `$${value.toFixed(2)}` : "–";
104
+ const fmtAgo = (iso) => {
105
+ const t = Date.parse(iso ?? "");
106
+ if (!Number.isFinite(t)) return "–";
107
+ const s = Math.max(0, Math.round((Date.now() - t) / 1000));
108
+ if (s < 60) return `${s}s ago`;
109
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
110
+ if (s < 86400) return `${(s / 3600).toFixed(1)}h ago`;
111
+ return `${Math.round(s / 86400)}d ago`;
112
+ };
113
+ const fmtDur = (ms) => {
114
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return "–";
115
+ const m = Math.round(ms / 60000);
116
+ return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
117
+ };
118
+ const pill = (label) => `<span class="pill ${esc(String(label ?? "").toLowerCase())}">${esc(label ?? "-")}</span>`;
119
+
120
+ function nowHtml(now) {
121
+ if (!now || now.state === "idle") {
122
+ return `<section id="now"><h2>Now</h2><div class="card" id="now-strip">Idle${now?.runId ? ` · last run ${esc(now.runId)} finished ${fmtAgo(now.updatedAt)}` : ""}</div></section>`;
123
+ }
124
+ return `<section id="now"><h2>Now</h2><div class="card" id="now-strip">
125
+ <div><div class="k">Active run</div><div class="v mono">${esc(now.runId)}</div></div>
126
+ <div><div class="k">State</div><div class="v">${pill(now.state)}</div></div>
127
+ <div><div class="k">Running node</div><div class="v mono">${esc(now.nodeId ?? "–")} <span class="dim">${fmtDur(now.elapsedMs)}</span></div></div>
128
+ <div><div class="k">USD so far</div><div class="v">${fmtUsd(now.costUsd)}</div></div>
129
+ <div><div class="k">Updated</div><div class="v">${fmtAgo(now.updatedAt)}</div></div>
130
+ </div></section>`;
131
+ }
132
+
133
+ function needsYouHtml(items) {
134
+ if (!items?.length) return "";
135
+ const rows = items.map((item) => `<div class="item"><b>${esc(item.nodeId ?? item.runId)}</b> (${esc(item.status)}${item.errorCode ? ` · ${esc(item.errorCode)}` : ""}): <code>${esc(item.command)}</code></div>`).join("");
136
+ return `<section id="needsyou"><h2>Needs you</h2>${rows}</section>`;
137
+ }
138
+
139
+ function runsHtml(runs, selectedRunId) {
140
+ const rows = (runs ?? []).map((run) => {
141
+ if (run.corrupt) return `<tr><td colspan="7">${esc(run.id)}: <span class="pill failed">unreadable</span> ${esc(run.corrupt)}</td></tr>`;
142
+ return `<tr class="clickable ${run.id === selectedRunId ? "selected" : ""}" data-run="${esc(run.id)}">
143
+ <td>${pill(run.state)}</td>
144
+ <td class="mono">${esc(run.id)}</td>
145
+ <td class="goal" title="${esc(run.goal)}">${esc(run.goal)}</td>
146
+ <td class="num">${run.nodesDone}/${run.nodesTotal}</td>
147
+ <td class="num">${run.attempts}</td>
148
+ <td class="num">${fmtDur(run.elapsedMs)}</td>
149
+ <td class="num">${fmtUsd(run.costUsd)}</td>
150
+ <td class="num">${fmtAgo(run.updatedAt)}</td>
151
+ </tr>`;
152
+ }).join("");
153
+ return `<section id="runs"><h2>Runs</h2><div class="card"><table>
154
+ <thead><tr><th>state</th><th>run</th><th>goal</th><th>nodes</th><th>attempts</th><th>elapsed</th><th>USD</th><th>updated</th></tr></thead>
155
+ <tbody>${rows || '<tr><td colspan="8" class="empty">no runs linked yet</td></tr>'}</tbody>
156
+ </table></div></section>`;
157
+ }
158
+
159
+ const TABS = [["log", "Worker log"], ["verification", "Verification"], ["diff", "Diff"], ["findings", "Findings"], ["prompt", "Prompt"]];
160
+
161
+ function tabBodyHtml(tab, detail) {
162
+ if (!detail) return `<p class="empty">select a node above</p>`;
163
+ if (tab === "log") return detail.log?.lines?.length ? `<pre>${esc(detail.log.lines.join("\n"))}</pre>` : `<p class="empty">no log yet</p>`;
164
+ if (tab === "verification") {
165
+ const commands = detail.verification?.commands ?? [];
166
+ if (!commands.length) return `<p class="empty">no verification recorded</p>`;
167
+ return commands.map((command) => `<div class="finding"><b>${command.passed ? pill("pass") : pill("fail")}</b> <span class="mono">${esc(command.command)}</span>${command.durationMs ? ` <span class="dim">${fmtDur(command.durationMs)}</span>` : ""}<pre>${esc(command.outputTail || "(no output)")}</pre></div>`).join("");
168
+ }
169
+ if (tab === "diff") {
170
+ const diff = detail.diff;
171
+ if (!diff) return `<p class="empty">no scope recorded</p>`;
172
+ const files = diff.files.map((path) => `<div class="mono">${esc(path)}</div>`).join("") || `<p class="empty">no files changed</p>`;
173
+ const unexpected = diff.unexpected.length ? `<p class="dim">unexpected: ${diff.unexpected.map(esc).join(", ")}</p>` : "";
174
+ return `<p>${esc(diff.stat)}</p>${unexpected}${files}`;
175
+ }
176
+ if (tab === "findings") {
177
+ const findings = detail.findings;
178
+ if (!findings) return `<p class="empty">no gate ran</p>`;
179
+ const list = findings.findings.map((finding) => `<div class="finding">${pill(finding.severity)} ${esc(finding.description)}<div class="dim">${esc(finding.evidence)}</div></div>`).join("") || `<p class="empty">no findings</p>`;
180
+ return `<p>${pill(findings.verdict)} ${esc(findings.summary ?? "")}</p>${list}`;
181
+ }
182
+ return detail.prompt ? `<pre>${esc(detail.prompt)}</pre>` : `<p class="empty">no prompt recorded</p>`;
183
+ }
184
+
185
+ function drawerHtml(drawer, tab) {
186
+ if (!drawer) return `<section id="drawer"><h2>Run drawer</h2><div class="card" style="padding:16px 18px"><p class="empty">select a run above</p></div></section>`;
187
+ const rows = drawer.nodes.map((node) => `<tr class="clickable ${node.id === drawer.selectedNodeId ? "selected" : ""}" data-node="${esc(node.id)}">
188
+ <td>${pill(node.status)}</td>
189
+ <td class="mono">${esc(node.id)}</td>
190
+ <td class="num">${node.attempt}</td>
191
+ <td class="mono">${esc(node.model ?? "–")}</td>
192
+ <td class="num">${fmtDur(node.elapsedMs)}</td>
193
+ <td class="num">${fmtUsd(node.costUsd)}</td>
194
+ <td>${node.verdict ? pill(node.verdict) : ""}</td>
195
+ <td class="goal" title="${esc(node.note ?? "")}">${esc(node.note ?? "")}</td>
196
+ </tr>`).join("");
197
+ const tabs = TABS.map(([key, label]) => `<button data-tab="${key}" class="${tab === key ? "active" : ""}">${esc(label)}</button>`).join("");
198
+ return `<section id="drawer"><h2>Run drawer · ${esc(drawer.runId)}</h2><div class="card" style="padding:16px 18px">
199
+ <table><thead><tr><th>state</th><th>node</th><th>try</th><th>model</th><th>elapsed</th><th>USD</th><th>verdict</th><th>note</th></tr></thead>
200
+ <tbody>${rows}</tbody></table>
201
+ <div class="tabs">${tabs}</div>
202
+ <div id="tabbody">${tabBodyHtml(tab, drawer.detail)}</div>
203
+ </div></section>`;
204
+ }
205
+
206
+ function handoffHtml(handoff) {
207
+ return `<section id="handoff"><h2>Handoff</h2><div class="card" style="padding:16px 18px">${handoff ? `<pre>${esc(handoff)}</pre>` : '<p class="empty">no HANDOFF.md yet</p>'}</div></section>`;
208
+ }
209
+
210
+ function renderSectionsHtml(snapshot) {
211
+ if (!snapshot?.selectedCampaignId) return `<p class="empty">no campaign found under .runs; initialize one with: runner.mjs campaign init</p>`;
212
+ return nowHtml(snapshot.now) + needsYouHtml(snapshot.needsYou) + runsHtml(snapshot.runs, state.runId) + drawerHtml(snapshot.drawer, state.tab) + handoffHtml(snapshot.handoff);
213
+ }
214
+
215
+ function renderCampaignSelect(snapshot) {
216
+ const select = document.getElementById("campaignSelect");
217
+ select.innerHTML = (snapshot.campaigns ?? []).map((campaign) => `<option value="${esc(campaign.id)}" ${campaign.id === snapshot.selectedCampaignId ? "selected" : ""}>${esc(campaign.id)} (${esc(campaign.status)})</option>`).join("");
218
+ }
219
+
220
+ function applySnapshot(snapshot) {
221
+ state.snapshot = snapshot;
222
+ document.getElementById("main").innerHTML = renderSectionsHtml(snapshot);
223
+ renderCampaignSelect(snapshot);
224
+ setConnection(state.transport, snapshot.generatedAt);
225
+ }
226
+
227
+ function setConnection(transport, at) {
228
+ const dot = document.getElementById("conn").querySelector(".dot");
229
+ dot.className = `dot ${transport === "stream" || transport === "poll" ? "live" : "lost"}`;
230
+ document.getElementById("conntext").textContent = transport === "none" ? "connection lost — reconnecting" : `updated ${new Date(Date.parse(at ?? "")).toLocaleTimeString([], { hour12: false })}`;
231
+ }
232
+
233
+ function streamUrl() {
234
+ const params = new URLSearchParams();
235
+ if (state.campaignId) params.set("campaign", state.campaignId);
236
+ if (state.runId) params.set("run", state.runId);
237
+ if (state.nodeId) params.set("node", state.nodeId);
238
+ const query = params.toString();
239
+ return `/api/stream${query ? `?${query}` : ""}`;
240
+ }
241
+
242
+ function connect() {
243
+ if (state.source) { state.source.close(); state.source = null; }
244
+ if (typeof EventSource !== "function") { startPolling(); return; }
245
+ const source = new EventSource(streamUrl());
246
+ state.source = source;
247
+ source.addEventListener("update", (event) => {
248
+ state.transport = "stream";
249
+ stopPolling();
250
+ try { applySnapshot(JSON.parse(event.data)); } catch (error) { console.error(error); }
251
+ });
252
+ source.addEventListener("error", () => { state.transport = "none"; setConnection("none"); startPolling(); });
253
+ }
254
+ async function pollOnce() {
255
+ try {
256
+ const response = await fetch(`/api/snapshot${streamUrl().slice("/api/stream".length)}`, { signal: AbortSignal.timeout(6000) });
257
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
258
+ if (state.transport !== "stream") state.transport = "poll";
259
+ applySnapshot(await response.json());
260
+ } catch { state.transport = "none"; setConnection("none"); }
261
+ }
262
+ function startPolling() { if (state.pollTimer) return; pollOnce(); state.pollTimer = setInterval(() => { if (!document.hidden) pollOnce(); }, POLL_FALLBACK_MS); }
263
+ function stopPolling() { if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; } }
264
+
265
+ function selectCampaign(id) {
266
+ if (id === state.campaignId) return;
267
+ state.campaignId = id; state.runId = null; state.nodeId = null; state.tab = "log";
268
+ connect();
269
+ }
270
+ function selectRun(id) {
271
+ state.runId = id === state.runId ? null : id;
272
+ state.nodeId = null;
273
+ connect();
274
+ }
275
+ function selectNode(id) {
276
+ state.nodeId = id === state.nodeId ? null : id;
277
+ connect();
278
+ }
279
+ function selectTab(tab) {
280
+ state.tab = tab;
281
+ if (state.snapshot) document.getElementById("tabbody").innerHTML = tabBodyHtml(tab, state.snapshot.drawer?.detail);
282
+ document.querySelectorAll(".tabs button").forEach((button) => button.classList.toggle("active", button.dataset.tab === tab));
283
+ }
284
+
285
+ document.getElementById("main").addEventListener("click", (event) => {
286
+ const run = event.target.closest("[data-run]");
287
+ if (run) return selectRun(run.dataset.run);
288
+ const node = event.target.closest("[data-node]");
289
+ if (node) return selectNode(node.dataset.node);
290
+ const tab = event.target.closest("[data-tab]");
291
+ if (tab) return selectTab(tab.dataset.tab);
292
+ });
293
+ document.getElementById("campaignSelect").addEventListener("change", (event) => selectCampaign(event.target.value));
294
+ setInterval(() => { if (state.snapshot && !document.hidden) applySnapshot(state.snapshot); }, 30_000);
295
+ connect();
296
+ window.__dashboardTestHooks = { renderSectionsHtml, applySnapshot };
297
+ </script>
298
+ </body>
299
+ </html>