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.
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/cli/setup.mjs +1 -1
- package/src/cli.mjs +1 -1
- package/src/contract/index.mjs +1 -1
- package/src/contract/runtime.mjs +5 -4
- package/src/engine/live-preflight.mjs +1 -1
- package/src/engine/runtime-discovery.mjs +46 -4
- package/src/harnesses/catalogue.mjs +25 -2
- package/src/harnesses/claude/index.mjs +84 -3
- package/src/harnesses/dsh/index.mjs +4 -116
- package/src/harnesses/dsh/runner.mjs +1 -17
- package/src/harnesses/fx/home.mjs +57 -0
- package/src/harnesses/fx/index.mjs +124 -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 +19 -4
- 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/host/preflight.mjs +2 -1
- package/src/plan/template.mjs +22 -0
package/README.md
CHANGED
|
@@ -273,10 +273,11 @@ routing policy.
|
|
|
273
273
|
|
|
274
274
|
| Harness | Default vendor | Example model |
|
|
275
275
|
| --- | --- | --- |
|
|
276
|
-
| `claude` | Anthropic | `claude-sonnet` |
|
|
276
|
+
| `claude` | Anthropic; another Anthropic-compatible endpoint declared per runtime | `claude-sonnet`, `claude-glm` |
|
|
277
277
|
| `codex` | OpenAI | `codex-gpt` |
|
|
278
278
|
| `agy` | Google | `agy-gemini` |
|
|
279
279
|
| `dsh` | declared per runtime; DeepSeek in the discovery entry | `dsh-deepseek` |
|
|
280
|
+
| `fx` | declared per runtime; DeepSeek, Zhipu and OpenAI in the discovery entries | `fx-deepseek`, `fx-glm`, `fx-gpt` |
|
|
280
281
|
| `zcode` | Zhipu | `zcode-glm` |
|
|
281
282
|
| `exec-jsonl` | declared per runtime | the model its command names |
|
|
282
283
|
| `replay` | declared per runtime | the recorded model |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli/setup.mjs
CHANGED
|
@@ -64,7 +64,7 @@ import { discoverSkillTargets, registerSkills } from "./skills.mjs";
|
|
|
64
64
|
const MIN_NODE_MAJOR = 22;
|
|
65
65
|
|
|
66
66
|
/** The harnesses a fresh machine can install, named in the failure hint. */
|
|
67
|
-
const INSTALL_HARNESSES = ["claude", "codex", "agy", "dsh", "zcode"];
|
|
67
|
+
const INSTALL_HARNESSES = ["claude", "codex", "agy", "dsh", "fx", "zcode"];
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
70
|
* @param {SetupOptions} [options]
|
package/src/cli.mjs
CHANGED
|
@@ -534,7 +534,7 @@ function warnIfNoTransport() {
|
|
|
534
534
|
}
|
|
535
535
|
|
|
536
536
|
/** The harness binaries whose presence the banner counts on PATH. */
|
|
537
|
-
const HARNESS_BINARIES = ["claude", "codex", "agy", "dsh", "zcode"];
|
|
537
|
+
const HARNESS_BINARIES = ["claude", "codex", "agy", "dsh", "fx", "zcode"];
|
|
538
538
|
|
|
539
539
|
/** Handled before verb dispatch, so they are not options of any command. */
|
|
540
540
|
const HELP_FLAGS = new Set(["--help", "-h"]);
|
package/src/contract/index.mjs
CHANGED
|
@@ -68,7 +68,7 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
|
|
|
68
68
|
|
|
69
69
|
/** @typedef {{mode: "execution"|"discovery"|"autonomous", objective: string, instructions: string[], readFiles: string[], writeFiles?: string[], writeRoots?: string[], symbols: string[], scopeAcknowledged?: string[], decisions: string[], nonGoals: string[], verification: VerificationCommand[]}} TaskPacket */
|
|
70
70
|
|
|
71
|
-
/** @typedef {{harness: "claude"|"codex"|"agy"|"dsh"|"zcode"|"exec-jsonl"|"replay", model: string, reasoning?: string, sandbox?: "read-only"|"workspace-write"|"danger-full-access", permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, costRank?: number, fallback?: string, vendor: string, tier?: number|string, stallTimeoutSec?: number, maxConcurrent?: number}} ValidatedRuntime */
|
|
71
|
+
/** @typedef {{harness: "claude"|"codex"|"agy"|"dsh"|"fx"|"zcode"|"exec-jsonl"|"replay", model: string, reasoning?: string, sandbox?: "read-only"|"workspace-write"|"danger-full-access", permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, costRank?: number, fallback?: string, vendor: string, tier?: number|string, stallTimeoutSec?: number, maxConcurrent?: number}} ValidatedRuntime */
|
|
72
72
|
|
|
73
73
|
/** @typedef {{enabled: boolean, review?: ("none"|"advisory"|"blocking"), runtime?: string, failOn?: ("minor"|"major"|"critical")[], maxRevisions?: number, requiredCapabilities?: CapabilityRequirements, skipWhen?: {verificationGreen: true, maxChangedPaths: number}}} ValidatedGate */
|
|
74
74
|
|
package/src/contract/runtime.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { assertObject, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireString, requireStringArray, requireTimestamp } from "./assert.mjs";
|
|
13
13
|
import { composeAssignments } from "../engine/runtime-discovery.mjs";
|
|
14
|
-
import { harnessCapabilities, resolvePermissionExecution, resolveVendor, validateCapabilityRequirements, writesWorkspace } from "../harnesses/index.mjs";
|
|
14
|
+
import { harnessCapabilities, readOnlyModeOf, resolvePermissionExecution, resolveVendor, validateCapabilityRequirements, writesWorkspace } from "../harnesses/index.mjs";
|
|
15
15
|
import { canonicalProvider } from "./provider.mjs";
|
|
16
16
|
import { stableJson } from "../util.mjs";
|
|
17
17
|
/** @typedef {import("./index.mjs").NodeStatus} NodeStatus */
|
|
@@ -27,7 +27,7 @@ const RUNTIME_FIELDS = new Set([
|
|
|
27
27
|
"executable", "args", "versionArgs", "maxArgvPromptBytes", "requiredCapabilities", "costRank",
|
|
28
28
|
"fallback", "vendor", "tier", "pricing", "stallTimeoutSec", "maxConcurrent",
|
|
29
29
|
]);
|
|
30
|
-
const RUNTIME_HARNESSES = new Set(["claude", "codex", "agy", "dsh", "zcode", "exec-jsonl", "replay"]);
|
|
30
|
+
const RUNTIME_HARNESSES = new Set(["claude", "codex", "agy", "dsh", "fx", "zcode", "exec-jsonl", "replay"]);
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* The strategies a routing rule may name: how a rule consumes its `prefer`
|
|
@@ -246,8 +246,9 @@ export function judgeWriteWarnings(runtimes, defaults, nodes) {
|
|
|
246
246
|
const runtime = runtimes[/** @type {string} */ (id)];
|
|
247
247
|
const execution = resolvePermissionExecution(runtime);
|
|
248
248
|
if (!execution.field || runtime[execution.field] === undefined || !writesWorkspace(runtime)) return [];
|
|
249
|
-
|
|
250
|
-
|
|
249
|
+
const readOnlyMode = readOnlyModeOf(runtime);
|
|
250
|
+
if (readOnlyMode === null) return [];
|
|
251
|
+
return [`judge runtime ${id} declares ${execution.field} ${execution.mode}; a judge's verdict reaches the gate without writing, so declare ${execution.field} ${readOnlyMode}`];
|
|
251
252
|
});
|
|
252
253
|
}
|
|
253
254
|
/**
|
|
@@ -181,7 +181,7 @@ function createLivePreflightRepo() {
|
|
|
181
181
|
*/
|
|
182
182
|
function safeLiveRuntime(runtime) {
|
|
183
183
|
if (runtime.harness === "codex") return { ...runtime, sandbox: "read-only" };
|
|
184
|
-
if (runtime.harness === "dsh") return { ...runtime, sandbox: "read-only" };
|
|
184
|
+
if (runtime.harness === "dsh" || runtime.harness === "fx") return { ...runtime, sandbox: "read-only" };
|
|
185
185
|
if (runtime.harness === "claude") return { ...runtime, permissionMode: "plan" };
|
|
186
186
|
// `plan` is the ZCode mode that reads without writing; the adapter's own
|
|
187
187
|
// default is `yolo`, which a preflight prompt must never reach.
|
|
@@ -56,6 +56,18 @@ export { exhaustedUntilOf, normalizeProviderAvailability } from "../harnesses/in
|
|
|
56
56
|
* @type {Readonly<Record<string, DiscoveryRuntime>>}
|
|
57
57
|
*/
|
|
58
58
|
export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
|
|
59
|
+
// Every vendor this harness reaches is declared ahead of the older harness
|
|
60
|
+
// for the same models, so fx wins each tie-break. Measured on the
|
|
61
|
+
// parseDuration benchmark (2026-09-25): 22-27 MB per fx worker against
|
|
62
|
+
// 362-422 MB for dsh, with the same scope and hidden-test results.
|
|
63
|
+
"fx-deepseek": {
|
|
64
|
+
harness: "fx",
|
|
65
|
+
model: "deepseek-flash",
|
|
66
|
+
vendor: "deepseek",
|
|
67
|
+
config: { "api_key.env_key": "DEEPSEEK_API_KEY" },
|
|
68
|
+
tier: 1,
|
|
69
|
+
costRank: 1,
|
|
70
|
+
},
|
|
59
71
|
// `dsh` defaults no vendor and no provider route, so both are declared here
|
|
60
72
|
// or nothing can build a command from this entry.
|
|
61
73
|
"dsh-deepseek": {
|
|
@@ -66,6 +78,32 @@ export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
|
|
|
66
78
|
tier: 1,
|
|
67
79
|
costRank: 1,
|
|
68
80
|
},
|
|
81
|
+
// GLM 5.3 on fx through the Z.ai Coding Plan's Chat Completions endpoint.
|
|
82
|
+
// The operator chose this default knowing the plan's usage policy lists
|
|
83
|
+
// Claude Code and ZCode, not fx, and restricts or bans an account for
|
|
84
|
+
// detected third-party use; `claude-glm` below is the supported route.
|
|
85
|
+
// Measured 2026-09-26: the tiny-text campaign with a GLM worker and a
|
|
86
|
+
// DeepSeek judge, both on fx, peaked at 283 MB.
|
|
87
|
+
"fx-glm": {
|
|
88
|
+
harness: "fx",
|
|
89
|
+
model: "glm-5.3",
|
|
90
|
+
vendor: "zhipu",
|
|
91
|
+
config: { base_url: "https://api.z.ai/api/coding/paas/v4", "api_key.env_key": "ZAI_API_KEY", context_window: 200_000 },
|
|
92
|
+
tier: 1,
|
|
93
|
+
costRank: 1,
|
|
94
|
+
},
|
|
95
|
+
// GLM through Claude Code, which the Z.ai Coding Plan lists as a supported
|
|
96
|
+
// tool. Declared before zcode-glm so it wins the tie-break: measured
|
|
97
|
+
// 2026-09-25 on one review prompt, 384-392 MB per turn against 0.9-1.0 GB
|
|
98
|
+
// for zcode, and the tiny-text campaign peaked at 865 MB against 1,575 MB.
|
|
99
|
+
"claude-glm": {
|
|
100
|
+
harness: "claude",
|
|
101
|
+
model: "glm-5.3",
|
|
102
|
+
vendor: "zhipu",
|
|
103
|
+
config: { base_url: "https://api.z.ai/api/anthropic", "auth_token.env_key": "ZAI_API_KEY" },
|
|
104
|
+
tier: 1,
|
|
105
|
+
costRank: 1,
|
|
106
|
+
},
|
|
69
107
|
// The flash tier, not the unsuffixed pro: measured 2026-09-24 in the judge
|
|
70
108
|
// canary, glm-5.3 recalled 0.79 against glm-5.3-flash's 0.85 at about nine
|
|
71
109
|
// times the price, which the owner confirmed matches the public benchmarks.
|
|
@@ -78,15 +116,19 @@ export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
|
|
|
78
116
|
costRank: 1,
|
|
79
117
|
},
|
|
80
118
|
"agy-gemini": { harness: "agy", model: "gemini-3.8-flash-low", vendor: "google", tier: 1, costRank: 1 },
|
|
119
|
+
// GPT on fx through fx's own ChatGPT login, which fx-faberun reads from the
|
|
120
|
+
// operator's profile (`FX_AUTH_HOME`). Measured 2026-09-26: one review turn
|
|
121
|
+
// peaked at 35-47 MB for the runner and fx together.
|
|
122
|
+
"fx-gpt": { harness: "fx", model: "gpt-5.6-sol", vendor: "openai", config: { provider: "codex" }, tier: 2, costRank: 2 },
|
|
81
123
|
// Three codex rows, because the harness declares three models and an
|
|
82
124
|
// account is entitled to only some of them: measured 2026-09-21 on the
|
|
83
125
|
// owner's ChatGPT account, plain `gpt-5.6` answers HTTP 400 while
|
|
84
126
|
// `gpt-5.6-sol` answers normally. One row meant `setup` could offer only the
|
|
85
127
|
// model that account cannot use, and the operator's only way out was to hand
|
|
86
|
-
// every contract its own catalogue through `--runtimes`.
|
|
87
|
-
//
|
|
88
|
-
// an account can reach is not something this file can know, and the
|
|
89
|
-
// preflight is what reports it per id.
|
|
128
|
+
// every contract its own catalogue through `--runtimes`. `fx-gpt` above is
|
|
129
|
+
// declared first, so it is the composed tier-2 default; among these three,
|
|
130
|
+
// which an account can reach is not something this file can know, and the
|
|
131
|
+
// live preflight is what reports it per id.
|
|
90
132
|
"codex-gpt": { harness: "codex", model: "gpt-5.6", vendor: "openai", tier: 2, costRank: 2 },
|
|
91
133
|
"codex-sol": { harness: "codex", model: "gpt-5.6-sol", vendor: "openai", tier: 2, costRank: 2 },
|
|
92
134
|
"codex-luna": { harness: "codex", model: "gpt-5.6-luna", vendor: "openai", tier: 2, costRank: 2 },
|
|
@@ -26,12 +26,13 @@ import { spawnInvocation } from "../host/platform.mjs";
|
|
|
26
26
|
* opt-in that reads the host.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
/** Display order of the registered harnesses: claude, codex, agy, dsh, zcode, exec-jsonl, replay. */
|
|
29
|
+
/** Display order of the registered harnesses: claude, codex, agy, dsh, fx, zcode, exec-jsonl, replay. */
|
|
30
30
|
export const MODEL_HARNESS_ORDER = Object.freeze([
|
|
31
31
|
"claude",
|
|
32
32
|
"codex",
|
|
33
33
|
"agy",
|
|
34
34
|
"dsh",
|
|
35
|
+
"fx",
|
|
35
36
|
"zcode",
|
|
36
37
|
"exec-jsonl",
|
|
37
38
|
"replay",
|
|
@@ -78,6 +79,11 @@ export const DECLARED_MODEL_CATALOGUES = Object.freeze({
|
|
|
78
79
|
declaredModel("claude-fable-5", { efforts: CLAUDE_EFFORTS }),
|
|
79
80
|
declaredModel("claude-fable-5-1", { efforts: CLAUDE_EFFORTS }),
|
|
80
81
|
declaredModel("claude-sonnet-4-6", { efforts: CLAUDE_EFFORTS }),
|
|
82
|
+
// GLM through a runtime whose `config.base_url` names Z.ai's
|
|
83
|
+
// Anthropic-compatible endpoint. The CLI sized its own window at 200,000
|
|
84
|
+
// tokens for this model (2026-09-25 result event), and `--effort` is a
|
|
85
|
+
// Claude-model flag, so no effort is declared.
|
|
86
|
+
declaredModel("glm-5.3", { contextWindowTokens: 200_000, efforts: NO_EFFORTS }),
|
|
81
87
|
]),
|
|
82
88
|
codex: Object.freeze([
|
|
83
89
|
declaredModel("gpt-5.6", { efforts: CODEX_EFFORTS }),
|
|
@@ -98,6 +104,20 @@ export const DECLARED_MODEL_CATALOGUES = Object.freeze({
|
|
|
98
104
|
declaredModel("deepseek-v4-pro", { contextWindowTokens: DSH_CONTEXT_WINDOW_TOKENS, efforts: DSH_EFFORTS, defaultEffort: "high" }),
|
|
99
105
|
declaredModel("deepseek-v4-flash-vision-exp", { contextWindowTokens: DSH_CONTEXT_WINDOW_TOKENS, efforts: DSH_EFFORTS, defaultEffort: "high" }),
|
|
100
106
|
]),
|
|
107
|
+
// The two ids api.deepseek.com/models listed on 2026-09-24. No efforts:
|
|
108
|
+
// fx's settings carry one `effort` for the Gateway catalogue and send
|
|
109
|
+
// nothing a custom Chat Completions connection is known to honour.
|
|
110
|
+
fx: Object.freeze([
|
|
111
|
+
declaredModel("deepseek-flash", { contextWindowTokens: DSH_CONTEXT_WINDOW_TOKENS, efforts: NO_EFFORTS }),
|
|
112
|
+
declaredModel("deepseek-v4-pro", { contextWindowTokens: DSH_CONTEXT_WINDOW_TOKENS, efforts: NO_EFFORTS }),
|
|
113
|
+
// GLM through a Z.ai Chat Completions endpoint named by `config.base_url`.
|
|
114
|
+
declaredModel("glm-5.3", { contextWindowTokens: GLM_CONTEXT_WINDOW_TOKENS, efforts: NO_EFFORTS }),
|
|
115
|
+
// GPT through fx's ChatGPT login (`config.provider: "codex"`). Measured
|
|
116
|
+
// 2026-09-26: fx listed these among the account's seven models, and both
|
|
117
|
+
// answered a review turn through the runner.
|
|
118
|
+
declaredModel("gpt-5.6-sol", { efforts: NO_EFFORTS }),
|
|
119
|
+
declaredModel("gpt-6-sol", { efforts: NO_EFFORTS }),
|
|
120
|
+
]),
|
|
101
121
|
zcode: Object.freeze([
|
|
102
122
|
declaredModel("glm-5.3-flash", { contextWindowTokens: GLM_CONTEXT_WINDOW_TOKENS, efforts: NO_EFFORTS }),
|
|
103
123
|
// No `[1m]` row: the suffix is a Claude Code convention the ZCode CLI does
|
|
@@ -115,6 +135,7 @@ const CATALOGUE_SOURCES = Object.freeze({
|
|
|
115
135
|
claude: "declared",
|
|
116
136
|
codex: "declared",
|
|
117
137
|
dsh: "declared",
|
|
138
|
+
fx: "declared",
|
|
118
139
|
zcode: "declared",
|
|
119
140
|
"exec-jsonl": "runtime-declared",
|
|
120
141
|
replay: "runtime-declared",
|
|
@@ -130,18 +151,20 @@ const EFFORT_TRANSPORT = Object.freeze({
|
|
|
130
151
|
codex: "config.model_reasoning_effort",
|
|
131
152
|
agy: "--effort",
|
|
132
153
|
dsh: "--reasoning (harness reasoningEffort)",
|
|
154
|
+
fx: null,
|
|
133
155
|
zcode: null,
|
|
134
156
|
"exec-jsonl": null,
|
|
135
157
|
replay: null,
|
|
136
158
|
});
|
|
137
159
|
|
|
138
160
|
/**
|
|
139
|
-
* Why a harness resolves no vendor: dsh, exec-jsonl, and replay declare none.
|
|
161
|
+
* Why a harness resolves no vendor: dsh, fx, exec-jsonl, and replay declare none.
|
|
140
162
|
*
|
|
141
163
|
* @type {Readonly<Record<string, string>>}
|
|
142
164
|
*/
|
|
143
165
|
const VENDOR_NOTES = Object.freeze({
|
|
144
166
|
dsh: "unresolved: dsh declares no default vendor; the contract names it",
|
|
167
|
+
fx: "unresolved: fx declares no default vendor; the contract names it",
|
|
145
168
|
"exec-jsonl": "unresolved: exec-jsonl declares no default vendor; the contract names it",
|
|
146
169
|
replay: "unresolved: replay declares no default vendor; the contract names it",
|
|
147
170
|
});
|
|
@@ -26,6 +26,70 @@ function claudePreambleArgs(runtime) {
|
|
|
26
26
|
];
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The environment one runtime's CLI runs under when its contract points it at
|
|
31
|
+
* an Anthropic-compatible endpoint other than Anthropic's own, e.g. GLM on
|
|
32
|
+
* the Z.ai Coding Plan, where Claude Code is one of the supported tools:
|
|
33
|
+
*
|
|
34
|
+
* "config": { "base_url": "https://api.z.ai/api/anthropic",
|
|
35
|
+
* "auth_token.env_key": "ZAI_API_KEY" }
|
|
36
|
+
*
|
|
37
|
+
* The overlay travels in the invocation's own command, so it reaches only
|
|
38
|
+
* this runtime's process: a controller that also runs a Claude judge on
|
|
39
|
+
* Anthropic keeps that judge on Anthropic. The token is read from the named
|
|
40
|
+
* variable at invocation time; its value never appears in the contract.
|
|
41
|
+
*
|
|
42
|
+
* A declared endpoint with no resolvable token is refused rather than run:
|
|
43
|
+
* the CLI would fall back to the operator's own Anthropic login and send that
|
|
44
|
+
* credential to the declared host. Measured 2026-09-25 on the tiny-text
|
|
45
|
+
* campaign: two parallel GLM 5.3 judges through this CLI peaked at 599 MB,
|
|
46
|
+
* where the same judges on zcode had peaked at about 1.5 GB together with
|
|
47
|
+
* the test processes.
|
|
48
|
+
*
|
|
49
|
+
* @param {import("../index.mjs").HarnessRuntime} runtime
|
|
50
|
+
* @returns {Record<string, string|null>|undefined}
|
|
51
|
+
*/
|
|
52
|
+
function endpointEnv(runtime) {
|
|
53
|
+
const baseUrl = declaredBaseUrl(runtime);
|
|
54
|
+
const declared = runtime.config?.["auth_token.env_key"];
|
|
55
|
+
const tokenVar = typeof declared === "string" && declared ? declared : null;
|
|
56
|
+
if (baseUrl === null && tokenVar === null) return undefined;
|
|
57
|
+
const token = tokenVar === null ? undefined : process.env[tokenVar];
|
|
58
|
+
if (typeof token !== "string" || !token) {
|
|
59
|
+
const error = /** @type {Error & {code: string}} */ (new Error(tokenVar === null
|
|
60
|
+
? `claude runtime declares base_url ${baseUrl} without auth_token.env_key`
|
|
61
|
+
: `claude runtime declares auth_token.env_key ${tokenVar}, which is unset`));
|
|
62
|
+
error.code = "auth_token_unresolved";
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
/** @type {Record<string, string|null>} */
|
|
66
|
+
const env = {
|
|
67
|
+
ANTHROPIC_AUTH_TOKEN: token,
|
|
68
|
+
// An ambient Anthropic key outranks the bearer token in the CLI.
|
|
69
|
+
ANTHROPIC_API_KEY: null,
|
|
70
|
+
};
|
|
71
|
+
if (baseUrl !== null) {
|
|
72
|
+
env.ANTHROPIC_BASE_URL = baseUrl;
|
|
73
|
+
// The CLI sends its own background requests (titles, summaries) to the
|
|
74
|
+
// alias models; another endpoint does not serve those names, so every
|
|
75
|
+
// alias resolves to the runtime's model.
|
|
76
|
+
env.ANTHROPIC_DEFAULT_OPUS_MODEL = runtime.model;
|
|
77
|
+
env.ANTHROPIC_DEFAULT_SONNET_MODEL = runtime.model;
|
|
78
|
+
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = runtime.model;
|
|
79
|
+
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
80
|
+
}
|
|
81
|
+
return env;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {{config?: Record<string, unknown>}} runtime
|
|
86
|
+
* @returns {string|null}
|
|
87
|
+
*/
|
|
88
|
+
function declaredBaseUrl(runtime) {
|
|
89
|
+
const value = runtime.config?.base_url;
|
|
90
|
+
return typeof value === "string" && value ? value : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
29
93
|
/**
|
|
30
94
|
* @type {import("../index.mjs").HarnessAdapter}
|
|
31
95
|
*/
|
|
@@ -52,8 +116,11 @@ export const claudeHarness = {
|
|
|
52
116
|
signalsProcesses: true,
|
|
53
117
|
},
|
|
54
118
|
|
|
55
|
-
// Headless acceptEdits denies Bash; bypassPermissions executes commands
|
|
56
|
-
|
|
119
|
+
// Headless acceptEdits denies Bash; bypassPermissions executes commands;
|
|
120
|
+
// plan neither edits nor runs anything, the mode a judge or a preflight ask
|
|
121
|
+
// needs (measured 2026-09-27: an Opus judge left on the acceptEdits default
|
|
122
|
+
// edited the review it was grading and was blocked judge_protocol).
|
|
123
|
+
permissionExecution: { field: "permissionMode", executingModes: ["bypassPermissions"], defaultMode: "acceptEdits", readOnlyModes: ["plan"] },
|
|
57
124
|
|
|
58
125
|
/** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string} */
|
|
59
126
|
executable(runtime) {
|
|
@@ -90,7 +157,21 @@ export const claudeHarness = {
|
|
|
90
157
|
// a kill) for the one harness that can take it as a flag.
|
|
91
158
|
if (typeof options.maxTurns === "number") args.push("--max-turns", String(options.maxTurns));
|
|
92
159
|
if (options.schema) args.push("--json-schema", JSON.stringify(options.schema));
|
|
93
|
-
|
|
160
|
+
const env = endpointEnv(runtime);
|
|
161
|
+
return { executable: this.executable(runtime), args, promptTransport: "stdin", input: prompt, ...(env ? { env } : {}) };
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The CLI's `total_cost_usd` applies Anthropic's rates to whatever model
|
|
166
|
+
* answered, so on another endpoint it is not what the turn cost: measured
|
|
167
|
+
* 2026-09-25, a GLM 5.3 review on the Z.ai Coding Plan reported $0.13.
|
|
168
|
+
* Such a runtime is priced like any harness that reports no cost.
|
|
169
|
+
*
|
|
170
|
+
* @param {{harness: string, config?: Record<string, unknown>}} runtime
|
|
171
|
+
* @returns {boolean}
|
|
172
|
+
*/
|
|
173
|
+
reportsCost(runtime) {
|
|
174
|
+
return declaredBaseUrl(runtime) === null;
|
|
94
175
|
},
|
|
95
176
|
|
|
96
177
|
normalize: normalizeClaudeResult,
|
|
@@ -1,6 +1,7 @@
|
|
|
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";
|
|
4
5
|
|
|
5
6
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
|
|
@@ -103,7 +104,7 @@ export const dshHarness = {
|
|
|
103
104
|
args,
|
|
104
105
|
promptTransport: "stdin",
|
|
105
106
|
input: withSchema(prompt, options.schema),
|
|
106
|
-
env:
|
|
107
|
+
env: runnerEnvironmentOverlay(process.env),
|
|
107
108
|
};
|
|
108
109
|
},
|
|
109
110
|
|
|
@@ -115,122 +116,9 @@ export const dshHarness = {
|
|
|
115
116
|
* @returns {import("../index.mjs").ProviderEnvelope}
|
|
116
117
|
*/
|
|
117
118
|
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
|
-
};
|
|
119
|
+
return normalizeRunnerTranscript("dsh", stdout, exitCode, signal, options);
|
|
162
120
|
},
|
|
163
121
|
};
|
|
164
122
|
|
|
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
123
|
export const harness = dshHarness;
|
|
236
124
|
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
|
|
@@ -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
|
+
}
|