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.
Files changed (88) hide show
  1. package/README.md +11 -4
  2. package/package.json +1 -1
  3. package/skills/faberun/SKILL.md +3 -3
  4. package/skills/faberun/references/contract.md +9 -9
  5. package/skills/faberun/references/local-env.md +11 -0
  6. package/skills/faberun/references/operations.md +26 -33
  7. package/src/campaign/brief.mjs +41 -9
  8. package/src/campaign/campaign-brief.mjs +18 -3
  9. package/src/campaign/index.mjs +22 -9
  10. package/src/campaign/ledger.mjs +72 -4
  11. package/src/campaign/metrics.mjs +29 -2
  12. package/src/campaign/record.mjs +34 -0
  13. package/src/cli/campaign.mjs +35 -12
  14. package/src/cli/contract.mjs +11 -1
  15. package/src/cli/launch.mjs +12 -2
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/setup.mjs +6 -1
  18. package/src/cli/skills.mjs +18 -7
  19. package/src/cli/uninstall.mjs +542 -0
  20. package/src/cli/walkthrough.mjs +462 -0
  21. package/src/cli.mjs +68 -8
  22. package/src/contract/articles.mjs +30 -0
  23. package/src/contract/definition-of-done.mjs +109 -0
  24. package/src/contract/index.mjs +7 -4
  25. package/src/contract/judge-independence.mjs +18 -0
  26. package/src/contract/runtime.mjs +6 -2
  27. package/src/contract/scope-findings.mjs +120 -0
  28. package/src/contract/snapshot.mjs +19 -5
  29. package/src/contract/task-packet.mjs +49 -1
  30. package/src/contract/worker-result.mjs +10 -0
  31. package/src/engine/assignment.mjs +45 -0
  32. package/src/engine/detach.mjs +18 -0
  33. package/src/engine/gate.mjs +10 -2
  34. package/src/engine/judge-gate.mjs +24 -9
  35. package/src/engine/live-preflight.mjs +114 -18
  36. package/src/engine/prompts.mjs +41 -3
  37. package/src/engine/resume.mjs +119 -12
  38. package/src/engine/retry.mjs +11 -11
  39. package/src/engine/run-command.mjs +80 -4
  40. package/src/engine/run-identity.mjs +27 -14
  41. package/src/engine/runtime-discovery.mjs +61 -13
  42. package/src/engine/worker-env.mjs +179 -0
  43. package/src/harnesses/agy/index.mjs +11 -0
  44. package/src/harnesses/catalogue.mjs +54 -2
  45. package/src/harnesses/claude/index.mjs +177 -2
  46. package/src/harnesses/codex/index.mjs +15 -0
  47. package/src/harnesses/dsh/index.mjs +18 -116
  48. package/src/harnesses/dsh/runner.mjs +1 -17
  49. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  50. package/src/harnesses/fx/home.mjs +57 -0
  51. package/src/harnesses/fx/index.mjs +137 -0
  52. package/src/harnesses/fx/native/build.zig +20 -0
  53. package/src/harnesses/fx/native/build.zig.zon +7 -0
  54. package/src/harnesses/fx/native/home.zig +85 -0
  55. package/src/harnesses/fx/native/main.zig +503 -0
  56. package/src/harnesses/fx/native/permissions.zig +51 -0
  57. package/src/harnesses/fx/native/relay.zig +253 -0
  58. package/src/harnesses/fx/native/usage.zig +112 -0
  59. package/src/harnesses/fx/permissions.mjs +35 -0
  60. package/src/harnesses/fx/runner.mjs +350 -0
  61. package/src/harnesses/fx/usage-proxy.mjs +176 -0
  62. package/src/harnesses/index.mjs +29 -14
  63. package/src/harnesses/replay/index.mjs +12 -0
  64. package/src/harnesses/runner-io.mjs +25 -0
  65. package/src/harnesses/runner-transcript.mjs +127 -0
  66. package/src/harnesses/session-metrics.mjs +38 -13
  67. package/src/harnesses/zcode/index.mjs +19 -0
  68. package/src/host/config.mjs +8 -2
  69. package/src/host/environment-report.mjs +85 -0
  70. package/src/host/home.mjs +27 -0
  71. package/src/host/install-registry.mjs +511 -0
  72. package/src/host/preflight.mjs +34 -10
  73. package/src/host/uninstall-roots.mjs +196 -0
  74. package/src/plan/proof-check.mjs +15 -0
  75. package/src/plan/repo-facts.mjs +95 -11
  76. package/src/plan/rounds.mjs +27 -0
  77. package/src/repo/declared-paths.mjs +74 -6
  78. package/src/repo/integrate.mjs +1 -1
  79. package/src/repo/scope-closure.mjs +59 -0
  80. package/src/repo/signal-block.mjs +26 -0
  81. package/src/repo/signal.mjs +9 -24
  82. package/src/repo/workspace.mjs +189 -14
  83. package/src/report/campaign-brief.mjs +4 -0
  84. package/src/report/message.mjs +37 -6
  85. package/src/report/progress.mjs +13 -2
  86. package/src/run/paths.mjs +14 -1
  87. package/src/run/usage.mjs +66 -1
  88. 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
- return { executable: this.executable(runtime), args, promptTransport: "stdin", input: prompt };
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
- normalize: normalizeClaudeResult,
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 { 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";
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: dshEnvironmentOverlay(process.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
- 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
- };
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 { 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
@@ -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
+ }