@robmclarty/volley 0.10.0 → 0.11.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/CHANGELOG.md +27 -0
- package/README.md +69 -8
- package/dist/cli.js +684 -296
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +44 -3
- package/dist/index.js +549 -246
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import { cac } from "cac";
|
|
6
6
|
import { serve_acp, to_session_updates, workspace_tools } from "fascicle/acp";
|
|
@@ -101,6 +101,438 @@ function step_countdown(max_steps, check) {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
//#endregion
|
|
104
|
+
//#region src/builder/prepare_step.ts
|
|
105
|
+
/** One hook that runs `hooks` in order, each on the request the one before it
|
|
106
|
+
* made, and reports no change when none of them made one. */
|
|
107
|
+
function compose_prepare_steps(hooks) {
|
|
108
|
+
return async ({ step_index, messages }) => {
|
|
109
|
+
let request = messages;
|
|
110
|
+
let changed = false;
|
|
111
|
+
for (const hook of hooks) {
|
|
112
|
+
const prepared = await hook({
|
|
113
|
+
step_index,
|
|
114
|
+
messages: request
|
|
115
|
+
});
|
|
116
|
+
if (prepared?.messages !== void 0) {
|
|
117
|
+
request = prepared.messages;
|
|
118
|
+
changed = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return changed ? { messages: request } : void 0;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/builder/trim.ts
|
|
126
|
+
/**
|
|
127
|
+
* Trimming a long tool-loop session's history (#7). Every turn resends the
|
|
128
|
+
* whole session, every earlier `read_file` result included, even for files the
|
|
129
|
+
* builder has rewritten since, so a long local session's requests grow
|
|
130
|
+
* linearly and each turn's latency grows with them: volley-41806476's input
|
|
131
|
+
* went from 2.6k to 57k tokens over 50 steps, and its turns from 3 s to as
|
|
132
|
+
* much as 120 s.
|
|
133
|
+
*
|
|
134
|
+
* Above a size the operator sets (`--builder-trim-history`), each turn's
|
|
135
|
+
* request replaces the older tool results with one-line stubs: a read of a
|
|
136
|
+
* file a later call wrote, edited, or read whole says so, and any other result
|
|
137
|
+
* names its call and size. The last `TRIM_KEEP_TURNS` turns, every assistant
|
|
138
|
+
* and user message, every failed call's result, and anything short stay as
|
|
139
|
+
* they are. It goes in through fascicle's `prepare_step`, which reshapes only
|
|
140
|
+
* what one turn sends, so the transcript stays as the model wrote it.
|
|
141
|
+
*
|
|
142
|
+
* A local runtime reuses the KV cache for the prefix a request shares with
|
|
143
|
+
* the last one, and rewriting an earlier message breaks that prefix, so the
|
|
144
|
+
* window is prefilled again. So the trim moves in coarse steps: it stubs
|
|
145
|
+
* everything before a turn boundary, boundaries fall every
|
|
146
|
+
* `TRIM_STRIDE_TURNS` turns, and the boundary moves on only when the trimmed
|
|
147
|
+
* request has grown past the size again. Between moves each request starts
|
|
148
|
+
* with the same bytes as the last, since a stub depends only on what came
|
|
149
|
+
* before its boundary and never on the step. The trim keeps no state:
|
|
150
|
+
* `trim_cutoff` works the boundary out again from the transcript each turn.
|
|
151
|
+
*/
|
|
152
|
+
/** A rough token count per character: the hook sees no token counts, so a
|
|
153
|
+
* request's size is estimated from its text. */
|
|
154
|
+
const CHARS_PER_TOKEN = 4;
|
|
155
|
+
/** What a message costs beyond its text: its role and framing. */
|
|
156
|
+
const MESSAGE_OVERHEAD_TOKENS = 8;
|
|
157
|
+
/** The calls that change a file, whose success makes an earlier read of it
|
|
158
|
+
* stale. Both volley's tools and fascicle's ACP tools use these names and a
|
|
159
|
+
* `path` input. */
|
|
160
|
+
const FILE_WRITERS = /* @__PURE__ */ new Set(["write_file", "edit_file"]);
|
|
161
|
+
const COMMAND_TOOLS = /* @__PURE__ */ new Set(["bash", "run_command"]);
|
|
162
|
+
function message_chars(message) {
|
|
163
|
+
if (typeof message.content === "string") return message.content.length;
|
|
164
|
+
let chars = 0;
|
|
165
|
+
for (const part of message.content) if (part.type === "text" || part.type === "reasoning") chars += part.text.length;
|
|
166
|
+
else if (part.type === "tool_call") chars += part.name.length + (JSON.stringify(part.input) ?? "").length;
|
|
167
|
+
return chars;
|
|
168
|
+
}
|
|
169
|
+
/** A request's size in tokens, estimated from its characters. */
|
|
170
|
+
function estimate_tokens(messages) {
|
|
171
|
+
let tokens = 0;
|
|
172
|
+
for (const message of messages) tokens += Math.ceil(message_chars(message) / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD_TOKENS;
|
|
173
|
+
return tokens;
|
|
174
|
+
}
|
|
175
|
+
function call_input(input) {
|
|
176
|
+
if (typeof input !== "object" || input === null) return {
|
|
177
|
+
path: null,
|
|
178
|
+
whole: true
|
|
179
|
+
};
|
|
180
|
+
const fields = input;
|
|
181
|
+
return {
|
|
182
|
+
path: typeof fields["path"] === "string" ? posix.normalize(fields["path"]) : null,
|
|
183
|
+
whole: fields["offset"] === void 0 && fields["limit"] === void 0
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** Each tool call's input, by call id, from the assistant messages in
|
|
187
|
+
* `messages`. */
|
|
188
|
+
function call_inputs(messages) {
|
|
189
|
+
const inputs = /* @__PURE__ */ new Map();
|
|
190
|
+
for (const message of messages) {
|
|
191
|
+
if (message.role !== "assistant" || typeof message.content === "string") continue;
|
|
192
|
+
for (const part of message.content) if (part.type === "tool_call") inputs.set(part.id, call_input(part.input));
|
|
193
|
+
}
|
|
194
|
+
return inputs;
|
|
195
|
+
}
|
|
196
|
+
/** Whether this result is one whose call changed its file or read it whole. */
|
|
197
|
+
function supersession_of(message, input) {
|
|
198
|
+
if (input === void 0 || input.path === null || message.is_error === true) return null;
|
|
199
|
+
if (FILE_WRITERS.has(message.name)) return message.content.startsWith("error:") ? null : {
|
|
200
|
+
tool: message.name,
|
|
201
|
+
changed: true
|
|
202
|
+
};
|
|
203
|
+
if (message.name === "read_file" && input.whole) return {
|
|
204
|
+
tool: "read_file",
|
|
205
|
+
changed: false
|
|
206
|
+
};
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
function exit_code_of(content) {
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(content);
|
|
212
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
213
|
+
const code = parsed["exit_code"];
|
|
214
|
+
if (typeof code === "number") return code;
|
|
215
|
+
}
|
|
216
|
+
} catch {}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
function stub(message, input, later) {
|
|
220
|
+
const size = `${String(message.content.length)} chars`;
|
|
221
|
+
if (later !== void 0 && input !== void 0 && input.path !== null) return later.changed ? `[volley] read_file ${input.path} (${size}) elided: a later ${later.tool} changed this file. Read it again for its current content.` : `[volley] read_file ${input.path} (${size}) elided: it was read again later in this session.`;
|
|
222
|
+
const exit_code = COMMAND_TOOLS.has(message.name) ? exit_code_of(message.content) : null;
|
|
223
|
+
const detail = exit_code === null ? size : `${size}, exit ${String(exit_code)}`;
|
|
224
|
+
return `[volley] ${message.name} output (${detail}) elided from an earlier turn. Run it again if you need it.`;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The request with every tool result before `cutoff` that is long enough to
|
|
228
|
+
* matter and didn't fail replaced by a stub. Every message keeps its place,
|
|
229
|
+
* role, id, and name. A stub reads only what comes before `cutoff`, so the
|
|
230
|
+
* same transcript prefix always gives the same bytes.
|
|
231
|
+
*/
|
|
232
|
+
function trim_view(messages, cutoff) {
|
|
233
|
+
const window = messages.slice(0, cutoff);
|
|
234
|
+
const inputs = call_inputs(window);
|
|
235
|
+
const next_on_path = /* @__PURE__ */ new Map();
|
|
236
|
+
const later_for = /* @__PURE__ */ new Map();
|
|
237
|
+
for (let i = window.length - 1; i >= 0; i -= 1) {
|
|
238
|
+
const message = window[i];
|
|
239
|
+
if (message?.role !== "tool") continue;
|
|
240
|
+
const input = inputs.get(message.tool_call_id);
|
|
241
|
+
const path = input?.path ?? null;
|
|
242
|
+
if (path === null) continue;
|
|
243
|
+
if (message.name === "read_file") {
|
|
244
|
+
const later = next_on_path.get(path);
|
|
245
|
+
if (later !== void 0) later_for.set(i, later);
|
|
246
|
+
}
|
|
247
|
+
const supersedes = supersession_of(message, input);
|
|
248
|
+
if (supersedes !== null) next_on_path.set(path, supersedes);
|
|
249
|
+
}
|
|
250
|
+
return messages.map((message, i) => {
|
|
251
|
+
if (i >= cutoff || message.role !== "tool" || message.is_error === true) return message;
|
|
252
|
+
if (message.content.length <= 200) return message;
|
|
253
|
+
return {
|
|
254
|
+
...message,
|
|
255
|
+
content: stub(message, inputs.get(message.tool_call_id), later_for.get(i))
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Where to cut this turn's request, as a message index, or null to send it
|
|
261
|
+
* whole. Boundaries are the starts of turns `TRIM_STRIDE_TURNS`,
|
|
262
|
+
* `2 * TRIM_STRIDE_TURNS`, and so on, short of the last `TRIM_KEEP_TURNS`.
|
|
263
|
+
* The cut moves to the next boundary only while the request, trimmed at the
|
|
264
|
+
* last one, is still over `threshold`. A trimmed request only grows as the
|
|
265
|
+
* session goes on, so a boundary once taken is kept.
|
|
266
|
+
*/
|
|
267
|
+
function trim_cutoff(messages, threshold) {
|
|
268
|
+
const turn_starts = messages.flatMap((message, i) => message.role === "assistant" ? [i] : []);
|
|
269
|
+
let cutoff = null;
|
|
270
|
+
for (let turn = 8; turn <= turn_starts.length - 6; turn += 8) {
|
|
271
|
+
if (estimate_tokens(cutoff === null ? messages : trim_view(messages, cutoff)) <= threshold) break;
|
|
272
|
+
cutoff = turn_starts[turn] ?? null;
|
|
273
|
+
}
|
|
274
|
+
return cutoff;
|
|
275
|
+
}
|
|
276
|
+
/** The `prepare_step` hook that trims a request estimated above `threshold`
|
|
277
|
+
* tokens. */
|
|
278
|
+
function step_trim(threshold) {
|
|
279
|
+
return ({ messages }) => {
|
|
280
|
+
const cutoff = trim_cutoff(messages, threshold);
|
|
281
|
+
return cutoff === null ? void 0 : { messages: trim_view(messages, cutoff) };
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region src/types.ts
|
|
286
|
+
function config_error(message) {
|
|
287
|
+
return Object.assign(new Error(message), { kind: "config_error" });
|
|
288
|
+
}
|
|
289
|
+
function check_error(message) {
|
|
290
|
+
return Object.assign(new Error(message), { kind: "check_error" });
|
|
291
|
+
}
|
|
292
|
+
/** Wraps a provider/schema failure with the phase and iteration it hit. */
|
|
293
|
+
function phase_error(phase, iteration, cause) {
|
|
294
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
295
|
+
return Object.assign(/* @__PURE__ */ new Error(`${phase} failed on iteration ${iteration}: ${detail}`), {
|
|
296
|
+
kind: "phase_error",
|
|
297
|
+
phase,
|
|
298
|
+
iteration,
|
|
299
|
+
cause
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
function error_kind(err) {
|
|
303
|
+
if (typeof err === "object" && err !== null && "kind" in err) {
|
|
304
|
+
const kind = err.kind;
|
|
305
|
+
return typeof kind === "string" ? kind : null;
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region src/engine.ts
|
|
311
|
+
/**
|
|
312
|
+
* The ONLY module that calls fascicle's `create_engine`. Every
|
|
313
|
+
* other module receives an `Engine`-shaped value, which keeps the engine
|
|
314
|
+
* mockable in tests and confines provider knowledge to one seam.
|
|
315
|
+
*/
|
|
316
|
+
/** LM Studio's OpenAI-compatible server and Ollama's server-root defaults.
|
|
317
|
+
* The Ollama URL is the server root, NOT `…/api`: `ai-sdk-ollama` appends
|
|
318
|
+
* `/api/...` itself, so a base URL ending in `/api` requests `/api/api/chat`
|
|
319
|
+
* and 404s. */
|
|
320
|
+
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
321
|
+
const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
|
|
322
|
+
/** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
|
|
323
|
+
const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
|
|
324
|
+
/** The variable an `openrouter` seat's API key is read from. */
|
|
325
|
+
const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
|
|
326
|
+
/** The variable a `claude_cli` seat's API key is read from, which the Claude
|
|
327
|
+
* CLI also reads on its own. */
|
|
328
|
+
const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
|
|
329
|
+
/** Every variable a seat reads a credential from. Every process volley starts
|
|
330
|
+
* runs without them (the check, the builder's commands, and volley's own git
|
|
331
|
+
* and checkride calls, which run hooks and filters the builder can write), so
|
|
332
|
+
* nothing the builder writes can read the keys its seats spend. */
|
|
333
|
+
const SEAT_CREDENTIAL_VARS = [OPENROUTER_KEY_VAR, ANTHROPIC_KEY_VAR];
|
|
334
|
+
/** The environment every process volley starts runs with: `env`, `PATH`
|
|
335
|
+
* included, less every variable a seat reads a credential from. */
|
|
336
|
+
function command_env(env) {
|
|
337
|
+
const stripped = new Set(SEAT_CREDENTIAL_VARS);
|
|
338
|
+
return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0 && !stripped.has(entry[0])));
|
|
339
|
+
}
|
|
340
|
+
/** Loopback authorities the container→host crossing rewrites. A base URL
|
|
341
|
+
* pointing at one of these is local to wherever volley runs; contained, that is
|
|
342
|
+
* *inside* the sandbox container, where the host LLM daemon is unreachable
|
|
343
|
+
* except across the boundary at `VOLLEY_MODEL_HOST`. `URL.hostname` keeps an
|
|
344
|
+
* IPv6 literal's brackets, so the IPv6 loopback is spelled `[::1]`. */
|
|
345
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
346
|
+
"localhost",
|
|
347
|
+
"127.0.0.1",
|
|
348
|
+
"[::1]",
|
|
349
|
+
"0.0.0.0"
|
|
350
|
+
]);
|
|
351
|
+
/** Cross a loopback base URL to the host LLM endpoint. When volley
|
|
352
|
+
* runs inside its sandbox container the local model daemon is on the *host*, so
|
|
353
|
+
* the example's `docker run` injects `VOLLEY_MODEL_HOST=host.docker.internal`
|
|
354
|
+
* (the host-gateway allowlist target; see `sandbox_invocation` in
|
|
355
|
+
* `src/sandbox.ts`). This normalizer *consumes that override* and swaps a
|
|
356
|
+
* loopback host for the gateway, so an unchanged `http://localhost:11434` still
|
|
357
|
+
* reaches the host from in-container. A non-loopback URL (a real remote daemon)
|
|
358
|
+
* is left untouched, and an unset `VOLLEY_MODEL_HOST` (the host / unsandboxed
|
|
359
|
+
* path) is a no-op — so the default and all-Claude paths are unaffected. */
|
|
360
|
+
function cross_to_host_gateway(base_url, env) {
|
|
361
|
+
const host = env["VOLLEY_MODEL_HOST"];
|
|
362
|
+
if (host === void 0 || host === "") return base_url;
|
|
363
|
+
let parsed;
|
|
364
|
+
try {
|
|
365
|
+
parsed = new URL(base_url);
|
|
366
|
+
} catch {
|
|
367
|
+
return base_url;
|
|
368
|
+
}
|
|
369
|
+
if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
|
|
370
|
+
parsed.hostname = host;
|
|
371
|
+
return parsed.toString().replace(/\/$/, "");
|
|
372
|
+
}
|
|
373
|
+
function strip_trailing_slashes(url) {
|
|
374
|
+
return url.replace(/\/+$/, "");
|
|
375
|
+
}
|
|
376
|
+
const SEAT_URL_SOURCES = {
|
|
377
|
+
ollama: {
|
|
378
|
+
env: "VOLLEY_OLLAMA_URL",
|
|
379
|
+
default_url: DEFAULT_OLLAMA_URL,
|
|
380
|
+
normalize: (url) => strip_trailing_slashes(url).replace(/\/api$/, "")
|
|
381
|
+
},
|
|
382
|
+
lmstudio: {
|
|
383
|
+
env: "VOLLEY_LMSTUDIO_URL",
|
|
384
|
+
default_url: DEFAULT_LMSTUDIO_URL,
|
|
385
|
+
normalize: strip_trailing_slashes
|
|
386
|
+
},
|
|
387
|
+
openrouter: {
|
|
388
|
+
env: "VOLLEY_OPENROUTER_URL",
|
|
389
|
+
default_url: DEFAULT_OPENROUTER_URL,
|
|
390
|
+
normalize: strip_trailing_slashes
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
/** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
|
|
394
|
+
* so get their endpoint probed by `--dry-run`. */
|
|
395
|
+
const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
|
|
396
|
+
/** Whether a seat on `provider` reaches its model at a base URL volley
|
|
397
|
+
* configures, and so takes a URL of its own. */
|
|
398
|
+
function takes_seat_url(provider) {
|
|
399
|
+
return SEAT_URL_PROVIDERS.includes(provider);
|
|
400
|
+
}
|
|
401
|
+
/** An absent, empty, or all-whitespace value counts as unset. */
|
|
402
|
+
function present(value) {
|
|
403
|
+
const trimmed = value?.trim() ?? "";
|
|
404
|
+
return trimmed === "" ? null : trimmed;
|
|
405
|
+
}
|
|
406
|
+
/** The base URL a seat's model calls go to: the seat's own URL, else the
|
|
407
|
+
* provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
|
|
408
|
+
* `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
|
|
409
|
+
* other seat's URL. The result is normalized, then a loopback authority is
|
|
410
|
+
* crossed to the host LLM endpoint when volley runs contained. Both seats can
|
|
411
|
+
* be loopback URLs on different ports, and each crosses on its own port. */
|
|
412
|
+
function seat_base_url(provider, seat_url, env) {
|
|
413
|
+
const source = SEAT_URL_SOURCES[provider];
|
|
414
|
+
const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
|
|
415
|
+
return cross_to_host_gateway(source.normalize(raw), env);
|
|
416
|
+
}
|
|
417
|
+
/** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
|
|
418
|
+
* or blank key is a config error naming the variable, which `resolve_config`
|
|
419
|
+
* raises before any work, because fascicle's own "requires a non-empty
|
|
420
|
+
* api_key" doesn't say where the key comes from. */
|
|
421
|
+
function openrouter_api_key(env) {
|
|
422
|
+
const key = present(env[OPENROUTER_KEY_VAR]);
|
|
423
|
+
if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
|
|
424
|
+
return key;
|
|
425
|
+
}
|
|
426
|
+
function load_pricing_overrides(path) {
|
|
427
|
+
if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
|
|
428
|
+
try {
|
|
429
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
430
|
+
} catch (err) {
|
|
431
|
+
throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/** A seat's provider entry, with the base URL resolved for that seat. The ai-sdk
|
|
435
|
+
* peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
|
|
436
|
+
* fascicle only when the provider actually runs. The entry is complete, because
|
|
437
|
+
* `Engine.with_providers` replaces a provider's whole entry: whatever this
|
|
438
|
+
* returns is all a seat's derived engine knows about the provider, so an
|
|
439
|
+
* `openrouter` entry carries its key and its transport along with its URL.
|
|
440
|
+
*
|
|
441
|
+
* `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
|
|
442
|
+
* so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
|
|
443
|
+
* traffic to volley.
|
|
444
|
+
*
|
|
445
|
+
* For the local providers, native transport is the deferred future bridge, not
|
|
446
|
+
* adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
|
|
447
|
+
* selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
|
|
448
|
+
* so no field is needed today. Flipping later is a one-line change — add
|
|
449
|
+
* `transport: 'native'` to the ollama/lmstudio entry returned below, which both
|
|
450
|
+
* seats' engines then share. No URL re-pointing is required: `seat_base_url`
|
|
451
|
+
* already yields the daemon-root URL the native `/api/chat` transport wants
|
|
452
|
+
* (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
|
|
453
|
+
* base-URL fix produces.
|
|
454
|
+
* Kept as a proven-once-then-reverted option so the transport stays a clean
|
|
455
|
+
* second variable in the model-vs-transport comparison. Flipping it today would
|
|
456
|
+
* also lose the reasoning: fascicle's native Ollama adapter drops
|
|
457
|
+
* `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
|
|
458
|
+
* thinking seat's reasoning would vanish from the terminal and the trajectory.
|
|
459
|
+
*
|
|
460
|
+
* Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
|
|
461
|
+
* when volley runs contained, so an in-container model client reaches the
|
|
462
|
+
* host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
|
|
463
|
+
* override is unset and this is a no-op. */
|
|
464
|
+
function seat_provider_config(provider, seat_url, env) {
|
|
465
|
+
const base_url = seat_base_url(provider, seat_url, env);
|
|
466
|
+
switch (provider) {
|
|
467
|
+
case "ollama": return { ollama: { base_url } };
|
|
468
|
+
case "lmstudio": return { lmstudio: { base_url } };
|
|
469
|
+
case "openrouter": return { openrouter: {
|
|
470
|
+
api_key: openrouter_api_key(env),
|
|
471
|
+
base_url,
|
|
472
|
+
transport: "native",
|
|
473
|
+
x_title: "volley"
|
|
474
|
+
} };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/** Per-run engine. `claude_cli` is always configured with `options.workspace`
|
|
478
|
+
* (the run's build root) as its session cwd; any URL-taking provider selected
|
|
479
|
+
* by the builder or critic role is wired in alongside it, at that seat's URL,
|
|
480
|
+
* so one engine drives both roles. An engine holds one URL per provider, so on
|
|
481
|
+
* a provider both seats share it carries the builder's; `create_seat_engines`
|
|
482
|
+
* gives the critic its own engine when its URL differs. Binary, auth mode,
|
|
483
|
+
* fallback provider URLs, and pricing come from the environment. */
|
|
484
|
+
function create_volley_engine(options) {
|
|
485
|
+
const env = options.env ?? process.env;
|
|
486
|
+
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
487
|
+
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}`);
|
|
488
|
+
const binary = env["VOLLEY_CLAUDE_BIN"];
|
|
489
|
+
const pricing_path = env["VOLLEY_PRICING_PATH"];
|
|
490
|
+
const providers = { claude_cli: {
|
|
491
|
+
default_cwd: options.workspace,
|
|
492
|
+
setting_sources: ["project", "local"],
|
|
493
|
+
auth_mode,
|
|
494
|
+
omit_env: SEAT_CREDENTIAL_VARS,
|
|
495
|
+
...binary !== void 0 && binary.length > 0 ? { binary } : {},
|
|
496
|
+
...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
|
|
497
|
+
} };
|
|
498
|
+
if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
|
|
499
|
+
if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
|
|
500
|
+
return create_engine({
|
|
501
|
+
providers,
|
|
502
|
+
...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
/** One engine per seat, shared unless it can't be. fascicle fixes one base URL
|
|
506
|
+
* per provider when it builds an engine, so two seats on the same provider at
|
|
507
|
+
* different URLs need two engines: the critic's is derived from the builder's
|
|
508
|
+
* with `with_providers`, which keeps claude_cli, auth, and pricing and
|
|
509
|
+
* overrides only the shared provider's entry. Every other pairing, including a
|
|
510
|
+
* run that sets no seat URL at all, gets the one engine a run has always had. */
|
|
511
|
+
async function create_seat_engines(options) {
|
|
512
|
+
const env = options.env ?? process.env;
|
|
513
|
+
const builder = create_volley_engine(options);
|
|
514
|
+
const provider = options.critic_provider;
|
|
515
|
+
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 {
|
|
516
|
+
builder,
|
|
517
|
+
critic: builder
|
|
518
|
+
};
|
|
519
|
+
try {
|
|
520
|
+
return {
|
|
521
|
+
builder,
|
|
522
|
+
critic: builder.with_providers(seat_provider_config(provider, options.critic_url ?? null, env))
|
|
523
|
+
};
|
|
524
|
+
} catch (err) {
|
|
525
|
+
await builder.dispose();
|
|
526
|
+
throw err;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/** Dispose each distinct seat engine once. Both disposals start together, so
|
|
530
|
+
* one that fails doesn't keep the other from running. */
|
|
531
|
+
async function dispose_seat_engines(engines) {
|
|
532
|
+
const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
|
|
533
|
+
await Promise.all(distinct.map((engine) => engine.dispose()));
|
|
534
|
+
}
|
|
535
|
+
//#endregion
|
|
104
536
|
//#region src/workspace_tools.ts
|
|
105
537
|
/**
|
|
106
538
|
* Shared workspace tools: `contain()` path confinement plus the read-only
|
|
@@ -564,14 +996,17 @@ async function run_fetch(raw, ctx, config) {
|
|
|
564
996
|
* containment this runs *inside* volley's hardened container against the
|
|
565
997
|
* bind-mounted worktree — the container is the isolation boundary, no
|
|
566
998
|
* `docker exec` hop — and it is likewise the executor on the
|
|
567
|
-
* `--allow-unsandboxed-builder` host escape hatch.
|
|
568
|
-
*
|
|
999
|
+
* `--allow-unsandboxed-builder` host escape hatch. A command runs with
|
|
1000
|
+
* volley's environment less the seats' keys, so nothing the builder runs can
|
|
1001
|
+
* read the key its seat spends. `status` is null when a signal (the timeout
|
|
1002
|
+
* SIGKILL) killed the process.
|
|
569
1003
|
*/
|
|
570
1004
|
function host_bash_executor(cwd) {
|
|
571
1005
|
return (command, options) => {
|
|
572
1006
|
const result = spawnSync(command, {
|
|
573
1007
|
shell: true,
|
|
574
1008
|
cwd,
|
|
1009
|
+
env: command_env(process.env),
|
|
575
1010
|
encoding: "utf8",
|
|
576
1011
|
timeout: options.timeout_ms,
|
|
577
1012
|
killSignal: "SIGKILL",
|
|
@@ -673,31 +1108,6 @@ function finish_tool() {
|
|
|
673
1108
|
};
|
|
674
1109
|
}
|
|
675
1110
|
//#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
1111
|
//#region src/check/checkride.ts
|
|
702
1112
|
/**
|
|
703
1113
|
* checkride runner: spawn `pnpm exec checkride --json --digest`, gate on the
|
|
@@ -734,7 +1144,7 @@ function spawn_capture(cmd, args, options) {
|
|
|
734
1144
|
return new Promise((resolve_promise, reject) => {
|
|
735
1145
|
const proc = spawn(cmd, args, {
|
|
736
1146
|
cwd: options.cwd,
|
|
737
|
-
|
|
1147
|
+
env: options.env ?? command_env(process.env),
|
|
738
1148
|
detached: process.platform !== "win32",
|
|
739
1149
|
stdio: [
|
|
740
1150
|
"ignore",
|
|
@@ -940,6 +1350,60 @@ function rejected_check(runner) {
|
|
|
940
1350
|
};
|
|
941
1351
|
}
|
|
942
1352
|
//#endregion
|
|
1353
|
+
//#region src/claude_cli_permissions.ts
|
|
1354
|
+
/** Whether a seat this run drives runs on the Claude CLI. A critique has no
|
|
1355
|
+
* builder, whatever its provider resolved to. */
|
|
1356
|
+
function uses_claude_cli(config) {
|
|
1357
|
+
return config.mode !== "critique" && config.builder_provider === "claude_cli" || config.critic_provider === "claude_cli";
|
|
1358
|
+
}
|
|
1359
|
+
const BUILDER_ALLOWED_TOOLS = [
|
|
1360
|
+
"Read(/**)",
|
|
1361
|
+
"Edit(/**)",
|
|
1362
|
+
"Bash",
|
|
1363
|
+
"WebFetch"
|
|
1364
|
+
];
|
|
1365
|
+
const CRITIC_ALLOWED_TOOLS = ["Read(/**)"];
|
|
1366
|
+
const CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
|
|
1367
|
+
const PERMISSION_BLOCKS = { permissions: {
|
|
1368
|
+
blockWritesOutsideWorkingDirectories: true,
|
|
1369
|
+
blockReadsOutsideWorkingDirectories: true
|
|
1370
|
+
} };
|
|
1371
|
+
/** The CLI flags that turn on `PERMISSION_BLOCKS`. */
|
|
1372
|
+
function settings_args() {
|
|
1373
|
+
return ["--settings", JSON.stringify(PERMISSION_BLOCKS)];
|
|
1374
|
+
}
|
|
1375
|
+
const MIN_CLAUDE_WRITE_BLOCK = [
|
|
1376
|
+
2,
|
|
1377
|
+
1,
|
|
1378
|
+
314
|
|
1379
|
+
];
|
|
1380
|
+
const MIN_CLAUDE_READ_BLOCK = [
|
|
1381
|
+
2,
|
|
1382
|
+
1,
|
|
1383
|
+
257
|
|
1384
|
+
];
|
|
1385
|
+
/** The version `claude --version` printed (`2.1.316 (Claude Code)`), or null
|
|
1386
|
+
* when it printed something else. */
|
|
1387
|
+
function parse_claude_version(stdout) {
|
|
1388
|
+
const match = /^\s*(\d+)\.(\d+)\.(\d+)\b/.exec(stdout);
|
|
1389
|
+
if (match === null) return null;
|
|
1390
|
+
return [
|
|
1391
|
+
Number(match[1]),
|
|
1392
|
+
Number(match[2]),
|
|
1393
|
+
Number(match[3])
|
|
1394
|
+
];
|
|
1395
|
+
}
|
|
1396
|
+
function version_below(version, min) {
|
|
1397
|
+
const [major, minor, patch] = version;
|
|
1398
|
+
const [min_major, min_minor, min_patch] = min;
|
|
1399
|
+
if (major !== min_major) return major < min_major;
|
|
1400
|
+
if (minor !== min_minor) return minor < min_minor;
|
|
1401
|
+
return patch < min_patch;
|
|
1402
|
+
}
|
|
1403
|
+
function format_version(version) {
|
|
1404
|
+
return version.join(".");
|
|
1405
|
+
}
|
|
1406
|
+
//#endregion
|
|
943
1407
|
//#region src/cost.ts
|
|
944
1408
|
/**
|
|
945
1409
|
* Cost and usage accumulation. All pricing knowledge lives in
|
|
@@ -995,14 +1459,77 @@ function phase_record(result, model, duration_ms = result.timing?.duration_ms ??
|
|
|
995
1459
|
...rate !== void 0 ? { throughput: rate } : {}
|
|
996
1460
|
};
|
|
997
1461
|
}
|
|
1462
|
+
const NS_PER_MS = 1e6;
|
|
1463
|
+
function ns_field(payload, name) {
|
|
1464
|
+
const value = payload[name];
|
|
1465
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
1466
|
+
}
|
|
1467
|
+
/** A turn's prefill and decode times: Ollama's own durations when it reported
|
|
1468
|
+
* them (in ns), else the time to the first streamed chunk, else none. */
|
|
1469
|
+
function step_split(step) {
|
|
1470
|
+
const reported = step.provider_reported?.["ollama"];
|
|
1471
|
+
if (typeof reported === "object" && reported !== null) {
|
|
1472
|
+
const ollama = reported;
|
|
1473
|
+
const evaluated = ns_field(ollama, "eval_duration");
|
|
1474
|
+
const prompt = ns_field(ollama, "prompt_eval_duration");
|
|
1475
|
+
if (evaluated !== void 0 && prompt !== void 0) return {
|
|
1476
|
+
prefill_ms: Math.round(prompt / NS_PER_MS),
|
|
1477
|
+
decode_ms: Math.round(evaluated / NS_PER_MS),
|
|
1478
|
+
timing_basis: "ollama_reported"
|
|
1479
|
+
};
|
|
1480
|
+
const total = ns_field(ollama, "total_duration");
|
|
1481
|
+
const load = ns_field(ollama, "load_duration");
|
|
1482
|
+
if (evaluated !== void 0 && total !== void 0 && load !== void 0 && total >= load + evaluated) return {
|
|
1483
|
+
prefill_ms: Math.round((total - load - evaluated) / NS_PER_MS),
|
|
1484
|
+
decode_ms: Math.round(evaluated / NS_PER_MS),
|
|
1485
|
+
timing_basis: "ollama_derived"
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
const timing = step.timing;
|
|
1489
|
+
if (timing?.first_chunk_ms !== void 0 && timing.whole_tool_call !== true) return {
|
|
1490
|
+
prefill_ms: timing.first_chunk_ms,
|
|
1491
|
+
decode_ms: Math.max(0, timing.duration_ms - timing.first_chunk_ms),
|
|
1492
|
+
timing_basis: "first_chunk"
|
|
1493
|
+
};
|
|
1494
|
+
return { timing_basis: "none" };
|
|
1495
|
+
}
|
|
1496
|
+
/** How much a tool call's result put into the transcript: its output as the
|
|
1497
|
+
* loop serializes it, or the error text it fed back. */
|
|
1498
|
+
function output_chars(call) {
|
|
1499
|
+
if (call.output === void 0) return call.error?.message.length ?? 0;
|
|
1500
|
+
return typeof call.output === "string" ? call.output.length : (JSON.stringify(call.output) ?? "").length;
|
|
1501
|
+
}
|
|
1502
|
+
/** Each turn of a session as the archive records it, or undefined when the
|
|
1503
|
+
* result has none. */
|
|
1504
|
+
function step_records(result) {
|
|
1505
|
+
if (result.steps.length === 0) return void 0;
|
|
1506
|
+
return result.steps.map((step) => ({
|
|
1507
|
+
index: step.index,
|
|
1508
|
+
input_tokens: step.usage.input_tokens,
|
|
1509
|
+
output_tokens: step.usage.output_tokens,
|
|
1510
|
+
duration_ms: step.timing?.duration_ms ?? 0,
|
|
1511
|
+
...step_split(step),
|
|
1512
|
+
finish_reason: step.finish_reason,
|
|
1513
|
+
...step.truncated === true ? { truncated: true } : {},
|
|
1514
|
+
tool_calls: step.tool_calls.map((call) => ({
|
|
1515
|
+
name: call.name,
|
|
1516
|
+
output_chars: output_chars(call)
|
|
1517
|
+
}))
|
|
1518
|
+
}));
|
|
1519
|
+
}
|
|
998
1520
|
/** Fold one phase's result into the loop state. Unknown cost records `null`
|
|
999
|
-
* for the phase and warns once per run — a run never fails over pricing.
|
|
1521
|
+
* for the phase and warns once per run — a run never fails over pricing. The
|
|
1522
|
+
* builder's record also carries its session's turns. */
|
|
1000
1523
|
function accumulate(state, role, result, model, duration_ms) {
|
|
1001
1524
|
const record = phase_record(result, model, duration_ms);
|
|
1525
|
+
const steps = role === "builder" ? step_records(result) : void 0;
|
|
1002
1526
|
const cost = record.cost_usd ?? 0;
|
|
1003
1527
|
return {
|
|
1004
1528
|
...state,
|
|
1005
|
-
[role]:
|
|
1529
|
+
[role]: steps !== void 0 ? {
|
|
1530
|
+
...record,
|
|
1531
|
+
steps
|
|
1532
|
+
} : record,
|
|
1006
1533
|
total_usage: add_usage(state.total_usage, result.usage),
|
|
1007
1534
|
total_cost_usd: state.total_cost_usd + cost,
|
|
1008
1535
|
iteration_cost_usd: state.iteration_cost_usd + cost,
|
|
@@ -1015,224 +1542,6 @@ function cost_cap_hit(config, state) {
|
|
|
1015
1542
|
return config.max_cost_usd !== null && state.total_cost_usd >= config.max_cost_usd;
|
|
1016
1543
|
}
|
|
1017
1544
|
//#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
1545
|
//#region src/prewarm.ts
|
|
1237
1546
|
/**
|
|
1238
1547
|
* Best-effort Ollama model pre-load. A cold multi-GB model can spend minutes
|
|
@@ -1468,6 +1777,7 @@ function write_resolved_config(config, env = process.env) {
|
|
|
1468
1777
|
builder_max_steps: config.builder_max_steps,
|
|
1469
1778
|
builder_max_tool_calls: config.builder_max_tool_calls,
|
|
1470
1779
|
builder_max_output_tokens: config.builder_max_output_tokens,
|
|
1780
|
+
builder_trim_history: config.builder_trim_history,
|
|
1471
1781
|
critic_model: config.critic_model,
|
|
1472
1782
|
critic_provider: config.critic_provider,
|
|
1473
1783
|
critic_url: config.critic_url,
|
|
@@ -1513,6 +1823,7 @@ function commit_identity(cwd) {
|
|
|
1513
1823
|
key
|
|
1514
1824
|
], {
|
|
1515
1825
|
cwd,
|
|
1826
|
+
env: command_env(process.env),
|
|
1516
1827
|
encoding: "utf8"
|
|
1517
1828
|
});
|
|
1518
1829
|
return set.status === 0 && set.stdout.trim() !== "" ? [] : ["-c", `${key}=${value}`];
|
|
@@ -1540,7 +1851,10 @@ function git_checkpoint(root, message, excluded_dir = null) {
|
|
|
1540
1851
|
"add",
|
|
1541
1852
|
"-A",
|
|
1542
1853
|
...pathspec
|
|
1543
|
-
], {
|
|
1854
|
+
], {
|
|
1855
|
+
cwd: root,
|
|
1856
|
+
env: command_env(process.env)
|
|
1857
|
+
});
|
|
1544
1858
|
if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
|
|
1545
1859
|
const commit = spawnSync("git", [
|
|
1546
1860
|
...commit_identity(root),
|
|
@@ -1548,7 +1862,10 @@ function git_checkpoint(root, message, excluded_dir = null) {
|
|
|
1548
1862
|
"-m",
|
|
1549
1863
|
message,
|
|
1550
1864
|
"--no-verify"
|
|
1551
|
-
], {
|
|
1865
|
+
], {
|
|
1866
|
+
cwd: root,
|
|
1867
|
+
env: command_env(process.env)
|
|
1868
|
+
});
|
|
1552
1869
|
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
1553
1870
|
const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
|
|
1554
1871
|
if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
|
|
@@ -1572,6 +1889,7 @@ function integrate_worktree(workspace, branch, message) {
|
|
|
1572
1889
|
branch
|
|
1573
1890
|
], {
|
|
1574
1891
|
cwd: workspace,
|
|
1892
|
+
env: command_env(process.env),
|
|
1575
1893
|
encoding: "utf8"
|
|
1576
1894
|
});
|
|
1577
1895
|
if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
|
|
@@ -1584,6 +1902,7 @@ function integrate_worktree(workspace, branch, message) {
|
|
|
1584
1902
|
"--no-verify"
|
|
1585
1903
|
], {
|
|
1586
1904
|
cwd: workspace,
|
|
1905
|
+
env: command_env(process.env),
|
|
1587
1906
|
encoding: "utf8"
|
|
1588
1907
|
});
|
|
1589
1908
|
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
@@ -1693,6 +2012,7 @@ function report_worktree_fate(config, sink) {
|
|
|
1693
2012
|
function git$1(cwd, args) {
|
|
1694
2013
|
const result = spawnSync("git", args, {
|
|
1695
2014
|
cwd,
|
|
2015
|
+
env: command_env(process.env),
|
|
1696
2016
|
encoding: "utf8"
|
|
1697
2017
|
});
|
|
1698
2018
|
if (result.error !== void 0) throw config_error(`git is required for --worktree but could not run: ${String(result.error)}`);
|
|
@@ -1719,6 +2039,7 @@ function git_quiet(cwd, args) {
|
|
|
1719
2039
|
try {
|
|
1720
2040
|
const result = spawnSync("git", args, {
|
|
1721
2041
|
cwd,
|
|
2042
|
+
env: command_env(process.env),
|
|
1722
2043
|
encoding: "utf8"
|
|
1723
2044
|
});
|
|
1724
2045
|
return {
|
|
@@ -1930,16 +2251,6 @@ async function with_worktree(options, body) {
|
|
|
1930
2251
|
* complete agentic session in the workspace — Claude Code under `claude_cli`,
|
|
1931
2252
|
* or a model driving volley's tools on any other provider, local or hosted.
|
|
1932
2253
|
*/
|
|
1933
|
-
const BUILDER_ALLOWED_TOOLS = [
|
|
1934
|
-
"Read",
|
|
1935
|
-
"Write",
|
|
1936
|
-
"Edit",
|
|
1937
|
-
"MultiEdit",
|
|
1938
|
-
"Bash",
|
|
1939
|
-
"Grep",
|
|
1940
|
-
"Glob",
|
|
1941
|
-
"WebFetch"
|
|
1942
|
-
];
|
|
1943
2254
|
function compose_builder_system() {
|
|
1944
2255
|
return [
|
|
1945
2256
|
"You are the builder inside the volley harness: an autonomous agent",
|
|
@@ -2001,6 +2312,14 @@ function check_hint(config) {
|
|
|
2001
2312
|
if (config.check_resolved === "command") return `the check (\`${config.check}\`)`;
|
|
2002
2313
|
return "the project's tests";
|
|
2003
2314
|
}
|
|
2315
|
+
/** The builder's `prepare_step`: the countdown, after the history trim when
|
|
2316
|
+
* `--builder-trim-history` turns it on, so the countdown's note still goes
|
|
2317
|
+
* last. */
|
|
2318
|
+
function builder_prepare_step(config) {
|
|
2319
|
+
const countdown = step_countdown(config.builder_max_steps, check_hint(config));
|
|
2320
|
+
if (config.builder_trim_history === null) return countdown;
|
|
2321
|
+
return compose_prepare_steps([step_trim(config.builder_trim_history), countdown]);
|
|
2322
|
+
}
|
|
2004
2323
|
/** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
|
|
2005
2324
|
* The `claude_cli` builder is confined at the CLI permission layer (allowlist +
|
|
2006
2325
|
* permission mode) and brings its own built-in tools — this arm is unchanged
|
|
@@ -2008,7 +2327,8 @@ function check_hint(config) {
|
|
|
2008
2327
|
* builder, local or `openrouter`, brings none, so volley supplies its whole
|
|
2009
2328
|
* workspace tool surface (or the set the caller injected in its place, with
|
|
2010
2329
|
* the handler that answers its approvals), the per-call loop knobs, the step
|
|
2011
|
-
* countdown (`./builder/countdown.ts`)
|
|
2330
|
+
* countdown (`./builder/countdown.ts`) and the history trim when it's on
|
|
2331
|
+
* (`./builder/trim.ts`), the output cap when one is set, and
|
|
2012
2332
|
* **no** schema (it produces a workspace, not a verdict); per-call values win
|
|
2013
2333
|
* over engine defaults. */
|
|
2014
2334
|
function builder_tool_options(deps) {
|
|
@@ -2016,7 +2336,11 @@ function builder_tool_options(deps) {
|
|
|
2016
2336
|
const bash_executor = deps.bash_executor ?? null;
|
|
2017
2337
|
if (config.builder_provider === "claude_cli") return { provider_options: { claude_cli: {
|
|
2018
2338
|
allowed_tools: [...BUILDER_ALLOWED_TOOLS],
|
|
2019
|
-
extra_args: [
|
|
2339
|
+
extra_args: [
|
|
2340
|
+
"--permission-mode",
|
|
2341
|
+
config.builder_permission_mode,
|
|
2342
|
+
...settings_args()
|
|
2343
|
+
]
|
|
2020
2344
|
} } };
|
|
2021
2345
|
return {
|
|
2022
2346
|
tools: deps.tools === void 0 ? builder_tools(build_root(config.workspace, config.worktree), bash_executor !== null ? { bash_executor } : {}) : [...deps.tools],
|
|
@@ -2025,7 +2349,7 @@ function builder_tool_options(deps) {
|
|
|
2025
2349
|
tool_error_policy: "feed_back",
|
|
2026
2350
|
tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
|
|
2027
2351
|
max_tool_calls_per_step: config.builder_max_tool_calls,
|
|
2028
|
-
prepare_step:
|
|
2352
|
+
prepare_step: builder_prepare_step(config),
|
|
2029
2353
|
...config.builder_max_output_tokens !== null ? {
|
|
2030
2354
|
max_tokens: config.builder_max_output_tokens,
|
|
2031
2355
|
truncated_turn_policy: "continue"
|
|
@@ -2231,6 +2555,7 @@ function git(root, args) {
|
|
|
2231
2555
|
try {
|
|
2232
2556
|
const result = spawnSync("git", args, {
|
|
2233
2557
|
cwd: root,
|
|
2558
|
+
env: command_env(process.env),
|
|
2234
2559
|
encoding: "utf8",
|
|
2235
2560
|
maxBuffer: 33554432
|
|
2236
2561
|
});
|
|
@@ -2551,12 +2876,6 @@ function compose_critic_prompt(input) {
|
|
|
2551
2876
|
* structured verdict. The
|
|
2552
2877
|
* harness — not the critic — writes the run's `feedback.md` and `verdict`.
|
|
2553
2878
|
*/
|
|
2554
|
-
const CRITIC_ALLOWED_TOOLS = [
|
|
2555
|
-
"Read",
|
|
2556
|
-
"Grep",
|
|
2557
|
-
"Glob"
|
|
2558
|
-
];
|
|
2559
|
-
const CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
|
|
2560
2879
|
/** A critic that holds tools isn't constrain-decoded, so nothing stops it
|
|
2561
2880
|
* answering null for the feedback or the unmet list, as GLM did on approving.
|
|
2562
2881
|
* Both fields allow null and read it as empty, so that answer is a verdict
|
|
@@ -2569,7 +2888,8 @@ const verdict_schema = z.object({
|
|
|
2569
2888
|
unmet_criteria: z.array(z.string()).nullable().transform((unmet) => unmet ?? []).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
|
|
2570
2889
|
});
|
|
2571
2890
|
/** Read-only tool wiring per provider. The claude_cli critic is confined at
|
|
2572
|
-
* the CLI permission layer (allowlist + explicit disallow
|
|
2891
|
+
* the CLI permission layer (allowlist + explicit disallow, and the settings
|
|
2892
|
+
* blocks that keep its reads in the build root); every other critic,
|
|
2573
2893
|
* local or `openrouter`, gets volley's own workspace-scoped read-only tools (or
|
|
2574
2894
|
* the set the caller injected in their place) and no write path at all.
|
|
2575
2895
|
*
|
|
@@ -2591,7 +2911,11 @@ const verdict_schema = z.object({
|
|
|
2591
2911
|
function critic_tool_options(config, tools) {
|
|
2592
2912
|
if (config.critic_provider === "claude_cli") return { provider_options: { claude_cli: {
|
|
2593
2913
|
allowed_tools: [...CRITIC_ALLOWED_TOOLS],
|
|
2594
|
-
extra_args: [
|
|
2914
|
+
extra_args: [
|
|
2915
|
+
"--disallowedTools",
|
|
2916
|
+
CRITIC_DISALLOWED_TOOLS,
|
|
2917
|
+
...settings_args()
|
|
2918
|
+
]
|
|
2595
2919
|
} } };
|
|
2596
2920
|
return {
|
|
2597
2921
|
tools: tools === void 0 ? read_only_tools(build_root(config.workspace, config.worktree)) : [...tools],
|
|
@@ -2957,7 +3281,8 @@ function phase_summary(record) {
|
|
|
2957
3281
|
...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
|
|
2958
3282
|
...record.critic_degraded === true ? { critic_degraded: true } : {},
|
|
2959
3283
|
...record.degrade_cause !== void 0 ? { degrade_cause: record.degrade_cause } : {},
|
|
2960
|
-
...record.throughput !== void 0 ? { throughput: record.throughput } : {}
|
|
3284
|
+
...record.throughput !== void 0 ? { throughput: record.throughput } : {},
|
|
3285
|
+
...record.steps !== void 0 ? { steps: record.steps } : {}
|
|
2961
3286
|
};
|
|
2962
3287
|
}
|
|
2963
3288
|
/** Archive one completed iteration: verdict files, check artifacts, and the
|
|
@@ -3214,6 +3539,7 @@ function load_resume_state(workspace, run_id, state_dir = null, options = {}) {
|
|
|
3214
3539
|
...recorded["builder_max_steps"] === void 0 ? {} : { builder_max_steps: Number(recorded["builder_max_steps"]) },
|
|
3215
3540
|
...recorded["builder_max_tool_calls"] === void 0 ? {} : { builder_max_tool_calls: Number(recorded["builder_max_tool_calls"]) },
|
|
3216
3541
|
...typeof recorded["builder_max_output_tokens"] === "number" ? { builder_max_output_tokens: recorded["builder_max_output_tokens"] } : {},
|
|
3542
|
+
...typeof recorded["builder_trim_history"] === "number" ? { builder_trim_history: recorded["builder_trim_history"] } : {},
|
|
3217
3543
|
critic_model: String(recorded["critic_model"]),
|
|
3218
3544
|
critic_provider: recorded["critic_provider"] ?? "claude_cli",
|
|
3219
3545
|
...typeof recorded["critic_url"] === "string" ? { critic_url: recorded["critic_url"] } : {},
|
|
@@ -3441,13 +3767,14 @@ function report_changes(config, renderer, changes) {
|
|
|
3441
3767
|
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
3768
|
}
|
|
3443
3769
|
/** Run the configured check, checkride or a command, with the environment
|
|
3444
|
-
* the caller set for it, else volley's own
|
|
3770
|
+
* the caller set for it, else volley's own less the seats' keys, since the
|
|
3771
|
+
* check runs code the builder wrote. */
|
|
3445
3772
|
async function execute_check(deps, abort) {
|
|
3446
3773
|
const { config } = deps;
|
|
3447
3774
|
const root = build_root(config.workspace, config.worktree);
|
|
3448
3775
|
const options = {
|
|
3449
3776
|
abort,
|
|
3450
|
-
|
|
3777
|
+
env: deps.check?.env ?? command_env(process.env)
|
|
3451
3778
|
};
|
|
3452
3779
|
return config.check_resolved === "checkride" ? run_checkride({
|
|
3453
3780
|
...options,
|
|
@@ -3987,13 +4314,6 @@ const CRITIC_READS = /* @__PURE__ */ new Set([
|
|
|
3987
4314
|
"list_files",
|
|
3988
4315
|
"search"
|
|
3989
4316
|
]);
|
|
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
4317
|
/** Whether `tool` asks before a call with `input`. A set without the tool
|
|
3998
4318
|
* asks, so a check never runs unasked by mistake. */
|
|
3999
4319
|
async function asks_before(tool, input) {
|
|
@@ -4008,10 +4328,10 @@ async function asks_before(tool, input) {
|
|
|
4008
4328
|
* the session exactly when the builder's `run_command` would ask before
|
|
4009
4329
|
* running the same command. */
|
|
4010
4330
|
function acp_seat_tools(session, options = {}) {
|
|
4011
|
-
const
|
|
4331
|
+
const env = command_env(options.env ?? process.env);
|
|
4012
4332
|
const tools = workspace_tools(session, {
|
|
4013
4333
|
ignore: [...IGNORED_DIRS],
|
|
4014
|
-
command_env
|
|
4334
|
+
command_env: env
|
|
4015
4335
|
});
|
|
4016
4336
|
const run_command = tools.find((tool) => tool.name === "run_command");
|
|
4017
4337
|
return {
|
|
@@ -4025,7 +4345,7 @@ function acp_seat_tools(session, options = {}) {
|
|
|
4025
4345
|
tool_set: "acp"
|
|
4026
4346
|
},
|
|
4027
4347
|
check: {
|
|
4028
|
-
env
|
|
4348
|
+
env,
|
|
4029
4349
|
on_approval: async (request) => await asks_before(run_command, request.input) ? session.approve(request) : true
|
|
4030
4350
|
}
|
|
4031
4351
|
};
|
|
@@ -4149,6 +4469,7 @@ const CRITIQUE_REFUSED = [
|
|
|
4149
4469
|
["builder_max_steps", "--builder-max-steps"],
|
|
4150
4470
|
["builder_max_tool_calls", "--builder-max-tool-calls"],
|
|
4151
4471
|
["builder_max_output_tokens", "--builder-max-output-tokens"],
|
|
4472
|
+
["builder_trim_history", "--builder-trim-history"],
|
|
4152
4473
|
["builder_permission_mode", "--builder-permission-mode"],
|
|
4153
4474
|
["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
|
|
4154
4475
|
["sandbox_image", "--sandbox-image"],
|
|
@@ -4188,6 +4509,12 @@ function builder_max_output_tokens_count(provider) {
|
|
|
4188
4509
|
fallback: CAPPED_BY_DEFAULT.includes(provider) ? DEFAULT_BUILDER_MAX_OUTPUT_TOKENS : null
|
|
4189
4510
|
};
|
|
4190
4511
|
}
|
|
4512
|
+
/** The history trim's size: off unless something sets it. */
|
|
4513
|
+
const BUILDER_TRIM_HISTORY = {
|
|
4514
|
+
flag: "--builder-trim-history",
|
|
4515
|
+
env_var: "VOLLEY_BUILDER_TRIM_HISTORY",
|
|
4516
|
+
fallback: null
|
|
4517
|
+
};
|
|
4191
4518
|
/** A builder count's flag or config value wins over its env var, which wins
|
|
4192
4519
|
* over the default, and either must be a positive integer. Local builder
|
|
4193
4520
|
* only; ignored by the `claude_cli` arm. */
|
|
@@ -4322,6 +4649,7 @@ function resolve_config(raw, options = {}) {
|
|
|
4322
4649
|
const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
|
|
4323
4650
|
const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
|
|
4324
4651
|
const builder_max_output_tokens = resolve_builder_count(raw.builder_max_output_tokens, builder_env, builder_max_output_tokens_count(builder_provider));
|
|
4652
|
+
const builder_trim_history = resolve_builder_count(raw.builder_trim_history, builder_env, BUILDER_TRIM_HISTORY);
|
|
4325
4653
|
const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
|
|
4326
4654
|
if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
|
|
4327
4655
|
const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
|
|
@@ -4360,6 +4688,7 @@ function resolve_config(raw, options = {}) {
|
|
|
4360
4688
|
builder_max_steps,
|
|
4361
4689
|
builder_max_tool_calls,
|
|
4362
4690
|
builder_max_output_tokens,
|
|
4691
|
+
builder_trim_history,
|
|
4363
4692
|
allow_unsandboxed_builder,
|
|
4364
4693
|
critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
|
|
4365
4694
|
critic_provider,
|
|
@@ -5455,6 +5784,10 @@ function render_matrix_table(rows) {
|
|
|
5455
5784
|
* never requires Docker or a model server — so they never contribute an exit 5
|
|
5456
5785
|
* on that path.
|
|
5457
5786
|
*
|
|
5787
|
+
* A `claude_cli` seat gets one read of `claude --version`, which only ever
|
|
5788
|
+
* warns: below the version that enforces the settings blocks keeping the seat's
|
|
5789
|
+
* file tools in the build root, the dry run says what goes unenforced.
|
|
5790
|
+
*
|
|
5458
5791
|
* A *local critic* additionally gets the critic-seat canary: one tiny $0
|
|
5459
5792
|
* generate through the real critic tool wiring, run
|
|
5460
5793
|
* whatever the builder provider is. A degradable failure warns and predicts
|
|
@@ -5476,6 +5809,7 @@ const REQUIRED_TOOLCHAIN = [
|
|
|
5476
5809
|
/** A short cap on the endpoint probe so a wrong/unreachable host fails fast
|
|
5477
5810
|
* rather than hanging the preflight before the loop even starts. */
|
|
5478
5811
|
const ENDPOINT_PROBE_TIMEOUT_MS = 3e3;
|
|
5812
|
+
const CLAUDE_VERSION_TIMEOUT_MS = 1e4;
|
|
5479
5813
|
/** Builders that drive their model through volley's own tool loop, local or
|
|
5480
5814
|
* hosted, so the containment preflight applies. `claude_cli` never does. */
|
|
5481
5815
|
function is_tool_loop_builder(provider) {
|
|
@@ -5522,7 +5856,10 @@ async function probe_seat_endpoints(config, renderer, env, reachable) {
|
|
|
5522
5856
|
/** Which required executables are not runnable on the current PATH. */
|
|
5523
5857
|
function toolchain_missing(tools = REQUIRED_TOOLCHAIN) {
|
|
5524
5858
|
return tools.filter((tool) => {
|
|
5525
|
-
const result = spawnSync(tool, ["--version"], {
|
|
5859
|
+
const result = spawnSync(tool, ["--version"], {
|
|
5860
|
+
env: command_env(process.env),
|
|
5861
|
+
stdio: "ignore"
|
|
5862
|
+
});
|
|
5526
5863
|
return result.error !== void 0 || result.status !== 0;
|
|
5527
5864
|
});
|
|
5528
5865
|
}
|
|
@@ -5531,7 +5868,10 @@ function toolchain_missing(tools = REQUIRED_TOOLCHAIN) {
|
|
|
5531
5868
|
* run's job — worktree.ts). */
|
|
5532
5869
|
function worktree_creatable(workspace) {
|
|
5533
5870
|
const repo = resolve(workspace);
|
|
5534
|
-
const version = spawnSync("git", ["--version"], {
|
|
5871
|
+
const version = spawnSync("git", ["--version"], {
|
|
5872
|
+
env: command_env(process.env),
|
|
5873
|
+
stdio: "ignore"
|
|
5874
|
+
});
|
|
5535
5875
|
if (version.error !== void 0 || version.status !== 0) return {
|
|
5536
5876
|
ok: false,
|
|
5537
5877
|
detail: "git is not available"
|
|
@@ -5545,7 +5885,10 @@ function worktree_creatable(workspace) {
|
|
|
5545
5885
|
repo,
|
|
5546
5886
|
"rev-parse",
|
|
5547
5887
|
"--is-inside-work-tree"
|
|
5548
|
-
], {
|
|
5888
|
+
], {
|
|
5889
|
+
env: command_env(process.env),
|
|
5890
|
+
encoding: "utf8"
|
|
5891
|
+
});
|
|
5549
5892
|
if (inside.status !== 0 || (inside.stdout ?? "").trim() !== "true") return {
|
|
5550
5893
|
ok: false,
|
|
5551
5894
|
detail: `${repo} is not a git work tree`
|
|
@@ -5555,6 +5898,33 @@ function worktree_creatable(workspace) {
|
|
|
5555
5898
|
detail: repo
|
|
5556
5899
|
};
|
|
5557
5900
|
}
|
|
5901
|
+
/** What `claude --version` printed, from the binary the run will spawn, or
|
|
5902
|
+
* null when it didn't run. */
|
|
5903
|
+
function claude_version(env) {
|
|
5904
|
+
const binary = env["VOLLEY_CLAUDE_BIN"];
|
|
5905
|
+
const result = spawnSync(binary !== void 0 && binary.length > 0 ? binary : "claude", ["--version"], {
|
|
5906
|
+
env: command_env(process.env),
|
|
5907
|
+
encoding: "utf8",
|
|
5908
|
+
timeout: CLAUDE_VERSION_TIMEOUT_MS
|
|
5909
|
+
});
|
|
5910
|
+
if (result.error !== void 0 || result.status !== 0) return null;
|
|
5911
|
+
return result.stdout;
|
|
5912
|
+
}
|
|
5913
|
+
/** Name the Claude Code version a `claude_cli` seat runs on, and warn when it
|
|
5914
|
+
* is too old to enforce the blocks that keep the seat's file tools in the
|
|
5915
|
+
* build root. Never fails the dry run. */
|
|
5916
|
+
function report_claude_cli(config, renderer, env, probe) {
|
|
5917
|
+
if (!uses_claude_cli(config)) return;
|
|
5918
|
+
const printed = probe(env);
|
|
5919
|
+
const version = printed === null ? null : parse_claude_version(printed);
|
|
5920
|
+
if (version === null) {
|
|
5921
|
+
renderer.warn("claude: could not read the Claude Code version (`claude --version`), so whether it keeps a claude_cli seat's file tools in the build root is unknown");
|
|
5922
|
+
return;
|
|
5923
|
+
}
|
|
5924
|
+
renderer.info(`claude: ${format_version(version)}`);
|
|
5925
|
+
if (version_below(version, MIN_CLAUDE_WRITE_BLOCK)) renderer.warn(`claude: Claude Code ${format_version(version)} ignores the block on writes outside the build root (needs ${format_version(MIN_CLAUDE_WRITE_BLOCK)}). Edit(/**) still keeps a builder's edits there under acceptEdits, but not under bypassPermissions.`);
|
|
5926
|
+
if (version_below(version, MIN_CLAUDE_READ_BLOCK)) renderer.warn(`claude: Claude Code ${format_version(version)} ignores the block on reads outside the build root (needs ${format_version(MIN_CLAUDE_READ_BLOCK)}).`);
|
|
5927
|
+
}
|
|
5558
5928
|
/** Reachability probe: any HTTP response (even a 404) proves the TCP connect
|
|
5559
5929
|
* landed; a thrown error (connection refused, DNS failure, timeout) means the
|
|
5560
5930
|
* endpoint is unreachable. */
|
|
@@ -5618,6 +5988,7 @@ function run_checkride_doctor(workspace) {
|
|
|
5618
5988
|
"doctor"
|
|
5619
5989
|
], {
|
|
5620
5990
|
cwd: workspace,
|
|
5991
|
+
env: command_env(process.env),
|
|
5621
5992
|
stdio: [
|
|
5622
5993
|
"ignore",
|
|
5623
5994
|
"ignore",
|
|
@@ -5643,6 +6014,7 @@ async function preflight(config, renderer, env, probes = {}) {
|
|
|
5643
6014
|
renderer.info(`builder max steps: ${config.builder_max_steps}`);
|
|
5644
6015
|
renderer.info(`builder tool calls per turn: ${config.builder_max_tool_calls}`);
|
|
5645
6016
|
renderer.info(`builder output tokens per turn: ${config.builder_max_output_tokens ?? "the provider's limit"}`);
|
|
6017
|
+
renderer.info(`builder history trim: ${config.builder_trim_history === null ? "off" : `above ${String(config.builder_trim_history)} estimated tokens`}`);
|
|
5646
6018
|
}
|
|
5647
6019
|
if (tool_loop_builder) {
|
|
5648
6020
|
const contained = detect_containment(env);
|
|
@@ -5653,6 +6025,7 @@ async function preflight(config, renderer, env, probes = {}) {
|
|
|
5653
6025
|
renderer.info(seat_line("critic", config.critic_model, config.critic_provider, config.critic_url, env));
|
|
5654
6026
|
report_worktree_fate(config, renderer);
|
|
5655
6027
|
report_gate_posture(config, renderer);
|
|
6028
|
+
report_claude_cli(config, renderer, env, probes.claude_version ?? claude_version);
|
|
5656
6029
|
if (config.check_resolved === "checkride") {
|
|
5657
6030
|
if (!(probes.checkride_doctor ?? run_checkride_doctor)(config.workspace)) {
|
|
5658
6031
|
renderer.error("checkride doctor failed; fix the workspace check pipeline before running");
|
|
@@ -5750,8 +6123,19 @@ function make_renderer(config, run_id = config.run_id) {
|
|
|
5750
6123
|
}, "trajectory.jsonl")
|
|
5751
6124
|
});
|
|
5752
6125
|
}
|
|
5753
|
-
|
|
5754
|
-
|
|
6126
|
+
/** Only `VOLLEY_AUTH_MODE=api_key` hands the claude CLI a key: under `auto`
|
|
6127
|
+
* and `oauth` it gets none and stays on its own login, whatever is set. */
|
|
6128
|
+
function warn_api_key_meter(config, renderer, env = process.env) {
|
|
6129
|
+
if (uses_claude_cli(config) && env["VOLLEY_AUTH_MODE"] === "api_key") renderer.warn("VOLLEY_AUTH_MODE=api_key: the claude CLI bills this run to ANTHROPIC_API_KEY at API rates, not to a subscription. Know which meter you are on.");
|
|
6130
|
+
}
|
|
6131
|
+
/** What to try when a `claude_cli` seat on the subscription login can't
|
|
6132
|
+
* authenticate, which is how every such seat fails once `claude -p` runs bare by
|
|
6133
|
+
* default (volley#9). Null for any other error. */
|
|
6134
|
+
function claude_login_hint(err, env = process.env) {
|
|
6135
|
+
const auth_failure = [err, err?.cause].some((e) => error_kind(e) === "provider_auth_error" && e.provider === "claude_cli");
|
|
6136
|
+
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
6137
|
+
if (!auth_failure || auth_mode === "api_key") return null;
|
|
6138
|
+
return `a claude_cli seat on the subscription login (VOLLEY_AUTH_MODE=${auth_mode}) could not authenticate. If \`claude auth login\` does not fix it, a Claude Code release that runs \`claude -p\` bare by default would explain it (volley#9): set VOLLEY_AUTH_MODE=api_key with ANTHROPIC_API_KEY to run on the API key meanwhile.`;
|
|
5755
6139
|
}
|
|
5756
6140
|
/** One loud warning when a tool-loop builder is about to run *uncontained* on
|
|
5757
6141
|
* the host via the opt-out. Config resolution has already refused this
|
|
@@ -5774,7 +6158,7 @@ function with_critic_options(command, required = false) {
|
|
|
5774
6158
|
function with_seat_options(command, required = false) {
|
|
5775
6159
|
const model_note = required ? " (required)" : "";
|
|
5776
6160
|
const provider_note = required ? "required" : "default: claude_cli";
|
|
5777
|
-
return with_critic_options(command.option("--builder-model <model>", `Builder model${model_note}`).option("--builder-provider <name>", `claude_cli | ollama | lmstudio | openrouter (${provider_note})`).option("--builder-url <url>", "Builder's model server base URL, ollama, lmstudio, or openrouter only (default: VOLLEY_OLLAMA_URL / VOLLEY_LMSTUDIO_URL / VOLLEY_OPENROUTER_URL)").option("--builder-max-steps <n>", "Tool-loop builder step cap per iteration, any provider but claude_cli (default: 50)").option("--builder-max-tool-calls <n>", "Tool calls a tool-loop builder may make per turn, reads aside, any provider but claude_cli (default: 1)").option("--builder-max-output-tokens <n>", "Output tokens a tool-loop builder may generate per turn, reasoning included, any provider but claude_cli (default: 16384 on ollama and lmstudio, the provider's on openrouter)").option("--allow-unsandboxed-builder", "Permit a builder other than claude_cli to run without a sandbox (real host bash + write)"), required);
|
|
6161
|
+
return with_critic_options(command.option("--builder-model <model>", `Builder model${model_note}`).option("--builder-provider <name>", `claude_cli | ollama | lmstudio | openrouter (${provider_note})`).option("--builder-url <url>", "Builder's model server base URL, ollama, lmstudio, or openrouter only (default: VOLLEY_OLLAMA_URL / VOLLEY_LMSTUDIO_URL / VOLLEY_OPENROUTER_URL)").option("--builder-max-steps <n>", "Tool-loop builder step cap per iteration, any provider but claude_cli (default: 50)").option("--builder-max-tool-calls <n>", "Tool calls a tool-loop builder may make per turn, reads aside, any provider but claude_cli (default: 1)").option("--builder-max-output-tokens <n>", "Output tokens a tool-loop builder may generate per turn, reasoning included, any provider but claude_cli (default: 16384 on ollama and lmstudio, the provider's on openrouter)").option("--builder-trim-history <tokens>", "Experimental: above this estimated request size, a tool-loop builder's turns send older tool results as one-line stubs, any provider but claude_cli (default: off)").option("--allow-unsandboxed-builder", "Permit a builder other than claude_cli to run without a sandbox (real host bash + write)"), required);
|
|
5778
6162
|
}
|
|
5779
6163
|
/** What a critique declares only so that it can refuse each by name, with
|
|
5780
6164
|
* exit 5: cac would answer an undeclared one with a bare "Unknown option".
|
|
@@ -5786,6 +6170,7 @@ const CRITIQUE_REFUSED_OPTIONS = [
|
|
|
5786
6170
|
"--builder-max-steps <n>",
|
|
5787
6171
|
"--builder-max-tool-calls <n>",
|
|
5788
6172
|
"--builder-max-output-tokens <n>",
|
|
6173
|
+
"--builder-trim-history <tokens>",
|
|
5789
6174
|
"--builder-permission-mode <mode>",
|
|
5790
6175
|
"--allow-unsandboxed-builder",
|
|
5791
6176
|
"--sandbox-image <tag>",
|
|
@@ -5810,6 +6195,7 @@ function merge_flags(base, flags) {
|
|
|
5810
6195
|
...flags.builderMaxSteps !== void 0 ? { builder_max_steps: Number(flags.builderMaxSteps) } : {},
|
|
5811
6196
|
...flags.builderMaxToolCalls !== void 0 ? { builder_max_tool_calls: Number(flags.builderMaxToolCalls) } : {},
|
|
5812
6197
|
...flags.builderMaxOutputTokens !== void 0 ? { builder_max_output_tokens: Number(flags.builderMaxOutputTokens) } : {},
|
|
6198
|
+
...flags.builderTrimHistory !== void 0 ? { builder_trim_history: Number(flags.builderTrimHistory) } : {},
|
|
5813
6199
|
...flags.allowUnsandboxedBuilder === true ? { allow_unsandboxed_builder: true } : {},
|
|
5814
6200
|
...flags.criticModel !== void 0 ? { critic_model: flags.criticModel } : {},
|
|
5815
6201
|
...flags.criticProvider !== void 0 ? { critic_provider: flags.criticProvider } : {},
|
|
@@ -5858,6 +6244,8 @@ function report_error(err) {
|
|
|
5858
6244
|
const cause = err.cause;
|
|
5859
6245
|
if (cause instanceof Error && cause.message !== message) process.stderr.write(` cause: ${cause.message}\n`);
|
|
5860
6246
|
}
|
|
6247
|
+
const hint = claude_login_hint(err);
|
|
6248
|
+
if (hint !== null) process.stderr.write(` hint: ${hint}\n`);
|
|
5861
6249
|
return exit_code_for_error(err);
|
|
5862
6250
|
}
|
|
5863
6251
|
async function main(argv) {
|
|
@@ -5868,7 +6256,7 @@ async function main(argv) {
|
|
|
5868
6256
|
const run_id = not_a_bare_number("--run-id", flags.runId);
|
|
5869
6257
|
const config = resolve_config(merged, run_id !== void 0 ? { run_id } : {});
|
|
5870
6258
|
const renderer = make_renderer(config);
|
|
5871
|
-
warn_api_key_meter(renderer);
|
|
6259
|
+
warn_api_key_meter(config, renderer);
|
|
5872
6260
|
warn_unsandboxed_builder(config, renderer);
|
|
5873
6261
|
if (merged.dry_run === true) {
|
|
5874
6262
|
exit_code = await preflight(config, renderer, process.env);
|
|
@@ -5887,7 +6275,7 @@ async function main(argv) {
|
|
|
5887
6275
|
});
|
|
5888
6276
|
if (resume.iterations_completed >= config.max_iterations) throw config_error(`run already completed ${String(resume.iterations_completed)} of ${String(config.max_iterations)} iterations`);
|
|
5889
6277
|
const renderer = make_renderer(config);
|
|
5890
|
-
warn_api_key_meter(renderer);
|
|
6278
|
+
warn_api_key_meter(config, renderer);
|
|
5891
6279
|
warn_unsandboxed_builder(config, renderer);
|
|
5892
6280
|
if (resume.config_signed === false) warn_trusted_config(config, renderer);
|
|
5893
6281
|
renderer.info(`resuming run ${run_id} at iteration ${String(resume.iterations_completed + 1)}`);
|
|
@@ -5906,7 +6294,7 @@ async function main(argv) {
|
|
|
5906
6294
|
...run_id !== void 0 ? { run_id } : {}
|
|
5907
6295
|
});
|
|
5908
6296
|
const renderer = make_renderer(config);
|
|
5909
|
-
warn_api_key_meter(renderer);
|
|
6297
|
+
warn_api_key_meter(config, renderer);
|
|
5910
6298
|
if (merged.dry_run === true) {
|
|
5911
6299
|
const sha = resolve_baseline(config.workspace, baseline);
|
|
5912
6300
|
renderer.info(`baseline: ${sha ?? "none (not a git repository, or no commit yet)"}`);
|
|
@@ -5935,7 +6323,7 @@ async function main(argv) {
|
|
|
5935
6323
|
discard_worktree: true
|
|
5936
6324
|
});
|
|
5937
6325
|
const renderer = make_renderer(base_config, "<run id>");
|
|
5938
|
-
warn_api_key_meter(renderer);
|
|
6326
|
+
warn_api_key_meter(base_config, renderer);
|
|
5939
6327
|
warn_unsandboxed_builder(base_config, renderer);
|
|
5940
6328
|
const outcome = await run_matrix({
|
|
5941
6329
|
base: merged,
|
|
@@ -5967,6 +6355,6 @@ if (import.meta.main) {
|
|
|
5967
6355
|
process.exitCode = code;
|
|
5968
6356
|
}
|
|
5969
6357
|
//#endregion
|
|
5970
|
-
export { package_version, warn_unsandboxed_builder };
|
|
6358
|
+
export { claude_login_hint, package_version, warn_api_key_meter, warn_unsandboxed_builder };
|
|
5971
6359
|
|
|
5972
6360
|
//# sourceMappingURL=cli.js.map
|