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,192 @@
1
+ /**
2
+ * Worker and judge failover: one declared hop per runtime.
3
+ *
4
+ * Each runtime may declare `fallback`, naming at most one other runtime. A
5
+ * role that exhausts its current runtime gets exactly that one hop — never a
6
+ * chain, never a synthesized ordering over every other runtime in the
7
+ * contract. Reachability is therefore always one enumeration away: the
8
+ * runtime a role started on, and (if declared) the runtime its `fallback`
9
+ * names. contract.mjs validates the field is never a self-loop; because a
10
+ * hop is bounded at one, a multi-runtime cycle is structurally impossible.
11
+ */
12
+ import { harnessCapabilities } from "../harnesses/index.mjs";
13
+ import { nextSameTierRuntime } from "./runtime-discovery.mjs";
14
+ import { routeRuntime } from "../contract/runtime.mjs";
15
+
16
+ /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
17
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
18
+
19
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
20
+ /** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
21
+ /** @typedef {{id: string, type: string, runtime?: string, gate: {runtime?: string}}} EdgeNode */
22
+
23
+ /**
24
+ * Every runtime id in cost order: declared costRank ascending, unranked last,
25
+ * declaration order breaking ties so reporting stays deterministic.
26
+ *
27
+ * "Unranked last" is a separate sort key rather than a sentinel rank, because
28
+ * costRank only has to be a finite non-negative number — a contract may
29
+ * legitimately declare Number.MAX_SAFE_INTEGER or more, and any sentinel drawn
30
+ * from the same number line would tie with it or lose to it. Ranked runtimes
31
+ * therefore sort ahead of unranked ones by construction, whatever they cost.
32
+ *
33
+ * @param {ValidatedContract} contract
34
+ * @returns {string[]}
35
+ */
36
+ function rankedRuntimeIds(contract) {
37
+ return Object.entries(contract.runtimes)
38
+ .map(([id, runtime], order) => ({ id, order, rank: runtime.costRank, unranked: runtime.costRank === undefined ? 1 : 0 }))
39
+ .sort((left, right) => left.unranked - right.unranked
40
+ || (left.unranked ? 0 : /** @type {number} */ (left.rank) - /** @type {number} */ (right.rank))
41
+ || left.order - right.order)
42
+ .map((entry) => entry.id);
43
+ }
44
+
45
+ /**
46
+ * The one-hop chain out of a runtime: its declared `fallback`, or nothing.
47
+ *
48
+ * @param {ValidatedContract} contract
49
+ * @param {"worker"|"judge"} role unused; kept for signature stability across callers
50
+ * @param {string} currentRuntime
51
+ * @returns {string[]}
52
+ */
53
+ export function synthesizedChain(contract, role, currentRuntime) {
54
+ const fallback = contract.runtimes[currentRuntime]?.fallback;
55
+ return fallback ? [fallback] : [];
56
+ }
57
+
58
+ /**
59
+ * The next hop out of `currentRuntime`, or null when its one declared
60
+ * fallback has already been attempted this revision.
61
+ *
62
+ * @param {ValidatedContract} contract
63
+ * @param {"worker"|"judge"} role
64
+ * @param {string} currentRuntime
65
+ * @param {Iterable<string|null>} [attempted]
66
+ * @returns {string|null}
67
+ */
68
+ export function nextSynthesizedRuntime(contract, role, currentRuntime, attempted = []) {
69
+ const spent = new Set(attempted);
70
+ return synthesizedChain(contract, role, currentRuntime).find((id) => !spent.has(id)) ?? null;
71
+ }
72
+
73
+ /**
74
+ * The hop number to record for this exhaustion, in the role's current revision.
75
+ *
76
+ * Hop is the failover budget: bounded at one, so a run cannot walk its
77
+ * runtimes forever. Only an actual edge spends from it. A quota-reset retry
78
+ * stays on the runtime the node already warmed, so it costs no runtime and
79
+ * must cost no hop either — charging it would let one wait consume the
80
+ * budget the later real edge needs.
81
+ *
82
+ * @param {{routing?: {currentOverride?: {role?: string, revision?: number, hop?: number}|null, history?: {role?: string, revision?: number, hop?: number}[]}|null}} state
83
+ * @param {"worker"|"judge"} role
84
+ * @param {number} revision
85
+ * @param {{kind: "reset"|"failover"}} schedule
86
+ * @returns {number}
87
+ */
88
+ export function nextHop(state, role, revision, schedule) {
89
+ const override = state.routing?.currentOverride;
90
+ const previousHop = override?.role === role && (override.revision ?? revision) === revision
91
+ ? override.hop ?? 0
92
+ : Math.max(0, ...(state.routing?.history ?? [])
93
+ .filter((entry) => entry.role === role && (entry.revision ?? revision) === revision)
94
+ .map((entry) => entry.hop ?? 0));
95
+ return schedule.kind === "reset" ? previousHop : previousHop + 1;
96
+ }
97
+
98
+ /**
99
+ * The one-hop failover target out of `current`, if its runtime declares one.
100
+ * Preflight walks this so the runtime a run could actually spend on is
101
+ * capability-checked before the first provider starts — and only this one
102
+ * hop, never the fallback's own fallback, since a node can take only one hop.
103
+ *
104
+ * @param {ValidatedContract} contract
105
+ * @param {{node: EdgeNode, role: "worker"|"judge", runtimeId: string}} current
106
+ * @param {{routing?: {assignments?: {worker?: string, judge?: string}, availability?: Record<string, {available: boolean, exhaustedUntil: string|null, reason: string}>}}|null} [state]
107
+ * @returns {RuntimeSnapshot[]}
108
+ */
109
+ export function failoverTargets(contract, current, state = null) {
110
+ const declared = synthesizedChain(contract, current.role, current.runtimeId);
111
+ if (declared.length) return declared.map((id) => runtimeSnapshot(contract, id));
112
+ const attempted = new Set([current.runtimeId]);
113
+ const composed = state ? nextSameTierRuntime(contract, state.routing ?? {}, current.role, current.runtimeId, attempted) : null;
114
+ return composed ? [runtimeSnapshot(contract, composed)] : [];
115
+ }
116
+
117
+ /**
118
+ * Every declared fallback edge, for reporting and documentation.
119
+ *
120
+ * @param {ValidatedContract} contract
121
+ * @returns {{from: string, to: string, source: "declared"}[]}
122
+ */
123
+ export function failoverEdges(contract) {
124
+ return rankedRuntimeIds(contract)
125
+ .filter((id) => contract.runtimes[id].fallback)
126
+ .map((id) => ({ from: id, to: /** @type {string} */ (contract.runtimes[id].fallback), source: /** @type {"declared"} */ ("declared") }));
127
+ }
128
+
129
+ /**
130
+ * Merge one runtime's capability requirements into the reachable-runtime map.
131
+ * The first snapshot wins; every later requirement set is accumulated, so a
132
+ * runtime reached twice is still checked against both callers' demands.
133
+ *
134
+ * @param {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: import("../harnesses/index.mjs").CapabilityRequirements[]}>} runtimes
135
+ * @param {RuntimeSnapshot} runtime
136
+ * @param {import("../harnesses/index.mjs").CapabilityRequirements[]} requiredCapabilitySets
137
+ */
138
+ export function addRuntimeRequirement(runtimes, runtime, requiredCapabilitySets) {
139
+ const incoming = requiredCapabilitySets.filter((requirements) => requirements && Object.keys(requirements).length);
140
+ const current = runtimes.get(runtime.id);
141
+ if (!current) runtimes.set(runtime.id, { runtime, requiredCapabilitySets: incoming });
142
+ else runtimes.set(runtime.id, { runtime: current.runtime, requiredCapabilitySets: [...current.requiredCapabilitySets, ...incoming] });
143
+ }
144
+
145
+ /**
146
+ * Resolve one declared runtime id into the snapshot shape every routing
147
+ * decision hands on, capabilities and vendor included.
148
+ *
149
+ * @param {ValidatedContract} contract
150
+ * @param {string} id
151
+ * @returns {RuntimeSnapshot}
152
+ */
153
+ export function runtimeSnapshot(contract, id) {
154
+ const runtime = contract.runtimes[id];
155
+ if (!runtime) throw new Error(`unknown persisted runtime: ${id}`);
156
+ return { id, ...runtime, capabilities: harnessCapabilities(runtime) };
157
+ }
158
+
159
+ /**
160
+ * A node parked on a failover or quota-reset backoff is not schedulable yet.
161
+ * The window belongs to the role that opened it, so a worker backoff never
162
+ * holds back a judge on the same node.
163
+ *
164
+ * @param {{routing?: {currentOverride?: {role?: string, backoffUntil?: string}|null}|null}} state
165
+ * @param {string} phase
166
+ * @returns {boolean}
167
+ */
168
+ export function routingBackoffActive(state, phase) {
169
+ const override = state.routing?.currentOverride;
170
+ return Boolean(override?.role === phase && override.backoffUntil && Date.parse(override.backoffUntil) > Date.now());
171
+ }
172
+
173
+ /**
174
+ * @param {ValidatedContract} contract
175
+ * @param {ValidatedNode} node
176
+ * @param {NodeSnapshot} state
177
+ * @param {"worker"|"judge"} role
178
+ * @returns {RuntimeSnapshot}
179
+ */
180
+ export function routeRuntimeForState(contract, node, state, role) {
181
+ const override = state.routing?.currentOverride;
182
+ if (override?.role === role && contract.runtimes[override.runtime]) {
183
+ const runtime = contract.runtimes[override.runtime];
184
+ return { id: override.runtime, ...runtime, capabilities: harnessCapabilities(runtime) };
185
+ }
186
+ const assigned = state.routing?.assignments?.[role];
187
+ if (assigned && contract.runtimes[assigned]) {
188
+ const runtime = contract.runtimes[assigned];
189
+ return { id: assigned, ...runtime, capabilities: harnessCapabilities(runtime) };
190
+ }
191
+ return /** @type {RuntimeSnapshot} */ (routeRuntime(contract, node, role));
192
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The provider gate: a standalone program the controller spawns in the worker's
3
+ * place, which holds the provider process until the controller writes the
4
+ * release file. It exists so the controller can record an operation intent
5
+ * before a single token is spent -- the gate is already the running process and
6
+ * its pid is already persisted, so a crash between spawn and release leaves a
7
+ * recoverable record rather than an unknown effect.
8
+ *
9
+ * It is spawned by path (`spawn(process.execPath, [gate.mjs])`), not imported,
10
+ * and it talks to its parent only through the environment:
11
+ * FABERUN_GATE_CONFIG the invocation to run, as JSON
12
+ * FABERUN_GATE_RELEASE the file whose appearance releases it
13
+ * FABERUN_GATE_PARENT_PID the controller it must not outlive
14
+ * FABERUN_GATE_PARENT_TOKEN that pid's start token, so a reused pid
15
+ * is not mistaken for a live controller
16
+ *
17
+ * Until 2026-09-11 this was a `String.raw` template inside node.mjs, spawned
18
+ * with `node -e`. As a real file it is covered by `npm run check` and by
19
+ * `tsc` -- which found eleven type errors in it on the first run, none of them
20
+ * reachable while the code was a string -- and it shows up in a stack trace.
21
+ *
22
+ * Nothing here is exported for a caller: this file is a program. Every name
23
+ * below is module-local.
24
+ */
25
+ import { existsSync, readFileSync, statSync, openSync, closeSync, readSync, writeSync } from "node:fs";
26
+ import { spawn } from "node:child_process";
27
+
28
+ /** @typedef {{executable: string, args: string[], cwd: string, promptTransport: "stdin"|"argv", harness: string, env: Record<string, string|null>|null, stdoutPath: string, stderrPath: string}} GateConfig */
29
+
30
+ /**
31
+ * @param {string} name
32
+ * @returns {string}
33
+ */
34
+ function requiredEnv(name) {
35
+ const value = process.env[name];
36
+ if (!value) throw new Error(`${name} is required`);
37
+ return value;
38
+ }
39
+
40
+ /** @type {GateConfig} */
41
+ const config = JSON.parse(readFileSync(requiredEnv("FABERUN_GATE_CONFIG"), "utf8"));
42
+ const releasePath = requiredEnv("FABERUN_GATE_RELEASE");
43
+ const parentPid = Number(process.env.FABERUN_GATE_PARENT_PID);
44
+ const parentToken = process.env.FABERUN_GATE_PARENT_TOKEN || null;
45
+ const maxLogBytes = 512 * 1024;
46
+
47
+ /**
48
+ * @param {number} pid
49
+ * @returns {string|null}
50
+ */
51
+ function startToken(pid) {
52
+ if (process.platform !== "linux" || !pid) return null;
53
+ try {
54
+ const stat = readFileSync("/proc/" + pid + "/stat", "utf8").trim();
55
+ return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19] ?? null;
56
+ } catch { return null; }
57
+ }
58
+
59
+ /** @returns {boolean} */
60
+ function parentAlive() {
61
+ try {
62
+ process.kill(parentPid, 0);
63
+ } catch (error) {
64
+ return error !== null && typeof error === "object" && "code" in error
65
+ && /** @type {{code: unknown}} */ (error).code === "EPERM";
66
+ }
67
+ return !parentToken || process.platform !== "linux" || startToken(parentPid) === parentToken;
68
+ }
69
+
70
+ /** @type {import("node:child_process").ChildProcess|null} */
71
+ let provider = null;
72
+ let inputEnded = config.promptTransport !== "stdin";
73
+ /** @type {Buffer[]} */
74
+ const pendingInput = [];
75
+ if (config.promptTransport === "stdin") {
76
+ process.stdin.on("data", (chunk) => {
77
+ if (provider) provider.stdin?.write(chunk);
78
+ else pendingInput.push(chunk);
79
+ });
80
+ process.stdin.on("end", () => {
81
+ inputEnded = true;
82
+ if (provider) provider.stdin?.end();
83
+ });
84
+ }
85
+
86
+ /** @param {NodeJS.Signals} signal */
87
+ function killGroup(signal) {
88
+ try { process.kill(-process.pid, signal); } catch {
89
+ // ESRCH: the process group is already gone, so there is nothing to signal.
90
+ }
91
+ }
92
+
93
+ function stopProvider() {
94
+ try { provider?.kill("SIGTERM"); } catch {
95
+ // No provider yet, or it already exited: a failed SIGTERM needs no action.
96
+ }
97
+ setTimeout(() => killGroup("SIGKILL"), 100).unref();
98
+ }
99
+
100
+ /**
101
+ * Providers write directly into the log files: a provider with non-blocking
102
+ * stdout (EAGAIN on a full pipe) must never die because the controller's event
103
+ * loop is briefly busy. Cap the files to the last maxLogBytes afterwards.
104
+ *
105
+ * @param {string} path
106
+ * @param {boolean} [preservePrefix]
107
+ */
108
+ function capLog(path, preservePrefix = false) {
109
+ try {
110
+ const size = statSync(path).size;
111
+ if (size <= maxLogBytes) return;
112
+ if (preservePrefix) {
113
+ const prefixLimit = Math.min(64 * 1024, maxLogBytes - 1);
114
+ const prefix = Buffer.alloc(prefixLimit);
115
+ const prefixFd = openSync(path, "r");
116
+ readSync(prefixFd, prefix, 0, prefixLimit, 0);
117
+ closeSync(prefixFd);
118
+ const prefixEnd = prefix.lastIndexOf(10);
119
+ if (prefixEnd >= 0) {
120
+ const tailLimit = maxLogBytes - prefixEnd - 1;
121
+ const tail = Buffer.alloc(tailLimit);
122
+ const tailFd = openSync(path, "r");
123
+ readSync(tailFd, tail, 0, tailLimit, size - tailLimit);
124
+ closeSync(tailFd);
125
+ const tailStart = tail.indexOf(10);
126
+ const suffix = tailStart >= 0 ? tail.subarray(tailStart + 1) : Buffer.alloc(0);
127
+ const out = openSync(path, "w");
128
+ writeSync(out, Buffer.concat([prefix.subarray(0, prefixEnd + 1), suffix]));
129
+ closeSync(out);
130
+ return;
131
+ }
132
+ }
133
+ const fd = openSync(path, "r");
134
+ const buffer = Buffer.alloc(maxLogBytes);
135
+ readSync(fd, buffer, 0, maxLogBytes, size - maxLogBytes);
136
+ closeSync(fd);
137
+ const out = openSync(path, "w");
138
+ writeSync(out, buffer);
139
+ closeSync(out);
140
+ } catch {
141
+ // Best-effort cap: any filesystem error leaves the log uncapped, which is safe.
142
+ }
143
+ }
144
+
145
+ /** @returns {Record<string, string|undefined>} */
146
+ function childEnv() {
147
+ const merged = { ...process.env };
148
+ for (const [key, value] of Object.entries(config.env ?? {})) {
149
+ if (value === null) delete merged[key];
150
+ else merged[key] = value;
151
+ }
152
+ // Worker providers are not a notification surface: strip the controller-only
153
+ // transport after the harness overlay so no harness can reintroduce it.
154
+ delete merged.FABERUN_NOTIFY_BIN;
155
+ return merged;
156
+ }
157
+
158
+ process.on("SIGTERM", () => stopProvider());
159
+ process.on("SIGINT", () => stopProvider());
160
+
161
+ const timer = setInterval(() => {
162
+ if (!parentAlive()) { clearInterval(timer); stopProvider(); return; }
163
+ if (!existsSync(releasePath)) return;
164
+ clearInterval(timer);
165
+ const stdoutFd = openSync(config.stdoutPath, "wx", 0o600);
166
+ const stderrFd = openSync(config.stderrPath, "wx", 0o600);
167
+ provider = spawn(config.executable, config.args, {
168
+ cwd: config.cwd,
169
+ env: childEnv(),
170
+ stdio: [config.promptTransport === "stdin" ? "pipe" : "ignore", stdoutFd, stderrFd],
171
+ });
172
+ if (config.promptTransport === "stdin") {
173
+ for (const chunk of pendingInput) provider.stdin?.write(chunk);
174
+ pendingInput.length = 0;
175
+ if (inputEnded) provider.stdin?.end();
176
+ }
177
+ provider.once("error", () => process.exitCode = 127);
178
+ provider.once("close", (code) => {
179
+ capLog(config.stdoutPath, config.harness === "codex");
180
+ capLog(config.stderrPath);
181
+ process.exit(code ?? 1);
182
+ });
183
+ }, 10);