faberun 0.26.2 → 0.27.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.
@@ -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,6 +3,7 @@ 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";
@@ -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,7 +67,7 @@ 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.
@@ -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
  /**
@@ -157,6 +159,17 @@ export function resolvePermissionExecution(runtime) {
157
159
  return { executes: policy.executingModes.includes(mode), field: policy.field, mode, executingModes: policy.executingModes };
158
160
  }
159
161
 
162
+ /**
163
+ * The mode in which a runtime's harness cannot write its workspace, or `null`
164
+ * when the harness declares none.
165
+ *
166
+ * @param {{harness: string}} runtime
167
+ * @returns {string|null}
168
+ */
169
+ export function readOnlyModeOf(runtime) {
170
+ return getHarness(runtime.harness).permissionExecution?.readOnlyModes?.[0] ?? null;
171
+ }
172
+
160
173
  /**
161
174
  * Whether a runtime can write its workspace. False only in a mode its adapter
162
175
  * declares read-only; RM-058 measured a codex reviewer under `read-only`
@@ -234,7 +247,7 @@ export function providerCommand(runtime, prompt, options = {}) {
234
247
  }
235
248
 
236
249
  /**
237
- * @param {string|{harness: string}} runtimeOrHarness
250
+ * @param {string|{harness: string, model?: string, config?: Record<string, unknown>}} runtimeOrHarness
238
251
  * @param {string} stdout
239
252
  * @param {number|null} exitCode
240
253
  * @param {string|null} signal
@@ -244,7 +257,9 @@ export function providerCommand(runtime, prompt, options = {}) {
244
257
  export function normalizeProviderResult(runtimeOrHarness, stdout, exitCode, signal, options = {}) {
245
258
  const runtime = typeof runtimeOrHarness === "string" ? { harness: runtimeOrHarness } : runtimeOrHarness;
246
259
  const harness = getHarness(runtime.harness);
247
- return harness.normalize(stdout, exitCode, signal, options);
260
+ const envelope = harness.normalize(stdout, exitCode, signal, options);
261
+ // A cost the harness reports in another vendor's rates is not a bill.
262
+ return harness.reportsCost?.(runtime) === false ? { ...envelope, costUsd: null } : envelope;
248
263
  }
249
264
 
250
265
  /**
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The process half every Faberun harness runner shares: one prompt read from
3
+ * stdin, one JSONL transcript written to fd 1. Kept apart from
4
+ * `runner-transcript.mjs` because that module runs inside the controller and
5
+ * this one inside the spawned runner process.
6
+ */
7
+
8
+ import { writeSync } from "node:fs";
9
+
10
+ /**
11
+ * Whole-line writes to fd 1: `process.exit` cannot lose an unfinished write.
12
+ *
13
+ * @param {Record<string, unknown>} event
14
+ */
15
+ export function emit(event) {
16
+ const line = Buffer.from(`${JSON.stringify(event)}\n`, "utf8");
17
+ for (let written = 0; written < line.length; ) written += writeSync(1, line, written);
18
+ }
19
+
20
+ /** @returns {Promise<string>} */
21
+ export async function readPrompt() {
22
+ const chunks = [];
23
+ for await (const chunk of process.stdin) chunks.push(chunk);
24
+ return Buffer.concat(chunks).toString("utf8");
25
+ }