@robmclarty/volley 0.10.0 → 0.10.1

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/dist/cli.js CHANGED
@@ -101,6 +101,256 @@ function step_countdown(max_steps, check) {
101
101
  };
102
102
  }
103
103
  //#endregion
104
+ //#region src/types.ts
105
+ function config_error(message) {
106
+ return Object.assign(new Error(message), { kind: "config_error" });
107
+ }
108
+ function check_error(message) {
109
+ return Object.assign(new Error(message), { kind: "check_error" });
110
+ }
111
+ /** Wraps a provider/schema failure with the phase and iteration it hit. */
112
+ function phase_error(phase, iteration, cause) {
113
+ const detail = cause instanceof Error ? cause.message : String(cause);
114
+ return Object.assign(/* @__PURE__ */ new Error(`${phase} failed on iteration ${iteration}: ${detail}`), {
115
+ kind: "phase_error",
116
+ phase,
117
+ iteration,
118
+ cause
119
+ });
120
+ }
121
+ function error_kind(err) {
122
+ if (typeof err === "object" && err !== null && "kind" in err) {
123
+ const kind = err.kind;
124
+ return typeof kind === "string" ? kind : null;
125
+ }
126
+ return null;
127
+ }
128
+ //#endregion
129
+ //#region src/engine.ts
130
+ /**
131
+ * The ONLY module that calls fascicle's `create_engine`. Every
132
+ * other module receives an `Engine`-shaped value, which keeps the engine
133
+ * mockable in tests and confines provider knowledge to one seam.
134
+ */
135
+ /** LM Studio's OpenAI-compatible server and Ollama's server-root defaults.
136
+ * The Ollama URL is the server root, NOT `…/api`: `ai-sdk-ollama` appends
137
+ * `/api/...` itself, so a base URL ending in `/api` requests `/api/api/chat`
138
+ * and 404s. */
139
+ const DEFAULT_OLLAMA_URL = "http://localhost:11434";
140
+ const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
141
+ /** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
142
+ const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
143
+ /** The variable an `openrouter` seat's API key is read from. */
144
+ const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
145
+ /** The variable a `claude_cli` seat's API key is read from, which the Claude
146
+ * CLI also reads on its own. */
147
+ const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
148
+ /** Every variable a seat reads a credential from. Every process volley starts
149
+ * runs without them (the check, the builder's commands, and volley's own git
150
+ * and checkride calls, which run hooks and filters the builder can write), so
151
+ * nothing the builder writes can read the keys its seats spend. */
152
+ const SEAT_CREDENTIAL_VARS = [OPENROUTER_KEY_VAR, ANTHROPIC_KEY_VAR];
153
+ /** The environment every process volley starts runs with: `env`, `PATH`
154
+ * included, less every variable a seat reads a credential from. */
155
+ function command_env(env) {
156
+ const stripped = new Set(SEAT_CREDENTIAL_VARS);
157
+ return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0 && !stripped.has(entry[0])));
158
+ }
159
+ /** Loopback authorities the container→host crossing rewrites. A base URL
160
+ * pointing at one of these is local to wherever volley runs; contained, that is
161
+ * *inside* the sandbox container, where the host LLM daemon is unreachable
162
+ * except across the boundary at `VOLLEY_MODEL_HOST`. `URL.hostname` keeps an
163
+ * IPv6 literal's brackets, so the IPv6 loopback is spelled `[::1]`. */
164
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
165
+ "localhost",
166
+ "127.0.0.1",
167
+ "[::1]",
168
+ "0.0.0.0"
169
+ ]);
170
+ /** Cross a loopback base URL to the host LLM endpoint. When volley
171
+ * runs inside its sandbox container the local model daemon is on the *host*, so
172
+ * the example's `docker run` injects `VOLLEY_MODEL_HOST=host.docker.internal`
173
+ * (the host-gateway allowlist target; see `sandbox_invocation` in
174
+ * `src/sandbox.ts`). This normalizer *consumes that override* and swaps a
175
+ * loopback host for the gateway, so an unchanged `http://localhost:11434` still
176
+ * reaches the host from in-container. A non-loopback URL (a real remote daemon)
177
+ * is left untouched, and an unset `VOLLEY_MODEL_HOST` (the host / unsandboxed
178
+ * path) is a no-op — so the default and all-Claude paths are unaffected. */
179
+ function cross_to_host_gateway(base_url, env) {
180
+ const host = env["VOLLEY_MODEL_HOST"];
181
+ if (host === void 0 || host === "") return base_url;
182
+ let parsed;
183
+ try {
184
+ parsed = new URL(base_url);
185
+ } catch {
186
+ return base_url;
187
+ }
188
+ if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
189
+ parsed.hostname = host;
190
+ return parsed.toString().replace(/\/$/, "");
191
+ }
192
+ function strip_trailing_slashes(url) {
193
+ return url.replace(/\/+$/, "");
194
+ }
195
+ const SEAT_URL_SOURCES = {
196
+ ollama: {
197
+ env: "VOLLEY_OLLAMA_URL",
198
+ default_url: DEFAULT_OLLAMA_URL,
199
+ normalize: (url) => strip_trailing_slashes(url).replace(/\/api$/, "")
200
+ },
201
+ lmstudio: {
202
+ env: "VOLLEY_LMSTUDIO_URL",
203
+ default_url: DEFAULT_LMSTUDIO_URL,
204
+ normalize: strip_trailing_slashes
205
+ },
206
+ openrouter: {
207
+ env: "VOLLEY_OPENROUTER_URL",
208
+ default_url: DEFAULT_OPENROUTER_URL,
209
+ normalize: strip_trailing_slashes
210
+ }
211
+ };
212
+ /** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
213
+ * so get their endpoint probed by `--dry-run`. */
214
+ const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
215
+ /** Whether a seat on `provider` reaches its model at a base URL volley
216
+ * configures, and so takes a URL of its own. */
217
+ function takes_seat_url(provider) {
218
+ return SEAT_URL_PROVIDERS.includes(provider);
219
+ }
220
+ /** An absent, empty, or all-whitespace value counts as unset. */
221
+ function present(value) {
222
+ const trimmed = value?.trim() ?? "";
223
+ return trimmed === "" ? null : trimmed;
224
+ }
225
+ /** The base URL a seat's model calls go to: the seat's own URL, else the
226
+ * provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
227
+ * `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
228
+ * other seat's URL. The result is normalized, then a loopback authority is
229
+ * crossed to the host LLM endpoint when volley runs contained. Both seats can
230
+ * be loopback URLs on different ports, and each crosses on its own port. */
231
+ function seat_base_url(provider, seat_url, env) {
232
+ const source = SEAT_URL_SOURCES[provider];
233
+ const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
234
+ return cross_to_host_gateway(source.normalize(raw), env);
235
+ }
236
+ /** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
237
+ * or blank key is a config error naming the variable, which `resolve_config`
238
+ * raises before any work, because fascicle's own "requires a non-empty
239
+ * api_key" doesn't say where the key comes from. */
240
+ function openrouter_api_key(env) {
241
+ const key = present(env[OPENROUTER_KEY_VAR]);
242
+ if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
243
+ return key;
244
+ }
245
+ function load_pricing_overrides(path) {
246
+ if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
247
+ try {
248
+ return JSON.parse(readFileSync(path, "utf8"));
249
+ } catch (err) {
250
+ throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
251
+ }
252
+ }
253
+ /** A seat's provider entry, with the base URL resolved for that seat. The ai-sdk
254
+ * peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
255
+ * fascicle only when the provider actually runs. The entry is complete, because
256
+ * `Engine.with_providers` replaces a provider's whole entry: whatever this
257
+ * returns is all a seat's derived engine knows about the provider, so an
258
+ * `openrouter` entry carries its key and its transport along with its URL.
259
+ *
260
+ * `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
261
+ * so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
262
+ * traffic to volley.
263
+ *
264
+ * For the local providers, native transport is the deferred future bridge, not
265
+ * adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
266
+ * selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
267
+ * so no field is needed today. Flipping later is a one-line change — add
268
+ * `transport: 'native'` to the ollama/lmstudio entry returned below, which both
269
+ * seats' engines then share. No URL re-pointing is required: `seat_base_url`
270
+ * already yields the daemon-root URL the native `/api/chat` transport wants
271
+ * (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
272
+ * base-URL fix produces.
273
+ * Kept as a proven-once-then-reverted option so the transport stays a clean
274
+ * second variable in the model-vs-transport comparison. Flipping it today would
275
+ * also lose the reasoning: fascicle's native Ollama adapter drops
276
+ * `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
277
+ * thinking seat's reasoning would vanish from the terminal and the trajectory.
278
+ *
279
+ * Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
280
+ * when volley runs contained, so an in-container model client reaches the
281
+ * host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
282
+ * override is unset and this is a no-op. */
283
+ function seat_provider_config(provider, seat_url, env) {
284
+ const base_url = seat_base_url(provider, seat_url, env);
285
+ switch (provider) {
286
+ case "ollama": return { ollama: { base_url } };
287
+ case "lmstudio": return { lmstudio: { base_url } };
288
+ case "openrouter": return { openrouter: {
289
+ api_key: openrouter_api_key(env),
290
+ base_url,
291
+ transport: "native",
292
+ x_title: "volley"
293
+ } };
294
+ }
295
+ }
296
+ /** Per-run engine. `claude_cli` is always configured with `options.workspace`
297
+ * (the run's build root) as its session cwd; any URL-taking provider selected
298
+ * by the builder or critic role is wired in alongside it, at that seat's URL,
299
+ * so one engine drives both roles. An engine holds one URL per provider, so on
300
+ * a provider both seats share it carries the builder's; `create_seat_engines`
301
+ * gives the critic its own engine when its URL differs. Binary, auth mode,
302
+ * fallback provider URLs, and pricing come from the environment. */
303
+ function create_volley_engine(options) {
304
+ const env = options.env ?? process.env;
305
+ const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
306
+ if (auth_mode !== "auto" && auth_mode !== "oauth" && auth_mode !== "api_key") throw config_error(`VOLLEY_AUTH_MODE must be auto, oauth, or api_key; got: ${auth_mode}`);
307
+ const binary = env["VOLLEY_CLAUDE_BIN"];
308
+ const pricing_path = env["VOLLEY_PRICING_PATH"];
309
+ const providers = { claude_cli: {
310
+ default_cwd: options.workspace,
311
+ setting_sources: ["project", "local"],
312
+ auth_mode,
313
+ ...binary !== void 0 && binary.length > 0 ? { binary } : {},
314
+ ...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
315
+ } };
316
+ if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
317
+ if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
318
+ return create_engine({
319
+ providers,
320
+ ...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
321
+ });
322
+ }
323
+ /** One engine per seat, shared unless it can't be. fascicle fixes one base URL
324
+ * per provider when it builds an engine, so two seats on the same provider at
325
+ * different URLs need two engines: the critic's is derived from the builder's
326
+ * with `with_providers`, which keeps claude_cli, auth, and pricing and
327
+ * overrides only the shared provider's entry. Every other pairing, including a
328
+ * run that sets no seat URL at all, gets the one engine a run has always had. */
329
+ async function create_seat_engines(options) {
330
+ const env = options.env ?? process.env;
331
+ const builder = create_volley_engine(options);
332
+ const provider = options.critic_provider;
333
+ if (provider !== options.builder_provider || !takes_seat_url(provider) || seat_base_url(provider, options.builder_url ?? null, env) === seat_base_url(provider, options.critic_url ?? null, env)) return {
334
+ builder,
335
+ critic: builder
336
+ };
337
+ try {
338
+ return {
339
+ builder,
340
+ critic: builder.with_providers(seat_provider_config(provider, options.critic_url ?? null, env))
341
+ };
342
+ } catch (err) {
343
+ await builder.dispose();
344
+ throw err;
345
+ }
346
+ }
347
+ /** Dispose each distinct seat engine once. Both disposals start together, so
348
+ * one that fails doesn't keep the other from running. */
349
+ async function dispose_seat_engines(engines) {
350
+ const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
351
+ await Promise.all(distinct.map((engine) => engine.dispose()));
352
+ }
353
+ //#endregion
104
354
  //#region src/workspace_tools.ts
105
355
  /**
106
356
  * Shared workspace tools: `contain()` path confinement plus the read-only
@@ -564,14 +814,17 @@ async function run_fetch(raw, ctx, config) {
564
814
  * containment this runs *inside* volley's hardened container against the
565
815
  * bind-mounted worktree — the container is the isolation boundary, no
566
816
  * `docker exec` hop — and it is likewise the executor on the
567
- * `--allow-unsandboxed-builder` host escape hatch. `status` is null
568
- * when a signal (the timeout SIGKILL) killed the process.
817
+ * `--allow-unsandboxed-builder` host escape hatch. A command runs with
818
+ * volley's environment less the seats' keys, so nothing the builder runs can
819
+ * read the key its seat spends. `status` is null when a signal (the timeout
820
+ * SIGKILL) killed the process.
569
821
  */
570
822
  function host_bash_executor(cwd) {
571
823
  return (command, options) => {
572
824
  const result = spawnSync(command, {
573
825
  shell: true,
574
826
  cwd,
827
+ env: command_env(process.env),
575
828
  encoding: "utf8",
576
829
  timeout: options.timeout_ms,
577
830
  killSignal: "SIGKILL",
@@ -673,31 +926,6 @@ function finish_tool() {
673
926
  };
674
927
  }
675
928
  //#endregion
676
- //#region src/types.ts
677
- function config_error(message) {
678
- return Object.assign(new Error(message), { kind: "config_error" });
679
- }
680
- function check_error(message) {
681
- return Object.assign(new Error(message), { kind: "check_error" });
682
- }
683
- /** Wraps a provider/schema failure with the phase and iteration it hit. */
684
- function phase_error(phase, iteration, cause) {
685
- const detail = cause instanceof Error ? cause.message : String(cause);
686
- return Object.assign(/* @__PURE__ */ new Error(`${phase} failed on iteration ${iteration}: ${detail}`), {
687
- kind: "phase_error",
688
- phase,
689
- iteration,
690
- cause
691
- });
692
- }
693
- function error_kind(err) {
694
- if (typeof err === "object" && err !== null && "kind" in err) {
695
- const kind = err.kind;
696
- return typeof kind === "string" ? kind : null;
697
- }
698
- return null;
699
- }
700
- //#endregion
701
929
  //#region src/check/checkride.ts
702
930
  /**
703
931
  * checkride runner: spawn `pnpm exec checkride --json --digest`, gate on the
@@ -734,7 +962,7 @@ function spawn_capture(cmd, args, options) {
734
962
  return new Promise((resolve_promise, reject) => {
735
963
  const proc = spawn(cmd, args, {
736
964
  cwd: options.cwd,
737
- ...options.env !== void 0 ? { env: options.env } : {},
965
+ env: options.env ?? command_env(process.env),
738
966
  detached: process.platform !== "win32",
739
967
  stdio: [
740
968
  "ignore",
@@ -1015,224 +1243,6 @@ function cost_cap_hit(config, state) {
1015
1243
  return config.max_cost_usd !== null && state.total_cost_usd >= config.max_cost_usd;
1016
1244
  }
1017
1245
  //#endregion
1018
- //#region src/engine.ts
1019
- /**
1020
- * The ONLY module that calls fascicle's `create_engine`. Every
1021
- * other module receives an `Engine`-shaped value, which keeps the engine
1022
- * mockable in tests and confines provider knowledge to one seam.
1023
- */
1024
- /** LM Studio's OpenAI-compatible server and Ollama's server-root defaults.
1025
- * The Ollama URL is the server root, NOT `…/api`: `ai-sdk-ollama` appends
1026
- * `/api/...` itself, so a base URL ending in `/api` requests `/api/api/chat`
1027
- * and 404s. */
1028
- const DEFAULT_OLLAMA_URL = "http://localhost:11434";
1029
- const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
1030
- /** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
1031
- const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
1032
- /** The variable an `openrouter` seat's API key is read from. */
1033
- const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
1034
- /** The variable a `claude_cli` seat's API key is read from, which the Claude
1035
- * CLI also reads on its own. */
1036
- const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
1037
- /** Every variable a seat reads a credential from. Under acp, the builder's
1038
- * commands and the check run without them, so nothing the builder writes can
1039
- * read the keys its seats spend. */
1040
- const SEAT_CREDENTIAL_VARS = [OPENROUTER_KEY_VAR, ANTHROPIC_KEY_VAR];
1041
- /** Loopback authorities the container→host crossing rewrites. A base URL
1042
- * pointing at one of these is local to wherever volley runs; contained, that is
1043
- * *inside* the sandbox container, where the host LLM daemon is unreachable
1044
- * except across the boundary at `VOLLEY_MODEL_HOST`. `URL.hostname` keeps an
1045
- * IPv6 literal's brackets, so the IPv6 loopback is spelled `[::1]`. */
1046
- const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
1047
- "localhost",
1048
- "127.0.0.1",
1049
- "[::1]",
1050
- "0.0.0.0"
1051
- ]);
1052
- /** Cross a loopback base URL to the host LLM endpoint. When volley
1053
- * runs inside its sandbox container the local model daemon is on the *host*, so
1054
- * the example's `docker run` injects `VOLLEY_MODEL_HOST=host.docker.internal`
1055
- * (the host-gateway allowlist target; see `sandbox_invocation` in
1056
- * `src/sandbox.ts`). This normalizer *consumes that override* and swaps a
1057
- * loopback host for the gateway, so an unchanged `http://localhost:11434` still
1058
- * reaches the host from in-container. A non-loopback URL (a real remote daemon)
1059
- * is left untouched, and an unset `VOLLEY_MODEL_HOST` (the host / unsandboxed
1060
- * path) is a no-op — so the default and all-Claude paths are unaffected. */
1061
- function cross_to_host_gateway(base_url, env) {
1062
- const host = env["VOLLEY_MODEL_HOST"];
1063
- if (host === void 0 || host === "") return base_url;
1064
- let parsed;
1065
- try {
1066
- parsed = new URL(base_url);
1067
- } catch {
1068
- return base_url;
1069
- }
1070
- if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
1071
- parsed.hostname = host;
1072
- return parsed.toString().replace(/\/$/, "");
1073
- }
1074
- function strip_trailing_slashes(url) {
1075
- return url.replace(/\/+$/, "");
1076
- }
1077
- const SEAT_URL_SOURCES = {
1078
- ollama: {
1079
- env: "VOLLEY_OLLAMA_URL",
1080
- default_url: DEFAULT_OLLAMA_URL,
1081
- normalize: (url) => strip_trailing_slashes(url).replace(/\/api$/, "")
1082
- },
1083
- lmstudio: {
1084
- env: "VOLLEY_LMSTUDIO_URL",
1085
- default_url: DEFAULT_LMSTUDIO_URL,
1086
- normalize: strip_trailing_slashes
1087
- },
1088
- openrouter: {
1089
- env: "VOLLEY_OPENROUTER_URL",
1090
- default_url: DEFAULT_OPENROUTER_URL,
1091
- normalize: strip_trailing_slashes
1092
- }
1093
- };
1094
- /** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
1095
- * so get their endpoint probed by `--dry-run`. */
1096
- const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
1097
- /** Whether a seat on `provider` reaches its model at a base URL volley
1098
- * configures, and so takes a URL of its own. */
1099
- function takes_seat_url(provider) {
1100
- return SEAT_URL_PROVIDERS.includes(provider);
1101
- }
1102
- /** An absent, empty, or all-whitespace value counts as unset. */
1103
- function present(value) {
1104
- const trimmed = value?.trim() ?? "";
1105
- return trimmed === "" ? null : trimmed;
1106
- }
1107
- /** The base URL a seat's model calls go to: the seat's own URL, else the
1108
- * provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
1109
- * `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
1110
- * other seat's URL. The result is normalized, then a loopback authority is
1111
- * crossed to the host LLM endpoint when volley runs contained. Both seats can
1112
- * be loopback URLs on different ports, and each crosses on its own port. */
1113
- function seat_base_url(provider, seat_url, env) {
1114
- const source = SEAT_URL_SOURCES[provider];
1115
- const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
1116
- return cross_to_host_gateway(source.normalize(raw), env);
1117
- }
1118
- /** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
1119
- * or blank key is a config error naming the variable, which `resolve_config`
1120
- * raises before any work, because fascicle's own "requires a non-empty
1121
- * api_key" doesn't say where the key comes from. */
1122
- function openrouter_api_key(env) {
1123
- const key = present(env[OPENROUTER_KEY_VAR]);
1124
- if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
1125
- return key;
1126
- }
1127
- function load_pricing_overrides(path) {
1128
- if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
1129
- try {
1130
- return JSON.parse(readFileSync(path, "utf8"));
1131
- } catch (err) {
1132
- throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
1133
- }
1134
- }
1135
- /** A seat's provider entry, with the base URL resolved for that seat. The ai-sdk
1136
- * peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
1137
- * fascicle only when the provider actually runs. The entry is complete, because
1138
- * `Engine.with_providers` replaces a provider's whole entry: whatever this
1139
- * returns is all a seat's derived engine knows about the provider, so an
1140
- * `openrouter` entry carries its key and its transport along with its URL.
1141
- *
1142
- * `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
1143
- * so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
1144
- * traffic to volley.
1145
- *
1146
- * For the local providers, native transport is the deferred future bridge, not
1147
- * adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
1148
- * selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
1149
- * so no field is needed today. Flipping later is a one-line change — add
1150
- * `transport: 'native'` to the ollama/lmstudio entry returned below, which both
1151
- * seats' engines then share. No URL re-pointing is required: `seat_base_url`
1152
- * already yields the daemon-root URL the native `/api/chat` transport wants
1153
- * (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
1154
- * base-URL fix produces.
1155
- * Kept as a proven-once-then-reverted option so the transport stays a clean
1156
- * second variable in the model-vs-transport comparison. Flipping it today would
1157
- * also lose the reasoning: fascicle's native Ollama adapter drops
1158
- * `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
1159
- * thinking seat's reasoning would vanish from the terminal and the trajectory.
1160
- *
1161
- * Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
1162
- * when volley runs contained, so an in-container model client reaches the
1163
- * host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
1164
- * override is unset and this is a no-op. */
1165
- function seat_provider_config(provider, seat_url, env) {
1166
- const base_url = seat_base_url(provider, seat_url, env);
1167
- switch (provider) {
1168
- case "ollama": return { ollama: { base_url } };
1169
- case "lmstudio": return { lmstudio: { base_url } };
1170
- case "openrouter": return { openrouter: {
1171
- api_key: openrouter_api_key(env),
1172
- base_url,
1173
- transport: "native",
1174
- x_title: "volley"
1175
- } };
1176
- }
1177
- }
1178
- /** Per-run engine. `claude_cli` is always configured with `options.workspace`
1179
- * (the run's build root) as its session cwd; any URL-taking provider selected
1180
- * by the builder or critic role is wired in alongside it, at that seat's URL,
1181
- * so one engine drives both roles. An engine holds one URL per provider, so on
1182
- * a provider both seats share it carries the builder's; `create_seat_engines`
1183
- * gives the critic its own engine when its URL differs. Binary, auth mode,
1184
- * fallback provider URLs, and pricing come from the environment. */
1185
- function create_volley_engine(options) {
1186
- const env = options.env ?? process.env;
1187
- const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
1188
- if (auth_mode !== "auto" && auth_mode !== "oauth" && auth_mode !== "api_key") throw config_error(`VOLLEY_AUTH_MODE must be auto, oauth, or api_key; got: ${auth_mode}`);
1189
- const binary = env["VOLLEY_CLAUDE_BIN"];
1190
- const pricing_path = env["VOLLEY_PRICING_PATH"];
1191
- const providers = { claude_cli: {
1192
- default_cwd: options.workspace,
1193
- setting_sources: ["project", "local"],
1194
- auth_mode,
1195
- ...binary !== void 0 && binary.length > 0 ? { binary } : {},
1196
- ...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
1197
- } };
1198
- if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
1199
- if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
1200
- return create_engine({
1201
- providers,
1202
- ...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
1203
- });
1204
- }
1205
- /** One engine per seat, shared unless it can't be. fascicle fixes one base URL
1206
- * per provider when it builds an engine, so two seats on the same provider at
1207
- * different URLs need two engines: the critic's is derived from the builder's
1208
- * with `with_providers`, which keeps claude_cli, auth, and pricing and
1209
- * overrides only the shared provider's entry. Every other pairing, including a
1210
- * run that sets no seat URL at all, gets the one engine a run has always had. */
1211
- async function create_seat_engines(options) {
1212
- const env = options.env ?? process.env;
1213
- const builder = create_volley_engine(options);
1214
- const provider = options.critic_provider;
1215
- if (provider !== options.builder_provider || !takes_seat_url(provider) || seat_base_url(provider, options.builder_url ?? null, env) === seat_base_url(provider, options.critic_url ?? null, env)) return {
1216
- builder,
1217
- critic: builder
1218
- };
1219
- try {
1220
- return {
1221
- builder,
1222
- critic: builder.with_providers(seat_provider_config(provider, options.critic_url ?? null, env))
1223
- };
1224
- } catch (err) {
1225
- await builder.dispose();
1226
- throw err;
1227
- }
1228
- }
1229
- /** Dispose each distinct seat engine once. Both disposals start together, so
1230
- * one that fails doesn't keep the other from running. */
1231
- async function dispose_seat_engines(engines) {
1232
- const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
1233
- await Promise.all(distinct.map((engine) => engine.dispose()));
1234
- }
1235
- //#endregion
1236
1246
  //#region src/prewarm.ts
1237
1247
  /**
1238
1248
  * Best-effort Ollama model pre-load. A cold multi-GB model can spend minutes
@@ -1513,6 +1523,7 @@ function commit_identity(cwd) {
1513
1523
  key
1514
1524
  ], {
1515
1525
  cwd,
1526
+ env: command_env(process.env),
1516
1527
  encoding: "utf8"
1517
1528
  });
1518
1529
  return set.status === 0 && set.stdout.trim() !== "" ? [] : ["-c", `${key}=${value}`];
@@ -1540,7 +1551,10 @@ function git_checkpoint(root, message, excluded_dir = null) {
1540
1551
  "add",
1541
1552
  "-A",
1542
1553
  ...pathspec
1543
- ], { cwd: root });
1554
+ ], {
1555
+ cwd: root,
1556
+ env: command_env(process.env)
1557
+ });
1544
1558
  if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
1545
1559
  const commit = spawnSync("git", [
1546
1560
  ...commit_identity(root),
@@ -1548,7 +1562,10 @@ function git_checkpoint(root, message, excluded_dir = null) {
1548
1562
  "-m",
1549
1563
  message,
1550
1564
  "--no-verify"
1551
- ], { cwd: root });
1565
+ ], {
1566
+ cwd: root,
1567
+ env: command_env(process.env)
1568
+ });
1552
1569
  if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
1553
1570
  const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
1554
1571
  if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
@@ -1572,6 +1589,7 @@ function integrate_worktree(workspace, branch, message) {
1572
1589
  branch
1573
1590
  ], {
1574
1591
  cwd: workspace,
1592
+ env: command_env(process.env),
1575
1593
  encoding: "utf8"
1576
1594
  });
1577
1595
  if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
@@ -1584,6 +1602,7 @@ function integrate_worktree(workspace, branch, message) {
1584
1602
  "--no-verify"
1585
1603
  ], {
1586
1604
  cwd: workspace,
1605
+ env: command_env(process.env),
1587
1606
  encoding: "utf8"
1588
1607
  });
1589
1608
  if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
@@ -1693,6 +1712,7 @@ function report_worktree_fate(config, sink) {
1693
1712
  function git$1(cwd, args) {
1694
1713
  const result = spawnSync("git", args, {
1695
1714
  cwd,
1715
+ env: command_env(process.env),
1696
1716
  encoding: "utf8"
1697
1717
  });
1698
1718
  if (result.error !== void 0) throw config_error(`git is required for --worktree but could not run: ${String(result.error)}`);
@@ -1719,6 +1739,7 @@ function git_quiet(cwd, args) {
1719
1739
  try {
1720
1740
  const result = spawnSync("git", args, {
1721
1741
  cwd,
1742
+ env: command_env(process.env),
1722
1743
  encoding: "utf8"
1723
1744
  });
1724
1745
  return {
@@ -2231,6 +2252,7 @@ function git(root, args) {
2231
2252
  try {
2232
2253
  const result = spawnSync("git", args, {
2233
2254
  cwd: root,
2255
+ env: command_env(process.env),
2234
2256
  encoding: "utf8",
2235
2257
  maxBuffer: 33554432
2236
2258
  });
@@ -3441,13 +3463,14 @@ function report_changes(config, renderer, changes) {
3441
3463
  renderer.warn(`gate edit: ${who} ${String(changes.gate_edits.length)} file(s) that decide ${whose} — ${listed}${hidden > 0 ? ` (+${String(hidden)} more)` : ""}. A green check proves less when ${edits} the gate; the critic is told about this and the run summary records it.` + (config.fail_on_gate_edit ? " This run will end without success (--fail-on-gate-edit, exit 8)." : ""));
3442
3464
  }
3443
3465
  /** Run the configured check, checkride or a command, with the environment
3444
- * the caller set for it, else volley's own. */
3466
+ * the caller set for it, else volley's own less the seats' keys, since the
3467
+ * check runs code the builder wrote. */
3445
3468
  async function execute_check(deps, abort) {
3446
3469
  const { config } = deps;
3447
3470
  const root = build_root(config.workspace, config.worktree);
3448
3471
  const options = {
3449
3472
  abort,
3450
- ...deps.check?.env !== void 0 ? { env: deps.check.env } : {}
3473
+ env: deps.check?.env ?? command_env(process.env)
3451
3474
  };
3452
3475
  return config.check_resolved === "checkride" ? run_checkride({
3453
3476
  ...options,
@@ -3987,13 +4010,6 @@ const CRITIC_READS = /* @__PURE__ */ new Set([
3987
4010
  "list_files",
3988
4011
  "search"
3989
4012
  ]);
3990
- /** The environment the builder's commands and the check run with under acp:
3991
- * the agent's own, `PATH` included, less every variable a seat reads a
3992
- * credential from. */
3993
- function acp_command_env(env) {
3994
- const stripped = new Set(SEAT_CREDENTIAL_VARS);
3995
- return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0 && !stripped.has(entry[0])));
3996
- }
3997
4013
  /** Whether `tool` asks before a call with `input`. A set without the tool
3998
4014
  * asks, so a check never runs unasked by mistake. */
3999
4015
  async function asks_before(tool, input) {
@@ -4008,10 +4024,10 @@ async function asks_before(tool, input) {
4008
4024
  * the session exactly when the builder's `run_command` would ask before
4009
4025
  * running the same command. */
4010
4026
  function acp_seat_tools(session, options = {}) {
4011
- const command_env = acp_command_env(options.env ?? process.env);
4027
+ const env = command_env(options.env ?? process.env);
4012
4028
  const tools = workspace_tools(session, {
4013
4029
  ignore: [...IGNORED_DIRS],
4014
- command_env
4030
+ command_env: env
4015
4031
  });
4016
4032
  const run_command = tools.find((tool) => tool.name === "run_command");
4017
4033
  return {
@@ -4025,7 +4041,7 @@ function acp_seat_tools(session, options = {}) {
4025
4041
  tool_set: "acp"
4026
4042
  },
4027
4043
  check: {
4028
- env: command_env,
4044
+ env,
4029
4045
  on_approval: async (request) => await asks_before(run_command, request.input) ? session.approve(request) : true
4030
4046
  }
4031
4047
  };
@@ -5522,7 +5538,10 @@ async function probe_seat_endpoints(config, renderer, env, reachable) {
5522
5538
  /** Which required executables are not runnable on the current PATH. */
5523
5539
  function toolchain_missing(tools = REQUIRED_TOOLCHAIN) {
5524
5540
  return tools.filter((tool) => {
5525
- const result = spawnSync(tool, ["--version"], { stdio: "ignore" });
5541
+ const result = spawnSync(tool, ["--version"], {
5542
+ env: command_env(process.env),
5543
+ stdio: "ignore"
5544
+ });
5526
5545
  return result.error !== void 0 || result.status !== 0;
5527
5546
  });
5528
5547
  }
@@ -5531,7 +5550,10 @@ function toolchain_missing(tools = REQUIRED_TOOLCHAIN) {
5531
5550
  * run's job — worktree.ts). */
5532
5551
  function worktree_creatable(workspace) {
5533
5552
  const repo = resolve(workspace);
5534
- const version = spawnSync("git", ["--version"], { stdio: "ignore" });
5553
+ const version = spawnSync("git", ["--version"], {
5554
+ env: command_env(process.env),
5555
+ stdio: "ignore"
5556
+ });
5535
5557
  if (version.error !== void 0 || version.status !== 0) return {
5536
5558
  ok: false,
5537
5559
  detail: "git is not available"
@@ -5545,7 +5567,10 @@ function worktree_creatable(workspace) {
5545
5567
  repo,
5546
5568
  "rev-parse",
5547
5569
  "--is-inside-work-tree"
5548
- ], { encoding: "utf8" });
5570
+ ], {
5571
+ env: command_env(process.env),
5572
+ encoding: "utf8"
5573
+ });
5549
5574
  if (inside.status !== 0 || (inside.stdout ?? "").trim() !== "true") return {
5550
5575
  ok: false,
5551
5576
  detail: `${repo} is not a git work tree`
@@ -5618,6 +5643,7 @@ function run_checkride_doctor(workspace) {
5618
5643
  "doctor"
5619
5644
  ], {
5620
5645
  cwd: workspace,
5646
+ env: command_env(process.env),
5621
5647
  stdio: [
5622
5648
  "ignore",
5623
5649
  "ignore",