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,208 @@
1
+ /**
2
+ * The tmux half of the operator seat: one session (`faberun-seat`) with
3
+ * one window per open campaign.
4
+ *
5
+ * tmux is optional by design (ADR-0033): the run engine never binds to it, so
6
+ * every function here returns an explicit result and an absent binary becomes
7
+ * `{available: false}` instead of a throw. That is what keeps every campaign
8
+ * command working on a host without tmux, where only reattaching is lost.
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+ import { errorCode, exitStatus } from "../util.mjs";
12
+
13
+ /** The single seat session every campaign window lives in. */
14
+ export const SEAT_SESSION = "faberun-seat";
15
+
16
+ /** Window option recording which harness launched the window. */
17
+ const HARNESS_OPTION = "@faberun-harness";
18
+
19
+ /** `list-windows` format: name, index, harness option, pane command, tab-separated. */
20
+ const WINDOW_FORMAT = "#{window_name}\t#{window_index}\t#{@faberun-harness}\t#{pane_current_command}";
21
+
22
+ /** Measured 2026-09-12: every seat tmux call here returns in well under a second. */
23
+ const TMUX_TIMEOUT_MS = 10_000;
24
+
25
+ /**
26
+ * @typedef {{available: boolean, ok: boolean, status: number|null, stdout: string, stderr: string, reason: string|null}} TmuxResult
27
+ * @typedef {{window: string, index: number|null, harness: string|null, command: string|null}} SeatWindow
28
+ */
29
+
30
+ /**
31
+ * @param {string[]} args
32
+ * @param {{cwd?: string}} [options]
33
+ * @returns {TmuxResult}
34
+ */
35
+ function runTmux(args, options = {}) {
36
+ try {
37
+ const stdout = execFileSync("tmux", args, {
38
+ cwd: options.cwd,
39
+ encoding: "utf8",
40
+ stdio: ["ignore", "pipe", "pipe"],
41
+ timeout: TMUX_TIMEOUT_MS,
42
+ });
43
+ return { available: true, ok: true, status: 0, stdout: String(stdout), stderr: "", reason: null };
44
+ } catch (error) {
45
+ if (errorCode(error) === "ENOENT") {
46
+ return { available: false, ok: false, status: null, stdout: "", stderr: "", reason: "tmux_unavailable" };
47
+ }
48
+ const failure = spawnFailure(error);
49
+ return {
50
+ available: true,
51
+ ok: false,
52
+ status: exitStatus(error) ?? null,
53
+ stdout: failure.stdout,
54
+ stderr: failure.stderr,
55
+ reason: "tmux_command_failed",
56
+ };
57
+ }
58
+ }
59
+
60
+ /** @param {unknown} error @returns {{stdout: string, stderr: string}} */
61
+ function spawnFailure(error) {
62
+ const record = /** @type {{stdout?: string|Buffer, stderr?: string|Buffer}} */ (error);
63
+ return { stdout: textOf(record.stdout), stderr: textOf(record.stderr) };
64
+ }
65
+
66
+ /** @param {unknown} value @returns {string} */
67
+ function textOf(value) {
68
+ if (value === undefined || value === null) return "";
69
+ return Buffer.isBuffer(value) ? value.toString("utf8") : String(value);
70
+ }
71
+
72
+ /**
73
+ * @param {string} session
74
+ * @returns {{available: boolean, exists: boolean}}
75
+ */
76
+ function sessionExists(session) {
77
+ const result = runTmux(["has-session", "-t", session]);
78
+ if (!result.available) return { available: false, exists: false };
79
+ return { available: true, exists: result.ok };
80
+ }
81
+
82
+ /**
83
+ * Create the seat window for one campaign, creating the session on the first
84
+ * window. The harness argv is quoted into the single shell-command tmux takes;
85
+ * the harness name is recorded as a window option so status can name it even
86
+ * after the pane's foreground command changes.
87
+ *
88
+ * @param {{session: string, window: string, argv: readonly string[], harness: string, cwd: string}} options
89
+ * @returns {{available: boolean, ok: boolean, created: boolean, session: string, window: string, command: string|null, reason: string|null, stderr: string}}
90
+ */
91
+ export function createSeatWindow(options) {
92
+ const existing = sessionExists(options.session);
93
+ if (!existing.available) {
94
+ return { available: false, ok: false, created: false, session: options.session, window: options.window, command: null, reason: "tmux_unavailable", stderr: "" };
95
+ }
96
+ const command = shellCommand(options.argv);
97
+ const args = existing.exists
98
+ ? ["new-window", "-t", options.session, "-n", options.window, "-c", options.cwd, command]
99
+ : ["new-session", "-d", "-s", options.session, "-n", options.window, "-c", options.cwd, command];
100
+ const result = runTmux(args);
101
+ if (!result.ok) {
102
+ return { available: result.available, ok: false, created: false, session: options.session, window: options.window, command, reason: result.reason, stderr: result.stderr };
103
+ }
104
+ runTmux(["set-option", "-w", "-t", `${options.session}:${options.window}`, HARNESS_OPTION, options.harness]);
105
+ return { available: true, ok: true, created: true, session: options.session, window: options.window, command, reason: null, stderr: "" };
106
+ }
107
+
108
+ /** @param {readonly string[]} argv @returns {string} */
109
+ function shellCommand(argv) {
110
+ return argv
111
+ .map((argument) => (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(argument) ? argument : `'${argument.replace(/'/gu, "'\\''")}'`))
112
+ .join(" ");
113
+ }
114
+
115
+ /**
116
+ * Replace the process in an existing seat window with a new harness, keeping
117
+ * the window, its name and its index. `respawn-window -k` is the kill and the
118
+ * launch in one tmux verb; the caller materializes the brief before calling,
119
+ * so a failed brief never tears down a working pane.
120
+ *
121
+ * @param {{session: string, window: string, argv: readonly string[], harness: string, cwd: string}} options
122
+ * @returns {{available: boolean, ok: boolean, respawned: boolean, session: string, window: string, command: string|null, reason: string|null, stderr: string}}
123
+ */
124
+ export function respawnSeatWindow(options) {
125
+ const command = shellCommand(options.argv);
126
+ const result = runTmux(["respawn-window", "-k", "-t", `${options.session}:${options.window}`, "-c", options.cwd, command]);
127
+ if (!result.ok) {
128
+ return { available: result.available, ok: false, respawned: false, session: options.session, window: options.window, command, reason: result.reason, stderr: result.stderr };
129
+ }
130
+ runTmux(["set-option", "-w", "-t", `${options.session}:${options.window}`, HARNESS_OPTION, options.harness]);
131
+ return { available: true, ok: true, respawned: true, session: options.session, window: options.window, command, reason: null, stderr: "" };
132
+ }
133
+
134
+ /**
135
+ * @param {string} session
136
+ * @returns {{available: boolean, windows: SeatWindow[], reason: string|null, stderr: string}}
137
+ */
138
+ export function listSeatWindows(session) {
139
+ const result = runTmux(["list-windows", "-t", session, "-F", WINDOW_FORMAT]);
140
+ if (!result.available) return { available: false, windows: [], reason: "tmux_unavailable", stderr: "" };
141
+ if (!result.ok) {
142
+ return { available: true, windows: [], reason: result.status === 1 ? "no_session" : "tmux_command_failed", stderr: result.stderr };
143
+ }
144
+ const windows = result.stdout.split("\n").map(parseWindowLine).filter((window) => window !== null);
145
+ return { available: true, windows, reason: null, stderr: "" };
146
+ }
147
+
148
+ /**
149
+ * @param {string} line
150
+ * @returns {SeatWindow|null}
151
+ */
152
+ function parseWindowLine(line) {
153
+ if (!line.trim()) return null;
154
+ const [name, index, harness, command] = line.split("\t");
155
+ if (!name) return null;
156
+ const parsed = Number.parseInt(index ?? "", 10);
157
+ return {
158
+ window: name,
159
+ index: Number.isFinite(parsed) ? parsed : null,
160
+ harness: harness ? harness : null,
161
+ command: command ? command : null,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * @returns {{available: boolean, version: string|null, reason: string|null}}
167
+ */
168
+ export function tmuxAvailability() {
169
+ const result = runTmux(["-V"]);
170
+ return {
171
+ available: result.available,
172
+ version: result.ok && result.stdout.trim() ? result.stdout.trim() : null,
173
+ reason: result.reason,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * @param {string} session
179
+ * @param {string} window
180
+ * @returns {{available: boolean, ok: boolean, reason: string|null, stderr: string}}
181
+ */
182
+ export function stopSeatWindow(session, window) {
183
+ return stopTmux(["kill-window", "-t", `${session}:${window}`], "window");
184
+ }
185
+
186
+ /**
187
+ * @param {string} session
188
+ * @returns {{available: boolean, ok: boolean, reason: string|null, stderr: string}}
189
+ */
190
+ export function stopSeatSession(session) {
191
+ return stopTmux(["kill-session", "-t", session], "session");
192
+ }
193
+
194
+ /**
195
+ * tmux exits 1 when the target is already gone. That is idempotent success for
196
+ * a stop, not a failure: stopping the last window takes the session with it.
197
+ *
198
+ * @param {string[]} args
199
+ * @param {"window"|"session"} label
200
+ * @returns {{available: boolean, ok: boolean, reason: string|null, stderr: string}}
201
+ */
202
+ function stopTmux(args, label) {
203
+ const result = runTmux(args);
204
+ if (result.available && !result.ok && result.status === 1) {
205
+ return { available: true, ok: true, reason: `no_${label}`, stderr: result.stderr };
206
+ }
207
+ return { available: result.available, ok: result.ok, reason: result.reason, stderr: result.stderr };
208
+ }
package/src/util.mjs ADDED
Binary file
@@ -0,0 +1,371 @@
1
+ /**
2
+ * The operator's remote API: the phone-shaped surface behind the dashboard
3
+ * server. Reads go straight to the campaign/seat aggregators that already
4
+ * exist; every write shells out to the runner CLI and touches no state file of
5
+ * its own (ADR-0034: a daemon that wrote state would be a second state machine
6
+ * with its own rules, and the CLI is the only writer). That is also why there
7
+ * is no replan, contract, routing or gate route: the contract is frozen with a
8
+ * digest, and the sanctioned middle ground from a phone is `campaign note`.
9
+ * Pause/resume ride the run-level `cancel`/`resume` verbs — the reversible
10
+ * pair this repo already owns (a canceled run is a resumable state) — fired
11
+ * once per linked run that still has work in flight.
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ import { closeSync, existsSync, openSync, readFileSync, readSync, statSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import process from "node:process";
18
+ import { discoverCampaigns } from "../campaign/index.mjs";
19
+ import { BRIEF_FILE, JOURNAL_FILE } from "../campaign/layout.mjs";
20
+ import { seatStatus } from "../seat/index.mjs";
21
+ import { errorMessage, errorCode, fail, readJsonTolerant, truncateChars } from "../util.mjs";
22
+
23
+ const DEFAULT_CLI_ENTRY = fileURLToPath(new URL("../cli.mjs", import.meta.url));
24
+ /** One events page is bounded twice: by the bytes read from the journal and by the entry count returned. */
25
+ const EVENTS_WINDOW_BYTES = 32 * 1024;
26
+ const EVENTS_WINDOW_ENTRIES = 100;
27
+ const REQUEST_BODY_MAX_BYTES = 16 * 1024;
28
+ const OUTPUT_TAIL_CHARS = 4 * 1024;
29
+ const LIST_GOAL_CHARS = 200;
30
+ const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
31
+ const RUN_DONE_STATUSES = new Set(["done", "no-op"]);
32
+ const RUN_TERMINAL_STATUSES = new Set(["done", "no-op", "failed", "blocked", "exhausted", "stalled", "canceled", "cancelled"]);
33
+ const RUN_ATTENTION_STATUSES = new Set(["blocked", "failed", "exhausted", "stalled", "canceled", "cancelled"]);
34
+ /** The journal's `sessionId` records who spoke; the web daemon is a speaker of its own. */
35
+ const WEB_SESSION_ID = "web";
36
+
37
+ /** @typedef {import("node:http").IncomingMessage} IncomingMessage */
38
+ /** @typedef {import("node:http").ServerResponse} ServerResponse */
39
+ /** @typedef {{runsDir: string, repoRoot: string, cliEntry: string, url: URL, request: IncomingMessage}} ApiContext */
40
+ /** @typedef {{type: string, eventId: string, at: string, [key: string]: unknown}} JournalRecord */
41
+ /** @typedef {(context: ApiContext, response: ServerResponse, params: string[]) => Promise<void>} ApiHandler */
42
+
43
+ /** @type {[RegExp, ApiHandler][]} */
44
+ const GET_ROUTES = [
45
+ [/^\/api\/campaigns$/u, listCampaigns],
46
+ [/^\/api\/campaigns\/([^/]+)$/u, showCampaign],
47
+ [/^\/api\/campaigns\/([^/]+)\/events$/u, campaignEvents],
48
+ [/^\/api\/campaigns\/([^/]+)\/brief$/u, campaignBrief],
49
+ [/^\/api\/seats$/u, listSeats],
50
+ ];
51
+
52
+ /** @type {[RegExp, ApiHandler][]} */
53
+ const POST_ROUTES = [
54
+ [/^\/api\/campaigns\/([^/]+)\/note$/u, postNote],
55
+ [/^\/api\/campaigns\/([^/]+)\/decisions\/([^/]+)$/u, postDecision],
56
+ [/^\/api\/campaigns\/([^/]+)\/pause$/u, postPause],
57
+ [/^\/api\/campaigns\/([^/]+)\/resume$/u, postResume],
58
+ [/^\/api\/seats\/([^/]+)\/switch$/u, postSeatSwitch],
59
+ ];
60
+
61
+ /**
62
+ * Route one `/api/*` request. Unknown paths answer 404 — deliberately,
63
+ * including the replan-shaped ones — and a path matched with the wrong method
64
+ * answers 405. Never throws: the failure envelope is JSON like the payloads.
65
+ *
66
+ * @param {IncomingMessage} request
67
+ * @param {ServerResponse} response
68
+ * @param {{runsDir: string, cliEntry?: string}} options
69
+ */
70
+ export async function handleApiRequest(request, response, options) {
71
+ const url = new URL(request.url ?? "/", "http://localhost");
72
+ /** @type {ApiContext} */
73
+ const context = {
74
+ runsDir: options.runsDir,
75
+ repoRoot: dirname(options.runsDir),
76
+ cliEntry: options.cliEntry ?? DEFAULT_CLI_ENTRY,
77
+ url,
78
+ request,
79
+ };
80
+ try {
81
+ const table = request.method === "GET" ? GET_ROUTES : request.method === "POST" ? POST_ROUTES : [];
82
+ for (const [pattern, handler] of table) {
83
+ const params = pattern.exec(url.pathname)?.slice(1);
84
+ if (params) return await handler(context, response, params.map(safeSegment));
85
+ }
86
+ if ([...GET_ROUTES, ...POST_ROUTES].some(([pattern]) => pattern.test(url.pathname))) {
87
+ throw fail("method_not_allowed", `use ${request.method === "GET" ? "POST" : "GET"} for ${url.pathname}`);
88
+ }
89
+ throw notFound("no such path");
90
+ } catch (error) {
91
+ const code = errorCode(error);
92
+ const status = code === "bad_request" ? 400 : code === "not_found" ? 404 : code === "method_not_allowed" ? 405 : 500;
93
+ sendJson(response, status, { error: errorMessage(error) });
94
+ }
95
+ }
96
+
97
+ /** @param {string} message @returns {Error & {code: string}} */
98
+ function notFound(message) {
99
+ return fail("not_found", message);
100
+ }
101
+
102
+ /** @param {string} message @returns {Error & {code: string}} */
103
+ function badRequest(message) {
104
+ return fail("bad_request", message);
105
+ }
106
+
107
+ /** @param {ServerResponse} response @param {number} status @param {unknown} payload */
108
+ function sendJson(response, status, payload) {
109
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
110
+ response.end(JSON.stringify(payload));
111
+ }
112
+
113
+ /** @param {string} value @returns {string} */
114
+ function safeSegment(value) {
115
+ if (!SAFE_SEGMENT.test(value)) throw notFound("no such path");
116
+ return value;
117
+ }
118
+
119
+ /** @param {ApiContext} context @param {string} id @returns {{path: string, campaign: import("../campaign/index.mjs").Campaign}} */
120
+ function findCampaign(context, id) {
121
+ const found = discoverCampaigns(context.runsDir).campaigns.find(({ campaign }) => campaign.id === id);
122
+ if (!found) throw notFound(`unknown campaign: ${id}`);
123
+ return found;
124
+ }
125
+
126
+ /** The phone-sized run row: three states and two counters, nothing the dashboard snapshot does not already compute better. @param {string} runsDir @param {string} runId @returns {Record<string, unknown>} */
127
+ function runRow(runsDir, runId) {
128
+ const status = readJsonTolerant(join(runsDir, runId, "status.json"));
129
+ const nodes = status && typeof status === "object" && Array.isArray(/** @type {any} */ (status).nodes) ? /** @type {any[]} */ (/** @type {any} */ (status).nodes) : [];
130
+ return {
131
+ id: runId,
132
+ state: nodes.some((node) => RUN_ATTENTION_STATUSES.has(String(node.status)))
133
+ ? "attention"
134
+ : nodes.length > 0 && nodes.every((node) => RUN_TERMINAL_STATUSES.has(String(node.status))) ? "done" : "active",
135
+ nodesDone: nodes.filter((node) => RUN_DONE_STATUSES.has(String(node.status))).length,
136
+ nodesTotal: nodes.length,
137
+ costUsd: typeof /** @type {any} */ (status)?.usage?.costUsd === "number" ? /** @type {any} */ (status).usage.costUsd : null,
138
+ };
139
+ }
140
+
141
+ /** Linked runs that still have a node in flight — the only ones pause or resume can change. @param {ApiContext} context @param {import("../campaign/index.mjs").Campaign} campaign @returns {string[]} */
142
+ function runsInFlight(context, campaign) {
143
+ return campaign.linkedRunIds.filter((runId) => {
144
+ const status = readJsonTolerant(join(context.runsDir, runId, "status.json"));
145
+ const nodes = status && typeof status === "object" && Array.isArray(/** @type {any} */ (status).nodes) ? /** @type {any[]} */ (/** @type {any} */ (status).nodes) : [];
146
+ return nodes.some((node) => !RUN_TERMINAL_STATUSES.has(String(node.status)));
147
+ });
148
+ }
149
+
150
+ /** @param {ApiContext} context @param {ServerResponse} response */
151
+ async function listCampaigns(context, response) {
152
+ const { campaigns, corrupt } = discoverCampaigns(context.runsDir);
153
+ sendJson(response, 200, {
154
+ schemaVersion: 1,
155
+ campaigns: campaigns.map(({ campaign }) => ({
156
+ id: campaign.id,
157
+ goal: truncateChars(String(campaign.goal ?? "").replace(/\s+/gu, " ").trim(), LIST_GOAL_CHARS),
158
+ status: campaign.status,
159
+ updatedAt: campaign.updatedAt,
160
+ runCount: campaign.linkedRunIds.length,
161
+ })),
162
+ corrupt: corrupt.map((entry) => entry.id),
163
+ });
164
+ }
165
+
166
+ /** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
167
+ async function showCampaign(context, response, params) {
168
+ const { campaign } = findCampaign(context, params[0]);
169
+ sendJson(response, 200, { campaign, runs: campaign.linkedRunIds.map((runId) => runRow(context.runsDir, runId)) });
170
+ }
171
+
172
+ /** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
173
+ async function campaignEvents(context, response, params) {
174
+ const { campaign, path } = findCampaign(context, params[0]);
175
+ const afterRaw = context.url.searchParams.get("after") ?? "0";
176
+ if (!/^\d+$/u.test(afterRaw)) throw badRequest("after must be a non-negative integer cursor");
177
+ const page = readJournalWindow(join(path, JOURNAL_FILE), Number(afterRaw));
178
+ sendJson(response, 200, {
179
+ schemaVersion: 1,
180
+ campaignId: campaign.id,
181
+ after: Number(afterRaw),
182
+ next: page.next,
183
+ size: page.size,
184
+ complete: page.next >= page.size,
185
+ entries: page.entries,
186
+ });
187
+ }
188
+
189
+ /** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
190
+ async function campaignBrief(context, response, params) {
191
+ const briefPath = join(findCampaign(context, params[0]).path, BRIEF_FILE);
192
+ if (!existsSync(briefPath)) throw notFound(`operator brief not generated yet: ${BRIEF_FILE}`);
193
+ response.writeHead(200, { "content-type": "text/markdown; charset=utf-8", "cache-control": "no-store" });
194
+ response.end(readFileSync(briefPath));
195
+ }
196
+
197
+ /** @param {ApiContext} context @param {ServerResponse} response */
198
+ async function listSeats(context, response) {
199
+ sendJson(response, 200, seatStatus());
200
+ }
201
+
202
+ /** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
203
+ async function postNote(context, response, params) {
204
+ // The campaign is resolved before the CLI runs, like every other route: an
205
+ // unknown id used to reach `campaign note`, whose own refusal names the
206
+ // absolute path it looked in and handed the caller the server's directory
207
+ // layout.
208
+ findCampaign(context, params[0]);
209
+ const body = await readJsonObject(context.request);
210
+ const argv = ["campaign", "note", params[0], "--session-id", sessionOf(body), "--kind", requiredString(body.kind, "kind"), "--text", requiredString(body.text, "text")];
211
+ for (const [flag, key] of [["--decision-id", "decisionId"], ["--supersedes", "supersedes"], ["--question-id", "questionId"], ["--run-id", "runId"]]) {
212
+ if (typeof body[key] === "string" && /** @type {string} */ (body[key]).trim()) argv.push(flag, /** @type {string} */ (body[key]));
213
+ }
214
+ sendResult(response, await runCli(context, argv));
215
+ }
216
+
217
+ /** A pending operator decision is the journal's open question; resolving it is `campaign resolve`. @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
218
+ async function postDecision(context, response, params) {
219
+ findCampaign(context, params[0]);
220
+ const body = await readJsonObject(context.request);
221
+ const argv = ["campaign", "resolve", params[0], "--session-id", sessionOf(body), "--question-id", params[1], "--text", requiredString(body.text, "text")];
222
+ sendResult(response, await runCli(context, argv));
223
+ }
224
+
225
+ /** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
226
+ async function postPause(context, response, params) {
227
+ const targets = runsInFlight(context, findCampaign(context, params[0]).campaign);
228
+ const results = [];
229
+ for (const runId of targets) results.push({ runId, ...await runCli(context, ["cancel", join(context.runsDir, runId)]) });
230
+ sendResult(response, { ok: results.every((result) => result.exitCode === 0), results });
231
+ }
232
+
233
+ /** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
234
+ async function postResume(context, response, params) {
235
+ const targets = runsInFlight(context, findCampaign(context, params[0]).campaign);
236
+ const results = [];
237
+ for (const runId of targets) results.push({ runId, ...await runCli(context, ["resume", join(context.runsDir, runId), "--detach"]) });
238
+ sendResult(response, { ok: results.every((result) => result.exitCode === 0), results });
239
+ }
240
+
241
+ /** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
242
+ async function postSeatSwitch(context, response, params) {
243
+ const body = await readJsonObject(context.request);
244
+ const argv = ["seat", "switch", params[0], "--harness", requiredString(body.harness, "harness")];
245
+ sendResult(response, await runCli(context, argv));
246
+ }
247
+
248
+ /** @param {ServerResponse} response @param {{ok: boolean, [key: string]: unknown}} envelope */
249
+ function sendResult(response, envelope) {
250
+ sendJson(response, envelope.ok ? 200 : 500, { schemaVersion: 1, ...envelope });
251
+ }
252
+
253
+ /** @param {Record<string, unknown>} body @returns {string} */
254
+ function sessionOf(body) {
255
+ return typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId.trim() : WEB_SESSION_ID;
256
+ }
257
+
258
+ /** @param {unknown} value @param {string} label @returns {string} */
259
+ function requiredString(value, label) {
260
+ if (typeof value !== "string" || !value.trim()) throw badRequest(`${label} is required`);
261
+ return value;
262
+ }
263
+
264
+ /** @param {IncomingMessage} request @returns {Promise<Record<string, unknown>>} */
265
+ async function readJsonObject(request) {
266
+ const chunks = [];
267
+ let total = 0;
268
+ for await (const chunk of request) {
269
+ total += chunk.length;
270
+ if (total > REQUEST_BODY_MAX_BYTES) throw badRequest(`request body exceeds ${REQUEST_BODY_MAX_BYTES} bytes`);
271
+ chunks.push(Buffer.from(chunk));
272
+ }
273
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
274
+ if (!raw) return {};
275
+ let parsed;
276
+ try {
277
+ parsed = JSON.parse(raw);
278
+ } catch {
279
+ throw badRequest("request body is not valid JSON");
280
+ }
281
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw badRequest("request body must be a JSON object");
282
+ return /** @type {Record<string, unknown>} */ (parsed);
283
+ }
284
+
285
+ /**
286
+ * Fire one CLI verb and collect its verdict. The argv is an array handed to
287
+ * spawn, never a shell string, so note text can never become an option or a
288
+ * command.
289
+ *
290
+ * @param {ApiContext} context
291
+ * @param {string[]} argv
292
+ * @returns {Promise<{ok: boolean, command: string, exitCode: number, stdout: string, stderr: string}>}
293
+ */
294
+ async function runCli(context, argv) {
295
+ const child = spawn(process.execPath, [context.cliEntry, ...argv], { cwd: context.repoRoot, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
296
+ let stdout = "";
297
+ let stderr = "";
298
+ if (child.stdout) child.stdout.on("data", (chunk) => { stdout += String(chunk); });
299
+ if (child.stderr) child.stderr.on("data", (chunk) => { stderr += String(chunk); });
300
+ /** @type {Promise<number>} */
301
+ const closed = new Promise((resolve) => child.on("close", (code) => resolve(code ?? -1)));
302
+ const exitCode = await closed;
303
+ return {
304
+ ok: exitCode === 0,
305
+ command: argv.join(" "),
306
+ exitCode,
307
+ stdout: truncateChars(stdout.trim(), OUTPUT_TAIL_CHARS),
308
+ stderr: truncateChars(stderr.trim(), OUTPUT_TAIL_CHARS),
309
+ };
310
+ }
311
+
312
+ /**
313
+ * One bounded page of the campaign journal, read forward from the `after` byte
314
+ * cursor. A client that starts at zero gets the first window, never the whole
315
+ * file, however long the campaign has run; `next` is the cursor to send back.
316
+ * Only a line the window has seen ended by its own newline is an entry — a
317
+ * trailing fragment torn by a concurrent append is left for the next page, and
318
+ * legacy `liveness` heartbeats are skipped as narrative-free. A cursor beyond
319
+ * the file is a client bug and answers itself as a bad request.
320
+ *
321
+ * @param {string} path
322
+ * @param {number} after
323
+ * @returns {{entries: JournalRecord[], next: number, size: number}}
324
+ */
325
+ function readJournalWindow(path, after) {
326
+ let size = 0;
327
+ try {
328
+ size = statSync(path).size;
329
+ } catch {
330
+ return { entries: [], next: 0, size: 0 };
331
+ }
332
+ if (after > size) throw badRequest(`cursor ${after} is beyond the journal size ${size}`);
333
+ const bytes = windowBytes(path, Math.min(size - after, EVENTS_WINDOW_BYTES), after);
334
+ /** @type {JournalRecord[]} */
335
+ const entries = [];
336
+ let start = 0;
337
+ let consumed = 0;
338
+ while (entries.length < EVENTS_WINDOW_ENTRIES) {
339
+ const newline = bytes.indexOf(0x0a, start);
340
+ if (newline === -1) break;
341
+ consumed = newline + 1;
342
+ const line = bytes.toString("utf8", start, newline).replace(/\r$/u, "");
343
+ if (line.trim()) {
344
+ try {
345
+ const entry = /** @type {JournalRecord} */ (JSON.parse(line));
346
+ if (entry.type !== "liveness") entries.push(entry);
347
+ } catch {
348
+ // dropped: a line torn by a concurrent append; the cursor advances past it
349
+ }
350
+ }
351
+ start = newline + 1;
352
+ }
353
+ return { entries, next: after + consumed, size };
354
+ }
355
+
356
+ /** Positioned read that loops until the window is full, because one readSync may return short. @param {string} path @param {number} length @param {number} position @returns {Buffer} */
357
+ function windowBytes(path, length, position) {
358
+ const descriptor = openSync(path, "r");
359
+ try {
360
+ const buffer = Buffer.alloc(length);
361
+ let filled = 0;
362
+ while (filled < length) {
363
+ const readNow = readSync(descriptor, buffer, filled, length - filled, position + filled);
364
+ if (readNow === 0) break;
365
+ filled += readNow;
366
+ }
367
+ return buffer.subarray(0, filled);
368
+ } finally {
369
+ closeSync(descriptor);
370
+ }
371
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The dashboard's network boundary: the decisions that must be settled before
3
+ * a socket exists. `server.mjs` serves routes; this module decides whether it
4
+ * may listen at all — which address the bind host resolves to, whether that
5
+ * address is private (loopback, RFC1918, or the 100.64.0.0/10 CGNAT range
6
+ * Tailscale assigns), and the bearer token file that is the second lock behind
7
+ * the private network. The address ranges decide, never the Tailscale binary:
8
+ * a literal classifies identically with or without the daemon, so nothing here
9
+ * shells out or resolves anything beyond the local resolver.
10
+ */
11
+ import { lookup } from "node:dns/promises";
12
+ import { readFileSync } from "node:fs";
13
+ import net from "node:net";
14
+ import { errorMessage, fail } from "../util.mjs";
15
+
16
+ /** The admissible bind space: loopback, RFC1918 and CGNAT — the range Tailscale hands out. Everything else, wildcard or public, is refused. */
17
+ const PRIVATE_BIND = new net.BlockList();
18
+ PRIVATE_BIND.addSubnet("127.0.0.0", 8, "ipv4");
19
+ PRIVATE_BIND.addSubnet("10.0.0.0", 8, "ipv4");
20
+ PRIVATE_BIND.addSubnet("172.16.0.0", 12, "ipv4");
21
+ PRIVATE_BIND.addSubnet("192.168.0.0", 16, "ipv4");
22
+ PRIVATE_BIND.addSubnet("100.64.0.0", 10, "ipv4");
23
+ PRIVATE_BIND.addSubnet("::1", 128, "ipv6");
24
+
25
+ const BIND_HELP = "bind loopback, an RFC1918 address, or a 100.64.0.0/10 CGNAT address (the Tailscale range)";
26
+
27
+ /**
28
+ * The address a bind host stands for: IP literals pass through untouched, and
29
+ * a name resolves through the host resolver (which reads /etc/hosts) with its
30
+ * first result standing in for the set.
31
+ *
32
+ * @param {string} host
33
+ * @returns {Promise<string>}
34
+ */
35
+ export async function resolveBindAddress(host) {
36
+ if (net.isIP(host) !== 0) return host;
37
+ try {
38
+ return (await lookup(host)).address;
39
+ } catch (error) {
40
+ throw fail("bind_unresolvable", `cannot resolve bind host ${host}: ${errorMessage(error)}`);
41
+ }
42
+ }
43
+
44
+ /** An IPv6 form like `::ffff:203.0.113.10` classifies by the IPv4 address it carries. @param {string} address @returns {string} */
45
+ function unwrapIPv4Mapped(address) {
46
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/iu.exec(address);
47
+ return mapped ? mapped[1] : address;
48
+ }
49
+
50
+ /**
51
+ * Passes when the address is private and returns the canonical address to
52
+ * bind; throws with the reason when it is a wildcard or public. Callers run
53
+ * this before `listen`: binding and closing again still exposed the port for
54
+ * the time it was open.
55
+ *
56
+ * @param {string} address
57
+ * @returns {string}
58
+ */
59
+ export function assertPrivateBind(address) {
60
+ if (address === "0.0.0.0") throw fail("public_bind", `refusing to bind 0.0.0.0: the IPv4 wildcard accepts connections on every interface, the public ones included; ${BIND_HELP}`);
61
+ if (address === "::") throw fail("public_bind", `refusing to bind '::': the IPv6 wildcard accepts connections on every interface, the public ones included; ${BIND_HELP}`);
62
+ const plain = unwrapIPv4Mapped(address);
63
+ // The family must be stated: without it, BlockList.check misses IPv6 rules (measured on Node 26.8.1).
64
+ const family = net.isIPv4(plain) ? "ipv4" : "ipv6";
65
+ if (PRIVATE_BIND.check(plain, family)) return plain;
66
+ throw fail("public_bind", `refusing to bind ${address}: not a private address; ${BIND_HELP}`);
67
+ }
68
+
69
+ /**
70
+ * The bearer token from a local file — the second lock behind the private
71
+ * network; there is no login system. One trimmed, space-free line, and the
72
+ * value never appears in any message this module produces.
73
+ *
74
+ * @param {string} path
75
+ * @returns {string}
76
+ */
77
+ export function loadBearerToken(path) {
78
+ let raw;
79
+ try {
80
+ raw = readFileSync(path, "utf8");
81
+ } catch (error) {
82
+ throw fail("token_unreadable", `bearer token file is missing or unreadable: ${path} (${errorMessage(error)})`);
83
+ }
84
+ const token = raw.trim();
85
+ if (!token) throw fail("token_empty", `bearer token file is empty: ${path}`);
86
+ if (/\s/u.test(token)) throw fail("token_invalid", `bearer token file must hold a single space-free line: ${path}`);
87
+ return token;
88
+ }