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
|
@@ -5,6 +5,14 @@ import { canonicalProvider } from "../contract/provider.mjs";
|
|
|
5
5
|
import { errorMessage } from "../util.mjs";
|
|
6
6
|
import { ANTHROPIC_MODEL_TIERS } from "./model-tiers.mjs";
|
|
7
7
|
import { spawnInvocation } from "../host/platform.mjs";
|
|
8
|
+
import { declaredEnvironment as claudeEnvironment } from "./claude/index.mjs";
|
|
9
|
+
import { declaredEnvironment as codexEnvironment } from "./codex/index.mjs";
|
|
10
|
+
import { declaredEnvironment as agyEnvironment } from "./agy/index.mjs";
|
|
11
|
+
import { declaredEnvironment as dshEnvironment } from "./dsh/index.mjs";
|
|
12
|
+
import { declaredEnvironment as fxEnvironment } from "./fx/index.mjs";
|
|
13
|
+
import { declaredEnvironment as zcodeEnvironment } from "./zcode/index.mjs";
|
|
14
|
+
import { declaredEnvironment as execJsonlEnvironment } from "./exec-jsonl/index.mjs";
|
|
15
|
+
import { declaredEnvironment as replayEnvironment } from "./replay/index.mjs";
|
|
8
16
|
|
|
9
17
|
/**
|
|
10
18
|
* Model catalogue report: which models each registered harness can run, the
|
|
@@ -26,17 +34,39 @@ import { spawnInvocation } from "../host/platform.mjs";
|
|
|
26
34
|
* opt-in that reads the host.
|
|
27
35
|
*/
|
|
28
36
|
|
|
29
|
-
/** Display order of the registered harnesses: claude, codex, agy, dsh, zcode, exec-jsonl, replay. */
|
|
37
|
+
/** Display order of the registered harnesses: claude, codex, agy, dsh, fx, zcode, exec-jsonl, replay. */
|
|
30
38
|
export const MODEL_HARNESS_ORDER = Object.freeze([
|
|
31
39
|
"claude",
|
|
32
40
|
"codex",
|
|
33
41
|
"agy",
|
|
34
42
|
"dsh",
|
|
43
|
+
"fx",
|
|
35
44
|
"zcode",
|
|
36
45
|
"exec-jsonl",
|
|
37
46
|
"replay",
|
|
38
47
|
]);
|
|
39
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The environment names each registered adapter reads to authenticate and
|
|
51
|
+
* configure itself, keyed by harness in `MODEL_HARNESS_ORDER`. Each list is the
|
|
52
|
+
* adapter's own frozen `declaredEnvironment` export, and only bare names ever
|
|
53
|
+
* travel here, never values. The worker/judge environment builder unions these
|
|
54
|
+
* names with the base operating-system set and the runtime's env keys, so a
|
|
55
|
+
* name missing from this catalogue is a name the child process never receives.
|
|
56
|
+
*
|
|
57
|
+
* @type {Readonly<Record<string, readonly string[]>>}
|
|
58
|
+
*/
|
|
59
|
+
export const DECLARED_HARNESS_ENVIRONMENTS = Object.freeze({
|
|
60
|
+
claude: claudeEnvironment,
|
|
61
|
+
codex: codexEnvironment,
|
|
62
|
+
agy: agyEnvironment,
|
|
63
|
+
dsh: dshEnvironment,
|
|
64
|
+
fx: fxEnvironment,
|
|
65
|
+
zcode: zcodeEnvironment,
|
|
66
|
+
"exec-jsonl": execJsonlEnvironment,
|
|
67
|
+
replay: replayEnvironment,
|
|
68
|
+
});
|
|
69
|
+
|
|
40
70
|
const CLAUDE_EFFORTS = Object.freeze(["low", "medium", "high", "max"]);
|
|
41
71
|
const CODEX_EFFORTS = Object.freeze(["low", "medium", "high", "xhigh"]);
|
|
42
72
|
const AGY_EFFORTS = Object.freeze(["low", "medium", "high"]);
|
|
@@ -78,6 +108,11 @@ export const DECLARED_MODEL_CATALOGUES = Object.freeze({
|
|
|
78
108
|
declaredModel("claude-fable-5", { efforts: CLAUDE_EFFORTS }),
|
|
79
109
|
declaredModel("claude-fable-5-1", { efforts: CLAUDE_EFFORTS }),
|
|
80
110
|
declaredModel("claude-sonnet-4-6", { efforts: CLAUDE_EFFORTS }),
|
|
111
|
+
// GLM through a runtime whose `config.base_url` names Z.ai's
|
|
112
|
+
// Anthropic-compatible endpoint. The CLI sized its own window at 200,000
|
|
113
|
+
// tokens for this model (2026-09-25 result event), and `--effort` is a
|
|
114
|
+
// Claude-model flag, so no effort is declared.
|
|
115
|
+
declaredModel("glm-5.3", { contextWindowTokens: 200_000, efforts: NO_EFFORTS }),
|
|
81
116
|
]),
|
|
82
117
|
codex: Object.freeze([
|
|
83
118
|
declaredModel("gpt-5.6", { efforts: CODEX_EFFORTS }),
|
|
@@ -98,6 +133,20 @@ export const DECLARED_MODEL_CATALOGUES = Object.freeze({
|
|
|
98
133
|
declaredModel("deepseek-v4-pro", { contextWindowTokens: DSH_CONTEXT_WINDOW_TOKENS, efforts: DSH_EFFORTS, defaultEffort: "high" }),
|
|
99
134
|
declaredModel("deepseek-v4-flash-vision-exp", { contextWindowTokens: DSH_CONTEXT_WINDOW_TOKENS, efforts: DSH_EFFORTS, defaultEffort: "high" }),
|
|
100
135
|
]),
|
|
136
|
+
// The two ids api.deepseek.com/models listed on 2026-09-24. No efforts:
|
|
137
|
+
// fx's settings carry one `effort` for the Gateway catalogue and send
|
|
138
|
+
// nothing a custom Chat Completions connection is known to honour.
|
|
139
|
+
fx: Object.freeze([
|
|
140
|
+
declaredModel("deepseek-flash", { contextWindowTokens: DSH_CONTEXT_WINDOW_TOKENS, efforts: NO_EFFORTS }),
|
|
141
|
+
declaredModel("deepseek-v4-pro", { contextWindowTokens: DSH_CONTEXT_WINDOW_TOKENS, efforts: NO_EFFORTS }),
|
|
142
|
+
// GLM through a Z.ai Chat Completions endpoint named by `config.base_url`.
|
|
143
|
+
declaredModel("glm-5.3", { contextWindowTokens: GLM_CONTEXT_WINDOW_TOKENS, efforts: NO_EFFORTS }),
|
|
144
|
+
// GPT through fx's ChatGPT login (`config.provider: "codex"`). Measured
|
|
145
|
+
// 2026-09-26: fx listed these among the account's seven models, and both
|
|
146
|
+
// answered a review turn through the runner.
|
|
147
|
+
declaredModel("gpt-5.6-sol", { efforts: NO_EFFORTS }),
|
|
148
|
+
declaredModel("gpt-6-sol", { efforts: NO_EFFORTS }),
|
|
149
|
+
]),
|
|
101
150
|
zcode: Object.freeze([
|
|
102
151
|
declaredModel("glm-5.3-flash", { contextWindowTokens: GLM_CONTEXT_WINDOW_TOKENS, efforts: NO_EFFORTS }),
|
|
103
152
|
// No `[1m]` row: the suffix is a Claude Code convention the ZCode CLI does
|
|
@@ -115,6 +164,7 @@ const CATALOGUE_SOURCES = Object.freeze({
|
|
|
115
164
|
claude: "declared",
|
|
116
165
|
codex: "declared",
|
|
117
166
|
dsh: "declared",
|
|
167
|
+
fx: "declared",
|
|
118
168
|
zcode: "declared",
|
|
119
169
|
"exec-jsonl": "runtime-declared",
|
|
120
170
|
replay: "runtime-declared",
|
|
@@ -130,18 +180,20 @@ const EFFORT_TRANSPORT = Object.freeze({
|
|
|
130
180
|
codex: "config.model_reasoning_effort",
|
|
131
181
|
agy: "--effort",
|
|
132
182
|
dsh: "--reasoning (harness reasoningEffort)",
|
|
183
|
+
fx: null,
|
|
133
184
|
zcode: null,
|
|
134
185
|
"exec-jsonl": null,
|
|
135
186
|
replay: null,
|
|
136
187
|
});
|
|
137
188
|
|
|
138
189
|
/**
|
|
139
|
-
* Why a harness resolves no vendor: dsh, exec-jsonl, and replay declare none.
|
|
190
|
+
* Why a harness resolves no vendor: dsh, fx, exec-jsonl, and replay declare none.
|
|
140
191
|
*
|
|
141
192
|
* @type {Readonly<Record<string, string>>}
|
|
142
193
|
*/
|
|
143
194
|
const VENDOR_NOTES = Object.freeze({
|
|
144
195
|
dsh: "unresolved: dsh declares no default vendor; the contract names it",
|
|
196
|
+
fx: "unresolved: fx declares no default vendor; the contract names it",
|
|
145
197
|
"exec-jsonl": "unresolved: exec-jsonl declares no default vendor; the contract names it",
|
|
146
198
|
replay: "unresolved: replay declares no default vendor; the contract names it",
|
|
147
199
|
});
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { normalizeClaudeResult, parseVersion } from "../protocol.mjs";
|
|
2
2
|
import { hookSettings } from "../../host/tool-policy-hook.mjs";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The environment names the Claude adapter reads to authenticate and configure
|
|
6
|
+
* the CLI: the provider credential the CLI resolves for API-key sign-in and the
|
|
7
|
+
* binary override `executable()` honours. Values never travel here.
|
|
8
|
+
*
|
|
9
|
+
* @type {readonly string[]}
|
|
10
|
+
*/
|
|
11
|
+
export const declaredEnvironment = Object.freeze([
|
|
12
|
+
"ANTHROPIC_API_KEY",
|
|
13
|
+
"FABERUN_CLAUDE_BIN",
|
|
14
|
+
]);
|
|
15
|
+
|
|
4
16
|
/** Built-in tools a closed-packet worker needs; every other tool is preamble. */
|
|
5
17
|
export const DEFAULT_CLAUDE_TOOLS = ["Read", "Edit", "Write", "Bash", "Glob", "Grep"];
|
|
6
18
|
|
|
@@ -26,6 +38,152 @@ function claudePreambleArgs(runtime) {
|
|
|
26
38
|
];
|
|
27
39
|
}
|
|
28
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The envelope this adapter returns: the shared provider envelope plus the
|
|
43
|
+
* top-level reset instant a session limit names. The field has to live here
|
|
44
|
+
* rather than in `error` so that `exhaustedUntilOf` keeps parsing the sentence
|
|
45
|
+
* against the caller's clock.
|
|
46
|
+
*
|
|
47
|
+
* @typedef {import("../index.mjs").ProviderEnvelope & {resetAt?: string}} ClaudeEnvelope
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The reset sentence a Claude quota stop writes into its error message, e.g.
|
|
52
|
+
* "You've hit your session limit · resets 6:40pm (America/Sao_Paulo)". The
|
|
53
|
+
* wall-clock form names a time and a zone but no date; an absolute form names
|
|
54
|
+
* the instant directly ("Your limit will reset at 2026-09-08 20:30:00").
|
|
55
|
+
*/
|
|
56
|
+
const SESSION_RESET_SENTENCE = /resets?(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(([A-Za-z_]+(?:\/[A-Za-z0-9_+-]+)+|UTC|GMT)\)/iu;
|
|
57
|
+
const ABSOLUTE_RESET_SENTENCE = /reset(?:s| at| on)?\s+(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:?\d{2})?)/iu;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The instant a Claude quota stop names, or null when its message names none.
|
|
61
|
+
*
|
|
62
|
+
* The generic availability classifier (`harnesses/index.mjs`'s
|
|
63
|
+
* `exhaustedUntilOf`) parses the same sentence for the durable refusal store,
|
|
64
|
+
* but the node's own wait decision (`engine/backoff.mjs`'s
|
|
65
|
+
* `quotaResetSchedule`) reads only a structured `resetAt`. This adapter is the
|
|
66
|
+
* protocol boundary and cannot import `harnesses/index.mjs` -- that module
|
|
67
|
+
* imports this one at load -- so the sentence is parsed here. A zone the
|
|
68
|
+
* runtime does not know names no instant, and the failover edge is taken as
|
|
69
|
+
* before.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} message
|
|
72
|
+
* @param {number} [now] the instant a wall-clock sentence is read against
|
|
73
|
+
* @returns {string|null}
|
|
74
|
+
*/
|
|
75
|
+
function sessionResetAt(message, now = Date.now()) {
|
|
76
|
+
const absolute = ABSOLUTE_RESET_SENTENCE.exec(message);
|
|
77
|
+
if (absolute) {
|
|
78
|
+
const value = absolute[1];
|
|
79
|
+
const zoned = value.includes("T") || /(?:Z|[+-]\d{2}:?\d{2})$/u.test(value) ? value : `${value.replace(" ", "T")}Z`;
|
|
80
|
+
const parsed = Date.parse(zoned);
|
|
81
|
+
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
|
|
82
|
+
}
|
|
83
|
+
const match = SESSION_RESET_SENTENCE.exec(message);
|
|
84
|
+
if (!match) return null;
|
|
85
|
+
let hour = Number(match[1]);
|
|
86
|
+
const minute = Number(match[2] ?? "0");
|
|
87
|
+
const meridiem = match[3]?.toLowerCase();
|
|
88
|
+
if (meridiem === "pm" && hour < 12) hour += 12;
|
|
89
|
+
if (meridiem === "am" && hour === 12) hour = 0;
|
|
90
|
+
if (hour > 23 || minute > 59) return null;
|
|
91
|
+
/** @type {Intl.DateTimeFormatPart[]} */
|
|
92
|
+
let parts;
|
|
93
|
+
try {
|
|
94
|
+
parts = new Intl.DateTimeFormat("en-US", { timeZone: match[4], hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }).formatToParts(new Date(now));
|
|
95
|
+
} catch {
|
|
96
|
+
// An unknown zone name is not a reset time; the caller falls back to failover.
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const at = (/** @type {Intl.DateTimeFormatPartTypes} */ type) => Number(parts.find((part) => part.type === type)?.value);
|
|
100
|
+
const offset = Date.UTC(at("year"), at("month") - 1, at("day"), at("hour") % 24, at("minute"), at("second")) - Math.floor(now / 1000) * 1000;
|
|
101
|
+
let target = Date.UTC(at("year"), at("month") - 1, at("day"), hour, minute) - offset;
|
|
102
|
+
if (target <= now) target += 86_400_000;
|
|
103
|
+
return new Date(target).toISOString();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Thread a session-limit reset into the envelope so the node can wait for it.
|
|
108
|
+
* The instant rides the envelope's top-level `resetAt`, the field the node's
|
|
109
|
+
* wait decision (`engine/backoff.mjs`'s `quotaResetSchedule`) reads. It is not
|
|
110
|
+
* written into `error.resetAt`/`exhaustedUntil` on purpose: `exhaustedUntilOf`
|
|
111
|
+
* must keep parsing the sentence against the clock its caller passes, so a test
|
|
112
|
+
* or a replayed envelope can re-read the same message at a chosen instant.
|
|
113
|
+
*
|
|
114
|
+
* @param {ClaudeEnvelope} envelope
|
|
115
|
+
* @returns {ClaudeEnvelope}
|
|
116
|
+
*/
|
|
117
|
+
function withSessionReset(envelope) {
|
|
118
|
+
if (envelope.status !== "exhausted" || envelope.resetAt) return envelope;
|
|
119
|
+
const resetAt = sessionResetAt(String(envelope.error?.message ?? ""));
|
|
120
|
+
return resetAt ? { ...envelope, resetAt } : envelope;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The environment one runtime's CLI runs under when its contract points it at
|
|
125
|
+
* an Anthropic-compatible endpoint other than Anthropic's own, e.g. GLM on
|
|
126
|
+
* the Z.ai Coding Plan, where Claude Code is one of the supported tools:
|
|
127
|
+
*
|
|
128
|
+
* "config": { "base_url": "https://api.z.ai/api/anthropic",
|
|
129
|
+
* "auth_token.env_key": "ZAI_API_KEY" }
|
|
130
|
+
*
|
|
131
|
+
* The overlay travels in the invocation's own command, so it reaches only
|
|
132
|
+
* this runtime's process: a controller that also runs a Claude judge on
|
|
133
|
+
* Anthropic keeps that judge on Anthropic. The token is read from the named
|
|
134
|
+
* variable at invocation time; its value never appears in the contract.
|
|
135
|
+
*
|
|
136
|
+
* A declared endpoint with no resolvable token is refused rather than run:
|
|
137
|
+
* the CLI would fall back to the operator's own Anthropic login and send that
|
|
138
|
+
* credential to the declared host. Measured 2026-09-25 on the tiny-text
|
|
139
|
+
* campaign: two parallel GLM 5.3 judges through this CLI peaked at 599 MB,
|
|
140
|
+
* where the same judges on zcode had peaked at about 1.5 GB together with
|
|
141
|
+
* the test processes.
|
|
142
|
+
*
|
|
143
|
+
* @param {import("../index.mjs").HarnessRuntime} runtime
|
|
144
|
+
* @returns {Record<string, string|null>|undefined}
|
|
145
|
+
*/
|
|
146
|
+
function endpointEnv(runtime) {
|
|
147
|
+
const baseUrl = declaredBaseUrl(runtime);
|
|
148
|
+
const declared = runtime.config?.["auth_token.env_key"];
|
|
149
|
+
const tokenVar = typeof declared === "string" && declared ? declared : null;
|
|
150
|
+
if (baseUrl === null && tokenVar === null) return undefined;
|
|
151
|
+
const token = tokenVar === null ? undefined : process.env[tokenVar];
|
|
152
|
+
if (typeof token !== "string" || !token) {
|
|
153
|
+
const error = /** @type {Error & {code: string}} */ (new Error(tokenVar === null
|
|
154
|
+
? `claude runtime declares base_url ${baseUrl} without auth_token.env_key`
|
|
155
|
+
: `claude runtime declares auth_token.env_key ${tokenVar}, which is unset`));
|
|
156
|
+
error.code = "auth_token_unresolved";
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
/** @type {Record<string, string|null>} */
|
|
160
|
+
const env = {
|
|
161
|
+
ANTHROPIC_AUTH_TOKEN: token,
|
|
162
|
+
// An ambient Anthropic key outranks the bearer token in the CLI.
|
|
163
|
+
ANTHROPIC_API_KEY: null,
|
|
164
|
+
};
|
|
165
|
+
if (baseUrl !== null) {
|
|
166
|
+
env.ANTHROPIC_BASE_URL = baseUrl;
|
|
167
|
+
// The CLI sends its own background requests (titles, summaries) to the
|
|
168
|
+
// alias models; another endpoint does not serve those names, so every
|
|
169
|
+
// alias resolves to the runtime's model.
|
|
170
|
+
env.ANTHROPIC_DEFAULT_OPUS_MODEL = runtime.model;
|
|
171
|
+
env.ANTHROPIC_DEFAULT_SONNET_MODEL = runtime.model;
|
|
172
|
+
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = runtime.model;
|
|
173
|
+
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
174
|
+
}
|
|
175
|
+
return env;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {{config?: Record<string, unknown>}} runtime
|
|
180
|
+
* @returns {string|null}
|
|
181
|
+
*/
|
|
182
|
+
function declaredBaseUrl(runtime) {
|
|
183
|
+
const value = runtime.config?.base_url;
|
|
184
|
+
return typeof value === "string" && value ? value : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
29
187
|
/**
|
|
30
188
|
* @type {import("../index.mjs").HarnessAdapter}
|
|
31
189
|
*/
|
|
@@ -93,10 +251,27 @@ export const claudeHarness = {
|
|
|
93
251
|
// a kill) for the one harness that can take it as a flag.
|
|
94
252
|
if (typeof options.maxTurns === "number") args.push("--max-turns", String(options.maxTurns));
|
|
95
253
|
if (options.schema) args.push("--json-schema", JSON.stringify(options.schema));
|
|
96
|
-
|
|
254
|
+
const env = endpointEnv(runtime);
|
|
255
|
+
return { executable: this.executable(runtime), args, promptTransport: "stdin", input: prompt, ...(env ? { env } : {}) };
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The CLI's `total_cost_usd` applies Anthropic's rates to whatever model
|
|
260
|
+
* answered, so on another endpoint it is not what the turn cost: measured
|
|
261
|
+
* 2026-09-25, a GLM 5.3 review on the Z.ai Coding Plan reported $0.13.
|
|
262
|
+
* Such a runtime is priced like any harness that reports no cost.
|
|
263
|
+
*
|
|
264
|
+
* @param {{harness: string, config?: Record<string, unknown>}} runtime
|
|
265
|
+
* @returns {boolean}
|
|
266
|
+
*/
|
|
267
|
+
reportsCost(runtime) {
|
|
268
|
+
return declaredBaseUrl(runtime) === null;
|
|
97
269
|
},
|
|
98
270
|
|
|
99
|
-
|
|
271
|
+
/** @param {string} stdout @param {number|null} exitCode @param {string|null} signal @param {import("../index.mjs").NormalizeOptions} [options] @returns {import("../index.mjs").ProviderEnvelope} */
|
|
272
|
+
normalize(stdout, exitCode, signal, options = {}) {
|
|
273
|
+
return withSessionReset(normalizeClaudeResult(stdout, exitCode, signal, options));
|
|
274
|
+
},
|
|
100
275
|
};
|
|
101
276
|
|
|
102
277
|
export const harness = claudeHarness;
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { normalizeCodexResult, parseVersion, toml } from "../protocol.mjs";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The environment names the Codex adapter reads to authenticate and configure
|
|
5
|
+
* the CLI: the provider credential for API-key sign-in, the config home
|
|
6
|
+
* `codexHome()` reads when it locates the session file its usage windows come
|
|
7
|
+
* from, and the binary override `executable()` honours. Values never travel
|
|
8
|
+
* here.
|
|
9
|
+
*
|
|
10
|
+
* @type {readonly string[]}
|
|
11
|
+
*/
|
|
12
|
+
export const declaredEnvironment = Object.freeze([
|
|
13
|
+
"CODEX_HOME",
|
|
14
|
+
"FABERUN_CODEX_BIN",
|
|
15
|
+
"OPENAI_API_KEY",
|
|
16
|
+
]);
|
|
17
|
+
|
|
3
18
|
/**
|
|
4
19
|
* Bound the Codex harness preamble: a closed-packet worker or a read-only judge
|
|
5
20
|
* needs the shell and patch tools, not browser, computer-use, app or sub-agent
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { dirname, join } from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import {
|
|
3
|
+
import { parseVersion } from "../protocol.mjs";
|
|
4
|
+
import { normalizeRunnerTranscript, runnerEnvironmentOverlay, withSchema } from "../runner-transcript.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The environment names the dsh adapter reads to authenticate and configure the
|
|
8
|
+
* harness: the provider credential the dsh CLI resolves for the declared
|
|
9
|
+
* provider and the binary override `executable()` honours. The environment
|
|
10
|
+
* overlay's `GIT_CONFIG_*` scrub is a generic pass over ambient keys, not a
|
|
11
|
+
* declared dependency. Values never travel here.
|
|
12
|
+
*
|
|
13
|
+
* @type {readonly string[]}
|
|
14
|
+
*/
|
|
15
|
+
export const declaredEnvironment = Object.freeze([
|
|
16
|
+
"DEEPSEEK_API_KEY",
|
|
17
|
+
"FABERUN_DSH_BIN",
|
|
18
|
+
]);
|
|
4
19
|
|
|
5
20
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
6
21
|
|
|
@@ -103,7 +118,7 @@ export const dshHarness = {
|
|
|
103
118
|
args,
|
|
104
119
|
promptTransport: "stdin",
|
|
105
120
|
input: withSchema(prompt, options.schema),
|
|
106
|
-
env:
|
|
121
|
+
env: runnerEnvironmentOverlay(process.env),
|
|
107
122
|
};
|
|
108
123
|
},
|
|
109
124
|
|
|
@@ -115,122 +130,9 @@ export const dshHarness = {
|
|
|
115
130
|
* @returns {import("../index.mjs").ProviderEnvelope}
|
|
116
131
|
*/
|
|
117
132
|
normalize(stdout, exitCode, signal, options = {}) {
|
|
118
|
-
|
|
119
|
-
let events;
|
|
120
|
-
try {
|
|
121
|
-
events = parseJsonLines(stdout, "dsh");
|
|
122
|
-
} catch (error) {
|
|
123
|
-
return failed("invalid_protocol", error instanceof Error ? error.message : String(error));
|
|
124
|
-
}
|
|
125
|
-
const terminal = events.findLast((event) => event.type === "dsh.completed" || event.type === "dsh.failed");
|
|
126
|
-
if (!terminal) {
|
|
127
|
-
const detail = options.stderr?.trim();
|
|
128
|
-
return failed(
|
|
129
|
-
"incomplete_stream",
|
|
130
|
-
`dsh emitted no terminal event${detail ? `: ${detail.slice(-512)}` : ""}${exitCode === null ? "" : ` (exit ${exitCode})`}`,
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
const usage = canonicalUsage(terminal.usage);
|
|
134
|
-
if (terminal.type === "dsh.completed") {
|
|
135
|
-
const text = typeof terminal.result === "string" ? terminal.result : null;
|
|
136
|
-
const result = options.preferStructured ? extractJson(text) ?? text : text;
|
|
137
|
-
const verdicts = options.preferStructured ? countVerdicts(events) : null;
|
|
138
|
-
return {
|
|
139
|
-
status: result?.trim() ? "done" : "no-op",
|
|
140
|
-
result,
|
|
141
|
-
continuationId: null,
|
|
142
|
-
usage,
|
|
143
|
-
costUsd: null,
|
|
144
|
-
error: null,
|
|
145
|
-
...(verdicts === null ? {} : { judgeCandidates: verdicts }),
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
const error = terminal.error && typeof terminal.error === "object" ? /** @type {Record<string, unknown>} */ (terminal.error) : {};
|
|
149
|
-
const kind = typeof terminal.kind === "string" ? terminal.kind : "error";
|
|
150
|
-
const code = typeof error.code === "string" && error.code ? error.code : kind;
|
|
151
|
-
const message = typeof error.message === "string" && error.message ? error.message : code;
|
|
152
|
-
const resetAt = resetTimestamp(error.retryAfterMs);
|
|
153
|
-
return {
|
|
154
|
-
status: statusFor(kind, `${code} ${message}`),
|
|
155
|
-
result: null,
|
|
156
|
-
continuationId: null,
|
|
157
|
-
usage,
|
|
158
|
-
costUsd: null,
|
|
159
|
-
error: { code, message, ...(resetAt ? { resetAt } : {}) },
|
|
160
|
-
...(resetAt ? { exhaustedUntil: resetAt } : {}),
|
|
161
|
-
};
|
|
133
|
+
return normalizeRunnerTranscript("dsh", stdout, exitCode, signal, options);
|
|
162
134
|
},
|
|
163
135
|
};
|
|
164
136
|
|
|
165
|
-
/**
|
|
166
|
-
* The environment overlay every dsh turn spawns with. The Claude Code shell
|
|
167
|
-
* exports `GIT_CONFIG_COUNT` with the VALUE half of each `GIT_CONFIG_{KEY,VALUE}_<n>`
|
|
168
|
-
* pair but not the key half, so `git init` inside the worker fails with status
|
|
169
|
-
* 128; the overlay removes the whole family. `GIT_TERMINAL_PROMPT=0` takes over
|
|
170
|
-
* the prompting that family was for. A null value removes the ambient variable
|
|
171
|
-
* when the gate merges the overlay over the runner environment.
|
|
172
|
-
*
|
|
173
|
-
* @param {NodeJS.ProcessEnv} env
|
|
174
|
-
* @returns {Record<string, string|null>}
|
|
175
|
-
*/
|
|
176
|
-
function dshEnvironmentOverlay(env) {
|
|
177
|
-
/** @type {Record<string, string|null>} */
|
|
178
|
-
const overlay = { GIT_TERMINAL_PROMPT: "0" };
|
|
179
|
-
for (const key of Object.keys(env)) {
|
|
180
|
-
if (key === "GIT_CONFIG_COUNT" || /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/u.test(key)) overlay[key] = null;
|
|
181
|
-
}
|
|
182
|
-
return overlay;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Append the output schema the judge prompt refers to. Codex and Claude receive
|
|
187
|
-
* it through a native flag; this harness has none, so it travels in the prompt.
|
|
188
|
-
*
|
|
189
|
-
* @param {string} prompt
|
|
190
|
-
* @param {object|undefined} schema
|
|
191
|
-
* @returns {string}
|
|
192
|
-
*/
|
|
193
|
-
function withSchema(prompt, schema) {
|
|
194
|
-
if (!schema) return prompt;
|
|
195
|
-
return `${prompt}\n\nOutput schema (the JSON object must validate against it):\n${JSON.stringify(schema)}`;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Count the verdict-shaped final messages, the way the codex adapter does: two
|
|
200
|
-
* verdicts in one turn must be visible at the provider boundary, because the
|
|
201
|
-
* last one alone is indistinguishable from a single clean answer.
|
|
202
|
-
*
|
|
203
|
-
* @param {Record<string, unknown>[]} events
|
|
204
|
-
* @returns {number}
|
|
205
|
-
*/
|
|
206
|
-
function countVerdicts(events) {
|
|
207
|
-
return events.filter((event) => event.type === "dsh.message" && isVerdictCandidate(event.text)).length;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* @param {string} kind
|
|
212
|
-
* @param {string} text
|
|
213
|
-
* @returns {"done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled"}
|
|
214
|
-
*/
|
|
215
|
-
function statusFor(kind, text) {
|
|
216
|
-
if (kind === "aborted") return "canceled";
|
|
217
|
-
if (kind === "blocked") return "blocked";
|
|
218
|
-
if (isQuotaText(text) || /insufficient balance/iu.test(text)) return "exhausted";
|
|
219
|
-
if (/permission|approval|sandbox/iu.test(text)) return "blocked";
|
|
220
|
-
return "failed";
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Turn the harness's relative retry hint into the absolute instant the
|
|
225
|
-
* controller needs; without one it takes the failover edge instead of waiting.
|
|
226
|
-
*
|
|
227
|
-
* @param {unknown} retryAfterMs
|
|
228
|
-
* @returns {string|null}
|
|
229
|
-
*/
|
|
230
|
-
function resetTimestamp(retryAfterMs) {
|
|
231
|
-
if (typeof retryAfterMs !== "number" || !Number.isFinite(retryAfterMs) || retryAfterMs <= 0) return null;
|
|
232
|
-
return new Date(Date.now() + retryAfterMs).toISOString();
|
|
233
|
-
}
|
|
234
|
-
|
|
235
137
|
export const harness = dshHarness;
|
|
236
138
|
export default dshHarness;
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { spawn } from "node:child_process";
|
|
26
26
|
import { spawnInvocation } from "../../host/platform.mjs";
|
|
27
|
-
import {
|
|
27
|
+
import { emit, readPrompt } from "../runner-io.mjs";
|
|
28
28
|
|
|
29
29
|
const HANDSHAKE_ID = 1;
|
|
30
30
|
const PROMPT_ID = 2;
|
|
@@ -65,22 +65,6 @@ function parseArgs(argv) {
|
|
|
65
65
|
return options;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/**
|
|
69
|
-
* Whole-line writes to fd 1: `process.exit` cannot lose an unfinished write.
|
|
70
|
-
*
|
|
71
|
-
* @param {Record<string, unknown>} event
|
|
72
|
-
*/
|
|
73
|
-
function emit(event) {
|
|
74
|
-
const line = Buffer.from(`${JSON.stringify(event)}\n`, "utf8");
|
|
75
|
-
for (let written = 0; written < line.length; ) written += writeSync(1, line, written);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function readPrompt() {
|
|
79
|
-
const chunks = [];
|
|
80
|
-
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
81
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
68
|
/**
|
|
85
69
|
* @param {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number}} totals
|
|
86
70
|
* @param {unknown} usage
|
|
@@ -31,6 +31,18 @@ import { finite } from "../../util.mjs";
|
|
|
31
31
|
|
|
32
32
|
export { HARNESS_OUTPUT_LIMIT_BYTES } from "../protocol.mjs";
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The environment names the exec-jsonl adapter reads to authenticate and
|
|
36
|
+
* configure the wrapper: only the binary override `executable()` honours.
|
|
37
|
+
* Credentials belong to the wrapper the contract names, so the adapter reads
|
|
38
|
+
* none of them. Values never travel here.
|
|
39
|
+
*
|
|
40
|
+
* @type {readonly string[]}
|
|
41
|
+
*/
|
|
42
|
+
export const declaredEnvironment = Object.freeze([
|
|
43
|
+
"FABERUN_EXEC_JSONL_BIN",
|
|
44
|
+
]);
|
|
45
|
+
|
|
34
46
|
export const EXEC_JSONL_PROTOCOL = Object.freeze({
|
|
35
47
|
schemaVersion: 1,
|
|
36
48
|
requestType: "run.request",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HOME an fx worker runs under. fx reads provider connections only from
|
|
3
|
+
* `$HOME/.fx/settings.json` and has no flag or variable that names another
|
|
4
|
+
* settings file, so pointing a worker at its own usage relay means giving it
|
|
5
|
+
* its own HOME. The same boundary closes the packet: fx loads every skill it
|
|
6
|
+
* finds under `~/.fx`, `~/.claude`, `~/.codex`, `~/.agents` and
|
|
7
|
+
* `~/.config/opencode` (fx 0.0.11 `skill_runtime.zig`), none of which a closed
|
|
8
|
+
* task packet asked for.
|
|
9
|
+
*
|
|
10
|
+
* Everything else in the operator's HOME is linked in, because the worker's
|
|
11
|
+
* shell inherits this HOME: git identity, npm and toolchain caches, and
|
|
12
|
+
* version managers keep resolving to the real files.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdirSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
/** Top-level HOME entries fx scans for skills or reads as its own profile. */
|
|
19
|
+
const WITHHELD = new Set([".fx", ".claude", ".codex", ".agents"]);
|
|
20
|
+
/** Entries under `~/.config` fx scans for skills. */
|
|
21
|
+
const WITHHELD_CONFIG = new Set(["opencode"]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} realHome
|
|
25
|
+
* @param {string} home an empty directory the caller owns and removes
|
|
26
|
+
* @param {Record<string, unknown>} settings the complete fx settings document
|
|
27
|
+
*/
|
|
28
|
+
export function prepareFxHome(realHome, home, settings) {
|
|
29
|
+
linkEntries(realHome, home, WITHHELD, ".config");
|
|
30
|
+
const config = join(home, ".config");
|
|
31
|
+
mkdirSync(config, { recursive: true });
|
|
32
|
+
linkEntries(join(realHome, ".config"), config, WITHHELD_CONFIG, null);
|
|
33
|
+
// fx on Linux refuses a profile directory others can read
|
|
34
|
+
// (`private_state_permissions_unsupported`); macOS did not check.
|
|
35
|
+
mkdirSync(join(home, ".fx"), { recursive: true, mode: 0o700 });
|
|
36
|
+
writeFileSync(join(home, ".fx", "settings.json"), `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} from
|
|
41
|
+
* @param {string} to
|
|
42
|
+
* @param {Set<string>} withheld
|
|
43
|
+
* @param {string|null} rebuilt an entry the caller recreates as a real directory
|
|
44
|
+
*/
|
|
45
|
+
function linkEntries(from, to, withheld, rebuilt) {
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = readdirSync(from);
|
|
49
|
+
} catch {
|
|
50
|
+
// An operator without `~/.config` has nothing to link from it.
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (withheld.has(entry) || entry === rebuilt) continue;
|
|
55
|
+
symlinkSync(join(from, entry), join(to, entry));
|
|
56
|
+
}
|
|
57
|
+
}
|