faberun 0.26.3 → 0.28.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 (88) hide show
  1. package/README.md +11 -4
  2. package/package.json +1 -1
  3. package/skills/faberun/SKILL.md +3 -3
  4. package/skills/faberun/references/contract.md +9 -9
  5. package/skills/faberun/references/local-env.md +11 -0
  6. package/skills/faberun/references/operations.md +26 -33
  7. package/src/campaign/brief.mjs +41 -9
  8. package/src/campaign/campaign-brief.mjs +18 -3
  9. package/src/campaign/index.mjs +22 -9
  10. package/src/campaign/ledger.mjs +72 -4
  11. package/src/campaign/metrics.mjs +29 -2
  12. package/src/campaign/record.mjs +34 -0
  13. package/src/cli/campaign.mjs +35 -12
  14. package/src/cli/contract.mjs +11 -1
  15. package/src/cli/launch.mjs +12 -2
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/setup.mjs +6 -1
  18. package/src/cli/skills.mjs +18 -7
  19. package/src/cli/uninstall.mjs +542 -0
  20. package/src/cli/walkthrough.mjs +462 -0
  21. package/src/cli.mjs +68 -8
  22. package/src/contract/articles.mjs +30 -0
  23. package/src/contract/definition-of-done.mjs +109 -0
  24. package/src/contract/index.mjs +7 -4
  25. package/src/contract/judge-independence.mjs +18 -0
  26. package/src/contract/runtime.mjs +6 -2
  27. package/src/contract/scope-findings.mjs +120 -0
  28. package/src/contract/snapshot.mjs +19 -5
  29. package/src/contract/task-packet.mjs +49 -1
  30. package/src/contract/worker-result.mjs +10 -0
  31. package/src/engine/assignment.mjs +45 -0
  32. package/src/engine/detach.mjs +18 -0
  33. package/src/engine/gate.mjs +10 -2
  34. package/src/engine/judge-gate.mjs +24 -9
  35. package/src/engine/live-preflight.mjs +114 -18
  36. package/src/engine/prompts.mjs +41 -3
  37. package/src/engine/resume.mjs +119 -12
  38. package/src/engine/retry.mjs +11 -11
  39. package/src/engine/run-command.mjs +80 -4
  40. package/src/engine/run-identity.mjs +27 -14
  41. package/src/engine/runtime-discovery.mjs +61 -13
  42. package/src/engine/worker-env.mjs +179 -0
  43. package/src/harnesses/agy/index.mjs +11 -0
  44. package/src/harnesses/catalogue.mjs +54 -2
  45. package/src/harnesses/claude/index.mjs +177 -2
  46. package/src/harnesses/codex/index.mjs +15 -0
  47. package/src/harnesses/dsh/index.mjs +18 -116
  48. package/src/harnesses/dsh/runner.mjs +1 -17
  49. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  50. package/src/harnesses/fx/home.mjs +57 -0
  51. package/src/harnesses/fx/index.mjs +137 -0
  52. package/src/harnesses/fx/native/build.zig +20 -0
  53. package/src/harnesses/fx/native/build.zig.zon +7 -0
  54. package/src/harnesses/fx/native/home.zig +85 -0
  55. package/src/harnesses/fx/native/main.zig +503 -0
  56. package/src/harnesses/fx/native/permissions.zig +51 -0
  57. package/src/harnesses/fx/native/relay.zig +253 -0
  58. package/src/harnesses/fx/native/usage.zig +112 -0
  59. package/src/harnesses/fx/permissions.mjs +35 -0
  60. package/src/harnesses/fx/runner.mjs +350 -0
  61. package/src/harnesses/fx/usage-proxy.mjs +176 -0
  62. package/src/harnesses/index.mjs +29 -14
  63. package/src/harnesses/replay/index.mjs +12 -0
  64. package/src/harnesses/runner-io.mjs +25 -0
  65. package/src/harnesses/runner-transcript.mjs +127 -0
  66. package/src/harnesses/session-metrics.mjs +38 -13
  67. package/src/harnesses/zcode/index.mjs +19 -0
  68. package/src/host/config.mjs +8 -2
  69. package/src/host/environment-report.mjs +85 -0
  70. package/src/host/home.mjs +27 -0
  71. package/src/host/install-registry.mjs +511 -0
  72. package/src/host/preflight.mjs +34 -10
  73. package/src/host/uninstall-roots.mjs +196 -0
  74. package/src/plan/proof-check.mjs +15 -0
  75. package/src/plan/repo-facts.mjs +95 -11
  76. package/src/plan/rounds.mjs +27 -0
  77. package/src/repo/declared-paths.mjs +74 -6
  78. package/src/repo/integrate.mjs +1 -1
  79. package/src/repo/scope-closure.mjs +59 -0
  80. package/src/repo/signal-block.mjs +26 -0
  81. package/src/repo/signal.mjs +9 -24
  82. package/src/repo/workspace.mjs +189 -14
  83. package/src/report/campaign-brief.mjs +4 -0
  84. package/src/report/message.mjs +37 -6
  85. package/src/report/progress.mjs +13 -2
  86. package/src/run/paths.mjs +14 -1
  87. package/src/run/usage.mjs +66 -1
  88. package/src/util.mjs +18 -2
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Faberun's fx client: one prompt on stdin, one JSONL transcript on stdout,
3
+ * no interactive surface. The adapter runs this file under
4
+ * `process.execPath`, so spawning it never depends on a shebang, an
5
+ * executable bit, or `node` being on the provider's PATH.
6
+ *
7
+ * Why ACP instead of `fx ask --json`: `ask` prints its JSON once, at exit, so
8
+ * the controller's stall detector would see a silent worker for the whole
9
+ * turn, and its only permission choices are "everything" or "let the model
10
+ * review itself". `fx acp` streams every tool call as it happens and, in
11
+ * `ask` mode, sends each shell command and file mutation to this client as
12
+ * `session/request_permission`, so the file-effect boundary the contract
13
+ * names is decided here, per path. Measured 2026-09-24 against
14
+ * api.deepseek.com: seven permission requests for one parseDuration turn,
15
+ * every file mutation carrying its `path` in `rawInput`.
16
+ *
17
+ * Usage and provider failures come from the loopback relay in
18
+ * `usage-proxy.mjs`, never from fx: fx's usage omits the cache split and its
19
+ * provider errors arrive as prose.
20
+ *
21
+ * Transcript, the only thing this process writes to stdout:
22
+ * {"type":"fx.started","sessionId":string}
23
+ * {"type":"fx.tool","name":string,"target":string|null} one per tool call
24
+ * {"type":"fx.request","status":number,"usage":Usage|null} one per provider request
25
+ * {"type":"fx.message","text":string} one per non-empty assistant message
26
+ * {"type":"fx.completed","sessionId":string,"result":string,"usage":Usage|null}
27
+ * {"type":"fx.failed","sessionId":string|null,"kind":string,"error":{...},"usage":Usage|null}
28
+ * where Usage is `{inputTokens, outputTokens, cacheReadInputTokens}` with
29
+ * `inputTokens` excluding the cached prefix, matching `canonicalUsage`.
30
+ */
31
+
32
+ import { spawn } from "node:child_process";
33
+ import { mkdtempSync, realpathSync, rmSync } from "node:fs";
34
+ import { homedir, tmpdir } from "node:os";
35
+ import { join } from "node:path";
36
+ import { spawnInvocation } from "../../host/platform.mjs";
37
+ import { emit, readPrompt } from "../runner-io.mjs";
38
+ import { prepareFxHome } from "./home.mjs";
39
+ import { permissionVerdict } from "./permissions.mjs";
40
+ import { startUsageProxy } from "./usage-proxy.mjs";
41
+
42
+ /** The provider name the generated settings give the relay connection. */
43
+ const PROVIDER = "faberun";
44
+ /**
45
+ * fx's built-in ChatGPT-subscription provider, and the Responses endpoint it
46
+ * posts to; the relay stands in for the host.
47
+ */
48
+ const CODEX_PROVIDER = "codex";
49
+ const CODEX_UPSTREAM = "https://chatgpt.com/backend-api/codex";
50
+ /**
51
+ * Which of fx's provider paths the worker takes: `openai-compatible` is a
52
+ * custom Chat Completions connection (DeepSeek, Z.ai) authenticated by a key
53
+ * in the environment; `codex` is fx's ChatGPT login, which fx-faberun reads
54
+ * from the operator's real profile through `FX_AUTH_HOME`.
55
+ */
56
+ const PROVIDER_KINDS = new Set(["openai-compatible", "codex"]);
57
+ /**
58
+ * Measured: DeepSeek's `max_tokens` ceiling on deepseek-flash; fx needs a
59
+ * declared value. It is the default, not a law: a runtime may declare
60
+ * `max_output_tokens` when its provider allows more. Measured 2026-09-27 on
61
+ * frb-linux2: a `faberun plan` draft node cannot fit in 8,192 output tokens
62
+ * and died with `OutputTruncated` with no way to raise the ceiling.
63
+ */
64
+ const MAX_OUTPUT_TOKENS = 8192;
65
+
66
+ /** @typedef {{fx: string, model: string, sandbox: string, provider: string, baseUrl: string|null, keyEnv: string, contextWindow: number, maxOutputTokens: number}} RunnerOptions */
67
+
68
+ /**
69
+ * @param {string[]} argv
70
+ * @returns {RunnerOptions}
71
+ */
72
+ function parseArgs(argv) {
73
+ /** @type {RunnerOptions} */
74
+ const options = {
75
+ fx: "fx",
76
+ model: "",
77
+ sandbox: "workspace-write",
78
+ provider: "openai-compatible",
79
+ baseUrl: null,
80
+ keyEnv: "DEEPSEEK_API_KEY",
81
+ contextWindow: 1_000_000,
82
+ maxOutputTokens: MAX_OUTPUT_TOKENS,
83
+ };
84
+ for (let index = 0; index < argv.length; index += 2) {
85
+ const flag = argv[index];
86
+ const value = argv[index + 1];
87
+ if (typeof value !== "string") throw new Error(`${flag} needs a value`);
88
+ if (flag === "--fx") options.fx = value;
89
+ else if (flag === "--model") options.model = value;
90
+ else if (flag === "--sandbox") options.sandbox = value;
91
+ else if (flag === "--provider") {
92
+ if (!PROVIDER_KINDS.has(value)) throw new Error(`unknown provider: ${value}`);
93
+ options.provider = value;
94
+ } else if (flag === "--base-url") options.baseUrl = value;
95
+ else if (flag === "--key-env") options.keyEnv = value;
96
+ else if (flag === "--context-window") options.contextWindow = Number(value);
97
+ else if (flag === "--max-output-tokens") options.maxOutputTokens = Number(value);
98
+ else throw new Error(`unknown argument: ${flag}`);
99
+ }
100
+ if (!options.model) throw new Error("--model is required");
101
+ return options;
102
+ }
103
+
104
+ /**
105
+ * @param {Record<string, any>} toolCall
106
+ * @returns {string|null}
107
+ */
108
+ function toolTarget(toolCall) {
109
+ const input = toolCall.rawInput && typeof toolCall.rawInput === "object" ? toolCall.rawInput : {};
110
+ const value = input.path ?? input.command ?? input.pattern ?? input.url ?? null;
111
+ return typeof value === "string" ? value.slice(0, 200) : null;
112
+ }
113
+
114
+ /** @param {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number}} totals @param {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number}} usage */
115
+ function addUsage(totals, usage) {
116
+ totals.inputTokens += usage.inputTokens;
117
+ totals.outputTokens += usage.outputTokens;
118
+ totals.cacheReadInputTokens += usage.cacheReadInputTokens;
119
+ }
120
+
121
+ /**
122
+ * The failure a provider status means. 402 is DeepSeek's "Insufficient
123
+ * Balance" and 429 its rate limit; both name the exhaustion the declared
124
+ * failover edge exists for.
125
+ *
126
+ * @param {{status: number, error: string|null, retryAfterMs: number|null}} request
127
+ * @returns {{kind: string, error: {code: string, message: string, retryAfterMs?: number}}}
128
+ */
129
+ function providerFailure(request) {
130
+ const code = request.status === 429 ? "RATE_LIMIT" : request.status === 402 ? "QUOTA" : `http_${request.status}`;
131
+ const message = `${request.status} ${request.error ?? ""}`.trim().slice(0, 512);
132
+ return { kind: "error", error: { code, message, ...(request.retryAfterMs ? { retryAfterMs: request.retryAfterMs } : {}) } };
133
+ }
134
+
135
+ const options = parseArgs(process.argv.slice(2));
136
+ const prompt = await readPrompt();
137
+ const workspace = realpathSync(process.cwd());
138
+ const home = mkdtempSync(join(tmpdir(), "faberun-fx-home-"));
139
+ const usage = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 };
140
+ let sawUsage = false;
141
+ /** @type {{status: number, error: string|null, retryAfterMs: number|null}|null} */
142
+ let lastFailure = null;
143
+ /** @type {string[]} */
144
+ const texts = [];
145
+ let segment = "";
146
+ let sessionId = /** @type {string|null} */ (null);
147
+ let settled = false;
148
+
149
+ const codex = options.provider === "codex";
150
+ const proxy = await startUsageProxy({
151
+ upstream: options.baseUrl ?? (codex ? CODEX_UPSTREAM : "https://api.deepseek.com"),
152
+ onRequest(request) {
153
+ if (request.usage) {
154
+ addUsage(usage, request.usage);
155
+ sawUsage = true;
156
+ }
157
+ lastFailure = request.status >= 400 ? request : null;
158
+ emit({ type: "fx.request", status: request.status, usage: request.usage });
159
+ },
160
+ });
161
+
162
+ prepareFxHome(homedir(), home, codex ? {
163
+ provider: CODEX_PROVIDER,
164
+ models: { [CODEX_PROVIDER]: options.model },
165
+ permission_mode: "ask",
166
+ } : {
167
+ provider: PROVIDER,
168
+ models: { [PROVIDER]: options.model },
169
+ permission_mode: "ask",
170
+ providers: {
171
+ [PROVIDER]: {
172
+ protocol: "openai-chat-completions",
173
+ base_url: proxy.url,
174
+ auth: { type: "bearer", env: options.keyEnv },
175
+ model_metadata: {
176
+ [options.model]: { context_window: options.contextWindow, max_output_tokens: options.maxOutputTokens, supports_tool_use: true },
177
+ },
178
+ },
179
+ },
180
+ });
181
+
182
+ // FX_SOUND: fx plays a sound on every send and response, which a detached
183
+ // campaign of parallel workers turns into noise on the operator's machine.
184
+ const env = {
185
+ ...process.env,
186
+ HOME: home,
187
+ FX_PROVIDER: codex ? CODEX_PROVIDER : PROVIDER,
188
+ FX_PERMISSION_MODE: "ask",
189
+ FX_AUTO_UPGRADE: "0",
190
+ FX_SOUND: "0",
191
+ // The ChatGPT session stays in the operator's profile: a copy would diverge
192
+ // on the first token refresh (fx-faberun only). fx accepts a loopback
193
+ // override of the Responses endpoint, which is how the relay meters it.
194
+ ...(codex ? { FX_AUTH_HOME: homedir(), FX_E2E_OPENAI_CODEX_RESPONSES_URL: `${proxy.url}/responses` } : {}),
195
+ };
196
+ const invocation = spawnInvocation(options.fx, ["acp"]);
197
+ const child = spawn(invocation.command, invocation.args, { cwd: workspace, env, stdio: ["pipe", "pipe", "pipe"], ...invocation.options });
198
+
199
+ function flushSegment() {
200
+ if (!segment.trim()) return;
201
+ texts.push(segment);
202
+ emit({ type: "fx.message", text: segment });
203
+ segment = "";
204
+ }
205
+
206
+ /** @param {Record<string, unknown>} event @param {number} exitCode */
207
+ async function settle(event, exitCode) {
208
+ if (settled) return;
209
+ settled = true;
210
+ emit({ ...event, usage: sawUsage ? usage : null });
211
+ child.kill("SIGTERM");
212
+ await proxy.close();
213
+ rmSync(home, { recursive: true, force: true });
214
+ process.exit(exitCode);
215
+ }
216
+
217
+ /** @param {string} kind @param {{code: string, message: string, retryAfterMs?: number}} error */
218
+ function fail(kind, error) {
219
+ flushSegment();
220
+ // A provider status beats fx's prose about it: it is the fact failover keys on.
221
+ const failure = lastFailure ? providerFailure(lastFailure) : { kind, error };
222
+ return settle({ type: "fx.failed", sessionId, ...failure }, 1);
223
+ }
224
+
225
+ let nextId = 0;
226
+ /** @type {Map<number, (message: Record<string, any>) => void>} */
227
+ const pending = new Map();
228
+ /** @param {string} method @param {Record<string, unknown>} params @returns {Promise<Record<string, any>>} */
229
+ function request(method, params) {
230
+ const id = ++nextId;
231
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
232
+ return new Promise((resolveReply) => pending.set(id, resolveReply));
233
+ }
234
+ /** @param {number|string} id @param {Record<string, unknown>} body */
235
+ function reply(id, body) {
236
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, ...body })}\n`);
237
+ }
238
+
239
+ /** @param {Record<string, any>} message */
240
+ function onServerRequest(message) {
241
+ if (message.method !== "session/request_permission") {
242
+ reply(message.id, { error: { code: -32601, message: `faberun does not serve ${message.method}` } });
243
+ return;
244
+ }
245
+ const toolCall = message.params?.toolCall ?? {};
246
+ const verdict = permissionVerdict(options.sandbox, workspace, toolCall);
247
+ const wanted = verdict.allow ? "allow_once" : "reject_once";
248
+ const option = (message.params?.options ?? []).find((/** @type {{kind: string}} */ candidate) => candidate.kind === wanted);
249
+ if (!verdict.allow) process.stderr.write(`faberun denied ${toolCall.title ?? "a tool call"}: ${verdict.reason}\n`);
250
+ reply(message.id, { result: { outcome: option ? { outcome: "selected", optionId: option.optionId } : { outcome: "cancelled" } } });
251
+ }
252
+
253
+ /** @param {Record<string, any>} update */
254
+ function onUpdate(update) {
255
+ if (update.sessionUpdate === "agent_message_chunk" && typeof update.content?.text === "string") {
256
+ segment += update.content.text;
257
+ } else if (update.sessionUpdate === "tool_call") {
258
+ // A tool call ends the message before it, the boundary a judge's
259
+ // verdicts are counted on.
260
+ flushSegment();
261
+ emit({ type: "fx.tool", name: typeof update.kind === "string" ? update.kind : "tool", target: toolTarget(update) });
262
+ }
263
+ }
264
+
265
+ let buffered = "";
266
+ let stderrTail = "";
267
+ child.stdout.setEncoding("utf8");
268
+ child.stdout.on("data", (chunk) => {
269
+ buffered += chunk;
270
+ const lines = buffered.split("\n");
271
+ buffered = lines.pop() ?? "";
272
+ for (const line of lines) {
273
+ if (!line.trim()) continue;
274
+ let message;
275
+ try {
276
+ message = JSON.parse(line);
277
+ } catch {
278
+ // stdout carries the JSON-RPC transport only; a non-JSON line is a
279
+ // harness defect, not provider prose, and must not be silently dropped.
280
+ fail("invalid_protocol", { code: "invalid_protocol", message: `fx wrote a non-JSON line: ${line.slice(0, 200)}` });
281
+ return;
282
+ }
283
+ if (message.method && message.id !== undefined) onServerRequest(message);
284
+ else if (message.method === "session/update") onUpdate(message.params?.update ?? {});
285
+ else if (message.id !== undefined && pending.has(message.id)) {
286
+ const resolveReply = pending.get(message.id);
287
+ pending.delete(message.id);
288
+ resolveReply?.(message);
289
+ }
290
+ }
291
+ });
292
+ child.stderr.setEncoding("utf8");
293
+ child.stderr.on("data", (chunk) => {
294
+ stderrTail = `${stderrTail}${chunk}`.slice(-4096);
295
+ process.stderr.write(chunk);
296
+ });
297
+ child.on("error", (error) => {
298
+ fail("harness_exit", { code: "harness_exit", message: `cannot start ${options.fx}: ${error.message}` });
299
+ });
300
+ child.on("close", (code) => {
301
+ const detail = stderrTail.trim() ? `: ${stderrTail.trim().slice(-512)}` : "";
302
+ fail("harness_exit", { code: "harness_exit", message: `fx exited with code ${code ?? 1} before the turn ended${detail}` });
303
+ });
304
+ for (const signal of ["SIGTERM", "SIGINT"]) {
305
+ process.on(signal, () => {
306
+ child.kill(/** @type {NodeJS.Signals} */ (signal));
307
+ rmSync(home, { recursive: true, force: true });
308
+ process.exit(1);
309
+ });
310
+ }
311
+ process.on("exit", () => {
312
+ if (!settled) child.kill("SIGKILL");
313
+ });
314
+
315
+ const initialized = await request("initialize", {
316
+ protocolVersion: 1,
317
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
318
+ });
319
+ if (initialized.error) {
320
+ await fail("initialize_failed", { code: "initialize_failed", message: JSON.stringify(initialized.error).slice(0, 512) });
321
+ }
322
+ const session = await request("session/new", { cwd: workspace, mcpServers: [] });
323
+ if (session.error || typeof session.result?.sessionId !== "string") {
324
+ await fail("session_failed", { code: "session_failed", message: JSON.stringify(session.error ?? session.result).slice(0, 512) });
325
+ }
326
+ sessionId = session.result.sessionId;
327
+ emit({ type: "fx.started", sessionId });
328
+ const turn = await request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] });
329
+ flushSegment();
330
+ if (turn.error) {
331
+ await fail("prompt_failed", { code: "prompt_failed", message: JSON.stringify(turn.error).slice(0, 512) });
332
+ }
333
+ const stopReason = turn.result?.stopReason;
334
+ if (stopReason === "end_turn") {
335
+ if (!sawUsage) {
336
+ // No request reached the relay with usage; fx's own totals are the
337
+ // fallback, without the cache split it cannot report.
338
+ const reported = turn.result?.usage;
339
+ if (reported && typeof reported === "object") {
340
+ usage.inputTokens = Number(reported.inputTokens) || 0;
341
+ usage.outputTokens = Number(reported.outputTokens) || 0;
342
+ sawUsage = true;
343
+ }
344
+ }
345
+ await settle({ type: "fx.completed", sessionId, result: texts.at(-1) ?? "" }, 0);
346
+ }
347
+ await fail(stopReason === "cancelled" ? "aborted" : String(stopReason ?? "unknown"), {
348
+ code: String(stopReason ?? "unknown"),
349
+ message: `the turn ended: ${stopReason}`,
350
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * A loopback relay between one fx worker and its OpenAI-compatible provider.
3
+ * fx 0.0.11 keeps only prompt and completion totals from a Chat Completions
4
+ * response, so the cache counters the provider returns never reach its JSON
5
+ * or its ACP usage. The relay forwards every byte unchanged and reads the
6
+ * `usage` object on the way back: measured 2026-09-24 over 24 fx requests to
7
+ * api.deepseek.com, 268,288 of 283,372 input tokens (94.7%) were cache hits
8
+ * that fx reported as ordinary input. It also sees the HTTP status fx turns
9
+ * into prose, which is the only reliable quota and rate-limit signal.
10
+ *
11
+ * One relay per runner, bound to 127.0.0.1 on an ephemeral port, so parallel
12
+ * workers never share a meter.
13
+ */
14
+
15
+ import { createServer } from "node:http";
16
+
17
+ /** Bytes of an error body kept for classification; the rest is the provider's prose. */
18
+ const ERROR_BODY_LIMIT = 2048;
19
+
20
+ /**
21
+ * @typedef {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number}} RequestUsage
22
+ * @typedef {{status: number, usage: RequestUsage|null, error: string|null, retryAfterMs: number|null}} RelayedRequest
23
+ */
24
+
25
+ /**
26
+ * @param {{upstream: string, onRequest: (request: RelayedRequest) => void}} options
27
+ * @returns {Promise<{url: string, close: () => Promise<void>}>}
28
+ */
29
+ export async function startUsageProxy({ upstream, onRequest }) {
30
+ const base = upstream.replace(/\/+$/u, "");
31
+ const server = createServer((req, res) => {
32
+ relay(base, req, res).then(onRequest, (error) => {
33
+ if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain" });
34
+ res.end("faberun usage proxy: upstream unreachable");
35
+ onRequest({ status: 502, usage: null, error: error instanceof Error ? error.message : String(error), retryAfterMs: null });
36
+ });
37
+ });
38
+ await new Promise((resolve, reject) => {
39
+ server.once("error", reject);
40
+ server.listen(0, "127.0.0.1", () => resolve(undefined));
41
+ });
42
+ const address = /** @type {import("node:net").AddressInfo} */ (server.address());
43
+ return {
44
+ url: `http://127.0.0.1:${address.port}`,
45
+ close: () => new Promise((resolve) => {
46
+ server.closeAllConnections();
47
+ server.close(() => resolve(undefined));
48
+ }),
49
+ };
50
+ }
51
+
52
+ /**
53
+ * @param {string} base
54
+ * @param {import("node:http").IncomingMessage} req
55
+ * @param {import("node:http").ServerResponse} res
56
+ * @returns {Promise<RelayedRequest>}
57
+ */
58
+ async function relay(base, req, res) {
59
+ const chunks = [];
60
+ for await (const chunk of req) chunks.push(chunk);
61
+ const headers = new Headers();
62
+ for (const [name, value] of Object.entries(req.headers)) {
63
+ if (value === undefined || ["host", "connection", "content-length", "accept-encoding"].includes(name)) continue;
64
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value);
65
+ }
66
+ const response = await fetch(`${base}${req.url ?? ""}`, {
67
+ method: req.method,
68
+ headers,
69
+ body: chunks.length ? Buffer.concat(chunks) : undefined,
70
+ redirect: "manual",
71
+ });
72
+ /** @type {Record<string, string>} */
73
+ const outgoing = {};
74
+ for (const name of ["content-type", "retry-after", "x-request-id"]) {
75
+ const value = response.headers.get(name);
76
+ if (value) outgoing[name] = value;
77
+ }
78
+ res.writeHead(response.status, outgoing);
79
+ const decoder = new TextDecoder();
80
+ let text = "";
81
+ let pending = "";
82
+ /** @type {unknown} */
83
+ let usage = null;
84
+ const contentType = response.headers.get("content-type");
85
+ // Measured 2026-09-26: the Codex endpoint streams SSE with no content-type
86
+ // at all, so a missing header is decided by the first bytes.
87
+ let streaming = (contentType ?? "").includes("text/event-stream");
88
+ let sniffed = contentType !== null;
89
+ if (response.body) {
90
+ for await (const chunk of response.body) {
91
+ res.write(chunk);
92
+ const decoded = decoder.decode(chunk, { stream: true });
93
+ if (!sniffed) {
94
+ sniffed = true;
95
+ streaming = /^\s*(data:|event:|:)/u.test(decoded);
96
+ }
97
+ if (!streaming) {
98
+ text += decoded;
99
+ continue;
100
+ }
101
+ pending += decoded;
102
+ const lines = pending.split("\n");
103
+ pending = lines.pop() ?? "";
104
+ for (const line of lines) usage = streamedUsage(line) ?? usage;
105
+ }
106
+ }
107
+ usage = streamedUsage(pending) ?? usage;
108
+ res.end();
109
+ if (!streaming && response.ok) usage = parsedUsage(text);
110
+ return {
111
+ status: response.status,
112
+ usage: canonicalRequestUsage(usage),
113
+ error: response.ok ? null : text.slice(0, ERROR_BODY_LIMIT) || response.statusText,
114
+ retryAfterMs: retryAfter(response.headers.get("retry-after")),
115
+ };
116
+ }
117
+
118
+ /** @param {string} line @returns {unknown} */
119
+ function streamedUsage(line) {
120
+ const trimmed = line.trim();
121
+ if (!trimmed.startsWith("data:")) return null;
122
+ const payload = trimmed.slice(5).trim();
123
+ if (payload === "[DONE]") return null;
124
+ return parsedUsage(payload);
125
+ }
126
+
127
+ /** @param {string} text @returns {unknown} */
128
+ function parsedUsage(text) {
129
+ try {
130
+ const value = JSON.parse(text);
131
+ if (!value || typeof value !== "object") return null;
132
+ // A Responses stream reports usage once, on `response.completed`, inside
133
+ // the response object.
134
+ return value.usage ?? value.response?.usage ?? null;
135
+ } catch {
136
+ // A partial or non-JSON frame carries no usage; the next frame might.
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Reduce the provider's usage object to the ledger's shape, where
143
+ * `inputTokens` excludes the cached prefix. DeepSeek reports the split
144
+ * directly (`prompt_cache_hit_tokens`, `prompt_cache_miss_tokens`); OpenAI,
145
+ * vLLM and Z.ai report `prompt_tokens_details.cached_tokens` inside
146
+ * `prompt_tokens`; the Responses API (fx's Codex provider) reports
147
+ * `input_tokens_details.cached_tokens` inside `input_tokens`.
148
+ *
149
+ * @param {unknown} usage
150
+ * @returns {RequestUsage|null}
151
+ */
152
+ export function canonicalRequestUsage(usage) {
153
+ if (!usage || typeof usage !== "object") return null;
154
+ const record = /** @type {Record<string, any>} */ (usage);
155
+ const number = (/** @type {unknown} */ value) => (typeof value === "number" && Number.isFinite(value) ? value : null);
156
+ const input = number(record.input_tokens);
157
+ if (input !== null) {
158
+ const cached = number(record.input_tokens_details?.cached_tokens) ?? 0;
159
+ return { inputTokens: Math.max(0, input - cached), outputTokens: number(record.output_tokens) ?? 0, cacheReadInputTokens: cached };
160
+ }
161
+ const prompt = number(record.prompt_tokens);
162
+ const output = number(record.completion_tokens) ?? 0;
163
+ const hit = number(record.prompt_cache_hit_tokens) ?? number(record.prompt_tokens_details?.cached_tokens) ?? 0;
164
+ const miss = number(record.prompt_cache_miss_tokens);
165
+ if (prompt === null && miss === null) return null;
166
+ return { inputTokens: miss ?? Math.max(0, (prompt ?? 0) - hit), outputTokens: output, cacheReadInputTokens: hit };
167
+ }
168
+
169
+ /** @param {string|null} header @returns {number|null} */
170
+ function retryAfter(header) {
171
+ if (!header) return null;
172
+ const seconds = Number(header);
173
+ if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000;
174
+ const at = Date.parse(header);
175
+ return Number.isFinite(at) && at > Date.now() ? at - Date.now() : null;
176
+ }
@@ -3,10 +3,11 @@ import { claudeHarness } from "./claude/index.mjs";
3
3
  import { codexHarness } from "./codex/index.mjs";
4
4
  import { agyHarness } from "./agy/index.mjs";
5
5
  import { dshHarness } from "./dsh/index.mjs";
6
+ import { fxHarness } from "./fx/index.mjs";
6
7
  import { zcodeHarness } from "./zcode/index.mjs";
7
8
  import { execJsonlHarness } from "./exec-jsonl/index.mjs";
8
9
  import { replayHarness } from "./replay/index.mjs";
9
- import { withoutNotifyEnv } from "../notify/index.mjs";
10
+ import { passthroughEnvironment, workerEnvironment } from "../engine/worker-env.mjs";
10
11
  import { spawnInvocation } from "../host/platform.mjs";
11
12
 
12
13
  /** Current wire-contract version for runner protocol artifacts. */
@@ -20,6 +21,7 @@ const HARNESSES = new Map([
20
21
  ["codex", codexHarness],
21
22
  ["agy", agyHarness],
22
23
  ["dsh", dshHarness],
24
+ ["fx", fxHarness],
23
25
  ["zcode", zcodeHarness],
24
26
  ["exec-jsonl", execJsonlHarness],
25
27
  ["replay", replayHarness],
@@ -65,12 +67,12 @@ const CAPABILITY_NAMES = new Set([
65
67
 
66
68
  /**
67
69
  * One declared runtime. `harness` names a registered adapter (`claude`,
68
- * `codex`, `agy`, `dsh`, `zcode`, `exec-jsonl`, or `replay`) and `model` names
70
+ * `codex`, `agy`, `dsh`, `fx`, `zcode`, `exec-jsonl`, or `replay`) and `model` names
69
71
  * what that harness asks; the two are independent. replay requires
70
72
  * `config["replay.recording"]` for commands, and dsh requires
71
73
  * `config.provider` for the provider route every attempt runs on.
72
74
  *
73
- * @typedef {{id?: string, harness: string, model: string, reasoning?: string, sandbox?: string, permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, tier?: number|string, vendor?: string}} HarnessRuntime
75
+ * @typedef {{id?: string, harness: string, model: string, reasoning?: string, sandbox?: string, permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, envPassthrough?: string[], tier?: number|string, vendor?: string}} HarnessRuntime
74
76
  */
75
77
 
76
78
  /**
@@ -106,7 +108,7 @@ export const READ_BYTE_LIMIT = 32 * 1024;
106
108
  * One provider adapter: capabilities plus executable, version, command, and
107
109
  * result-normalization behavior.
108
110
  *
109
- * @typedef {{capabilities: HarnessCapabilities, permissionExecution: PermissionExecutionPolicy, executable: (runtime: HarnessRuntime) => string, versionArgs: (runtime: HarnessRuntime) => string[], parseVersion: (stdout: string, stderr?: string) => string|null, command: (runtime: HarnessRuntime, prompt: string, options: CommandOptions) => HarnessCommand, normalize: (stdout: string, exitCode: number|null, signal: string|null, options?: NormalizeOptions) => ProviderEnvelope}} HarnessAdapter
111
+ * @typedef {{capabilities: HarnessCapabilities, permissionExecution: PermissionExecutionPolicy, executable: (runtime: HarnessRuntime) => string, versionArgs: (runtime: HarnessRuntime) => string[], parseVersion: (stdout: string, stderr?: string) => string|null, command: (runtime: HarnessRuntime, prompt: string, options: CommandOptions) => HarnessCommand, normalize: (stdout: string, exitCode: number|null, signal: string|null, options?: NormalizeOptions) => ProviderEnvelope, reportsCost?: (runtime: {harness: string, config?: Record<string, unknown>}) => boolean}} HarnessAdapter
110
112
  */
111
113
 
112
114
  /**
@@ -214,10 +216,14 @@ export function resolveVendor(runtime) {
214
216
 
215
217
  /**
216
218
  * Build one provider invocation. Prompt transport is explicit in the result:
217
- * stdin adapters return `input`, while argv adapters append the prompt. An
218
- * optional `env` overlay is merged over the runner environment at spawn time;
219
- * a null value removes the ambient variable. A caller-supplied `options.env`
220
- * merges over the adapter's own overlay here, once, for every harness.
219
+ * stdin adapters return `input`, while argv adapters append the prompt.
220
+ *
221
+ * The returned `env` is the runtime-specific allowlist overlay (adapter
222
+ * declarations, `*.env_key` names, and the runtime's `envPassthrough`) merged
223
+ * over the adapter's own overlay; a null value removes the ambient variable.
224
+ * The spawn gate unions it with the base operating-system names, so no other
225
+ * controller variable reaches the child. A caller-supplied `options.env`
226
+ * merges over everything here, once, for every harness.
221
227
  *
222
228
  * @param {HarnessRuntime} runtime
223
229
  * @param {string} prompt
@@ -227,6 +233,12 @@ export function resolveVendor(runtime) {
227
233
  export function providerCommand(runtime, prompt, options = {}) {
228
234
  const harness = getHarness(runtime.harness);
229
235
  const command = harness.command(runtime, prompt, options);
236
+ // The runtime-specific allowlist travels to the gate as an overlay. The gate
237
+ // unions it with the base operating-system names and nothing else, so a
238
+ // controller variable no adapter, `*.env_key`, or envPassthrough named never
239
+ // reaches the child. It is deliberately not the whole environment: the
240
+ // caller's overlay stays an overlay, and the base set stays the gate's job.
241
+ command.env = { ...passthroughEnvironment(runtime, undefined, process.env), ...command.env };
230
242
  if (options.env) command.env = { ...command.env, ...options.env };
231
243
  if (command.promptTransport === "argv") {
232
244
  const limit = runtime.maxArgvPromptBytes ?? harness.capabilities.maxArgvPromptBytes;
@@ -245,7 +257,7 @@ export function providerCommand(runtime, prompt, options = {}) {
245
257
  }
246
258
 
247
259
  /**
248
- * @param {string|{harness: string}} runtimeOrHarness
260
+ * @param {string|{harness: string, model?: string, config?: Record<string, unknown>}} runtimeOrHarness
249
261
  * @param {string} stdout
250
262
  * @param {number|null} exitCode
251
263
  * @param {string|null} signal
@@ -255,7 +267,9 @@ export function providerCommand(runtime, prompt, options = {}) {
255
267
  export function normalizeProviderResult(runtimeOrHarness, stdout, exitCode, signal, options = {}) {
256
268
  const runtime = typeof runtimeOrHarness === "string" ? { harness: runtimeOrHarness } : runtimeOrHarness;
257
269
  const harness = getHarness(runtime.harness);
258
- return harness.normalize(stdout, exitCode, signal, options);
270
+ const envelope = harness.normalize(stdout, exitCode, signal, options);
271
+ // A cost the harness reports in another vendor's rates is not a bill.
272
+ return harness.reportsCost?.(runtime) === false ? { ...envelope, costUsd: null } : envelope;
259
273
  }
260
274
 
261
275
  /**
@@ -503,10 +517,11 @@ export function probeRuntime(runtime, options = {}) {
503
517
  const invocation = spawnInvocation(executable, args, { cwd: options.cwd });
504
518
  child = spawn(invocation.command, invocation.args, {
505
519
  cwd: options.cwd,
506
- // A worker or judge never delivers a notification; the controller does.
507
- // In this repository a worker runs the test suite, whose fixture
508
- // controllers would otherwise inherit a live transport and deliver.
509
- env: withoutNotifyEnv(process.env),
520
+ // A probe is a worker-class spawn: it receives only the base names,
521
+ // the adapter's declared names, the runtime's `*.env_key` names, and
522
+ // any envPassthrough the runtime carries. It never delivers a
523
+ // notification; the controller does.
524
+ env: workerEnvironment(runtime),
510
525
  stdio: ["ignore", "pipe", "pipe"],
511
526
  ...invocation.options,
512
527
  });
@@ -2,6 +2,18 @@ import { resolve } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { parseVersion } from "../protocol.mjs";
4
4
 
5
+ /**
6
+ * The environment names the replay adapter reads to authenticate and configure
7
+ * the recording: only the binary override `executable()` honours. A recording
8
+ * carries no credential of its own, and `replay/bin.mjs` reads no environment
9
+ * name at all. Values never travel here.
10
+ *
11
+ * @type {readonly string[]}
12
+ */
13
+ export const declaredEnvironment = Object.freeze([
14
+ "FABERUN_REPLAY_BIN",
15
+ ]);
16
+
5
17
  /** Envelope statuses a recording may carry. */
6
18
  const REPLAY_STATUSES = Object.freeze(new Set([
7
19
  "done",