faberun 0.26.3 → 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 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.26.3",
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"]);
@@ -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
 
@@ -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`
@@ -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`. Declaration order is
87
- // unchanged, so the composed default is still `codex-gpt`: which of the three
88
- // an account can reach is not something this file can know, and the live
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
  */
@@ -93,7 +157,21 @@ export const claudeHarness = {
93
157
  // a kill) for the one harness that can take it as a flag.
94
158
  if (typeof options.maxTurns === "number") args.push("--max-turns", String(options.maxTurns));
95
159
  if (options.schema) args.push("--json-schema", JSON.stringify(options.schema));
96
- return { executable: this.executable(runtime), args, promptTransport: "stdin", input: prompt };
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;
97
175
  },
98
176
 
99
177
  normalize: normalizeClaudeResult,
@@ -1,6 +1,7 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { canonicalUsage, extractJson, failed, isQuotaText, isVerdictCandidate, parseJsonLines, parseVersion } from "../protocol.mjs";
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: dshEnvironmentOverlay(process.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
- if (signal) return failed("canceled", `provider ended after ${signal}`, "canceled");
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 { writeSync } from "node:fs";
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
+ }