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.
- package/README.md +11 -4
- package/package.json +1 -1
- package/skills/faberun/SKILL.md +3 -3
- package/skills/faberun/references/contract.md +9 -9
- package/skills/faberun/references/local-env.md +11 -0
- package/skills/faberun/references/operations.md +26 -33
- package/src/campaign/brief.mjs +41 -9
- package/src/campaign/campaign-brief.mjs +18 -3
- package/src/campaign/index.mjs +22 -9
- package/src/campaign/ledger.mjs +72 -4
- package/src/campaign/metrics.mjs +29 -2
- package/src/campaign/record.mjs +34 -0
- package/src/cli/campaign.mjs +35 -12
- package/src/cli/contract.mjs +11 -1
- package/src/cli/launch.mjs +12 -2
- package/src/cli/manual.mjs +24 -8
- package/src/cli/setup.mjs +6 -1
- package/src/cli/skills.mjs +18 -7
- package/src/cli/uninstall.mjs +542 -0
- package/src/cli/walkthrough.mjs +462 -0
- package/src/cli.mjs +68 -8
- package/src/contract/articles.mjs +30 -0
- package/src/contract/definition-of-done.mjs +109 -0
- package/src/contract/index.mjs +7 -4
- package/src/contract/judge-independence.mjs +18 -0
- package/src/contract/runtime.mjs +6 -2
- package/src/contract/scope-findings.mjs +120 -0
- package/src/contract/snapshot.mjs +19 -5
- package/src/contract/task-packet.mjs +49 -1
- package/src/contract/worker-result.mjs +10 -0
- package/src/engine/assignment.mjs +45 -0
- package/src/engine/detach.mjs +18 -0
- package/src/engine/gate.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -9
- package/src/engine/live-preflight.mjs +114 -18
- package/src/engine/prompts.mjs +41 -3
- package/src/engine/resume.mjs +119 -12
- package/src/engine/retry.mjs +11 -11
- package/src/engine/run-command.mjs +80 -4
- package/src/engine/run-identity.mjs +27 -14
- package/src/engine/runtime-discovery.mjs +61 -13
- package/src/engine/worker-env.mjs +179 -0
- package/src/harnesses/agy/index.mjs +11 -0
- package/src/harnesses/catalogue.mjs +54 -2
- package/src/harnesses/claude/index.mjs +177 -2
- package/src/harnesses/codex/index.mjs +15 -0
- package/src/harnesses/dsh/index.mjs +18 -116
- package/src/harnesses/dsh/runner.mjs +1 -17
- package/src/harnesses/exec-jsonl/index.mjs +12 -0
- package/src/harnesses/fx/home.mjs +57 -0
- package/src/harnesses/fx/index.mjs +137 -0
- package/src/harnesses/fx/native/build.zig +20 -0
- package/src/harnesses/fx/native/build.zig.zon +7 -0
- package/src/harnesses/fx/native/home.zig +85 -0
- package/src/harnesses/fx/native/main.zig +503 -0
- package/src/harnesses/fx/native/permissions.zig +51 -0
- package/src/harnesses/fx/native/relay.zig +253 -0
- package/src/harnesses/fx/native/usage.zig +112 -0
- package/src/harnesses/fx/permissions.mjs +35 -0
- package/src/harnesses/fx/runner.mjs +350 -0
- package/src/harnesses/fx/usage-proxy.mjs +176 -0
- package/src/harnesses/index.mjs +29 -14
- package/src/harnesses/replay/index.mjs +12 -0
- package/src/harnesses/runner-io.mjs +25 -0
- package/src/harnesses/runner-transcript.mjs +127 -0
- package/src/harnesses/session-metrics.mjs +38 -13
- package/src/harnesses/zcode/index.mjs +19 -0
- package/src/host/config.mjs +8 -2
- package/src/host/environment-report.mjs +85 -0
- package/src/host/home.mjs +27 -0
- package/src/host/install-registry.mjs +511 -0
- package/src/host/preflight.mjs +34 -10
- package/src/host/uninstall-roots.mjs +196 -0
- package/src/plan/proof-check.mjs +15 -0
- package/src/plan/repo-facts.mjs +95 -11
- package/src/plan/rounds.mjs +27 -0
- package/src/repo/declared-paths.mjs +74 -6
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +59 -0
- package/src/repo/signal-block.mjs +26 -0
- package/src/repo/signal.mjs +9 -24
- package/src/repo/workspace.mjs +189 -14
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/message.mjs +37 -6
- package/src/report/progress.mjs +13 -2
- package/src/run/paths.mjs +14 -1
- package/src/run/usage.mjs +66 -1
- 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
|
+
}
|
package/src/harnesses/index.mjs
CHANGED
|
@@ -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 {
|
|
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.
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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
|
-
|
|
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
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
|
|
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",
|