@robmclarty/volley 0.6.1 → 0.8.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 CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.8.0 — 2026-10-19
4
+
5
+ ### Added
6
+ - **`volley acp` runs volley from Zed.** It serves volley over stdio as an Agent Client Protocol agent, so Zed's agent panel, or any ACP client, can drive it. Each prompt is a fresh volley run on the open project. The builder's work streams in as the reply under a **Round N** line, the check runs as a command card, the critic's verdict shows as a thought, and the turn ends with a summary. Edits and commands go through the editor's permission prompts. Outside volley's sandbox container, every builder command and every check asks first, Auto mode isn't offered, and neither the commands nor the check can read `OPENROUTER_API_KEY` or `ANTHROPIC_API_KEY`. Run state goes under `$XDG_STATE_HOME/volley`, never the repo. It requires the four seat flags and refuses `--worktree`, `--git`, `--json`, and a `claude_cli` builder (exit 5). The README covers the Zed setup and a check to run by hand.
7
+ - **Either seat can run on OpenRouter.** `--builder-provider openrouter` and `--critic-provider openrouter` reach a hosted model with one `OPENROUTER_API_KEY`, from the plain CLI as well as `volley acp`, and `VOLLEY_OPENROUTER_URL` overrides the endpoint. An OpenRouter builder runs volley's own tool loop under the same containment gate as a local one, and an OpenRouter critic gets the local critic's retry and fallback. OpenRouter reports its own charge, so `--max-cost-usd` trips on it. An unset key is a configuration error (exit 5).
8
+ - **`--state-dir <root>` keeps run state out of the workspace.** Each run's state goes to `<root>/<run_id>/`, and `state_dir` in a config file does the same. `volley resume <id> --state-dir <root>` finds a run there. Without the flag, state stays in `.volley/` as before.
9
+ - **`run_volley` takes per-seat tools, a builder approval handler, and a trajectory sink.** All three are optional, and a run without them is exactly what it was.
10
+
11
+ ### Changed
12
+ - **`ResolvedConfig` has `state_dir`.** It's required (`string | null`), so SDK code that builds a `ResolvedConfig` by hand instead of through `resolve_config` needs it. Null keeps the run's state in `.volley/`.
13
+
14
+ ### Fixed
15
+ - **A local critic that runs out of steps falls back instead of failing the run.** Since fascicle 0.10.1, a critic that spent its step budget reading files threw straight past the retry and the fallback, so the run exited 6, and `--dry-run`'s canary exited 5. It now goes straight to judging without tools. A fallback verdict now says why: `degrade_cause` (`stream_error` or `step_exhaustion`) on the critic record, `critic_degrade_causes` in the summary's comparison block, and `deg:stream` or `deg:steps` in the matrix flags.
16
+ - **Stopping a run mid-check now stops the whole check.** A stop killed only the check's shell, which left a test runner's workers, or a server the check started in the background, still running. The check now runs in a process group of its own, and a stop kills all of it.
17
+
18
+ ### Internal
19
+ - **Built on fascicle 0.16.3 and @agentclientprotocol/sdk 1.8.0.**
20
+ - **The tests drive a whole volley turn through an in-process ACP client**, against a stub model server. The live check in Zed is scripted in the README and was run by hand. Its one miss, the check's card losing its command look while it asks, waits on robmclarty/fascicle#16.
21
+ - **The flow diagram's critic retry and fallback rows name every provider but `claude_cli`.**
22
+
23
+ ## v0.7.0 — 2026-10-16
24
+
25
+ ### Added
26
+ - **Each seat can use its own model server.** `--builder-url` and `--critic-url` (`builder_url` and `critic_url` in a config file) point the builder and the critic at different machines even when both run on the same provider, such as two Ollama servers. A seat without a URL of its own uses the provider's env var (`VOLLEY_OLLAMA_URL` or `VOLLEY_LMSTUDIO_URL`), then the default, never the other seat's. Everything a seat sends goes to its URL: its model calls, the Ollama prewarm and context check, and the `--dry-run` probe and critic canary. Inside the sandbox, a loopback seat URL crosses to `VOLLEY_MODEL_HOST` on its own port. Both URLs carry over to `volley resume` and to every `volley matrix` pairing. A URL on a `claude_cli` seat, or one that isn't plain http(s), is a configuration error (exit 5).
27
+
28
+ ### Changed
29
+ - **Ollama seats need ai-sdk-ollama 4.3.0 or later.** Before 4.3.0 it streams a thinking model's reasoning only when a request sets `think`, which volley never does, so the reasoning vanished from the terminal and the trajectory and the phase's throughput read high. volley now declares the floor as an optional peer: pnpm warns about an older copy, npm refuses it with ERESOLVE, and an install without ai-sdk-ollama is unaffected.
30
+ - **`--dry-run` probes the critic's model endpoint too.** Each local seat's endpoint is probed, once for a server both seats share, so a `claude_cli` builder with a local critic now gets a reachability check before the canary, and an unreachable endpoint is named with its seat.
31
+ - **`ResolvedConfig` has `builder_url` and `critic_url`.** Both are required (`string | null`), so SDK code that builds a `ResolvedConfig` by hand instead of through `resolve_config` needs them. A run saved by an earlier version resumes with both null.
32
+ - **An empty `VOLLEY_OLLAMA_URL` or `VOLLEY_LMSTUDIO_URL` means the default.** It used to fail the run with a configuration error.
33
+
34
+ ### Fixed
35
+ - **An IPv6 loopback model URL reaches the host from inside the sandbox.** `http://[::1]:…` was never crossed to `VOLLEY_MODEL_HOST`, because `URL` keeps the brackets in the hostname.
36
+
37
+ ### Internal
38
+ - **Built on fascicle 0.14.0, TypeScript 7, and tsdown.** tsdown replaces tsup, whose declaration build needed a compiler API TypeScript 7 no longer exports. The published files keep their names and exports, and the bundles now keep their source comments. The dev toolchain moves to checkride 0.14.0, fallow 3.33.1, oxlint 1.88.0, vitest 5.0.4, and @ast-grep/cli 0.50.0, alongside ai 7.0.140, undici 8.12.1, and cac 7.1.0, and `pnpm audit` is clean.
39
+ - **The throughput docs mark the gap that remains on Ollama.** A turn whose only output is a tool call still reports a rate far too high until fascicle times those turns another way (robmclarty/fascicle#12).
40
+ - **An integration test holds each seat to its own server.** It runs real engines against two stand-in Ollama servers, and the local-provider tests no longer reach a developer's own Ollama.
41
+
3
42
  ## v0.6.1 — 2026-10-14
4
43
 
5
44
  ### Fixed
package/README.md CHANGED
@@ -55,7 +55,8 @@ The cap is enforced in the loop guard after each phase. Sessions are never
55
55
  killed mid-flight for cost (a builder killed mid-edit leaves the workspace
56
56
  undefined), so set the cap below your true ceiling by roughly one iteration's
57
57
  expected cost. Per-run totals land in `.volley/summary.json` and the final
58
- terminal summary.
58
+ terminal summary. An [OpenRouter](#openrouter) seat's cost is the charge
59
+ OpenRouter reports for each call, so the cap trips on one as on any other.
59
60
 
60
61
  ## How it works
61
62
 
@@ -94,7 +95,7 @@ Under the hood the loop is one [fascicle](https://www.npmjs.com/package/fascicle
94
95
  composition in [`src/flow.ts`](./src/flow.ts), and fascicle's `describe.diagram`
95
96
  draws it straight from the code. It shows what the sketch above leaves out: the
96
97
  branch that skips verification once the builder alone crosses the cost cap, the
97
- critic's retry-then-tool-less fallback for local models whose streams die, and
98
+ critic's retry-then-tool-less fallback on every provider but `claude_cli`, and
98
99
  the guard that decides when the loop stops.
99
100
 
100
101
  <!-- flow diagram: pnpm diagram -->
@@ -108,8 +109,8 @@ volley loop: one round per iteration, up to max_i
108
109
  │ │ └─ else sequence
109
110
  │ │ ├─ check the deterministic gate: checkride, a command, or none
110
111
  │ │ └─ critique the read-only critic's verdict and feedback
111
- │ │ └─ critic fallback: judge without tools if a local critic's stream keeps dying
112
- │ │ ├─ retry one more try after a stream death, local providers only
112
+ │ │ └─ critic fallback: judge without tools if a critic on any provider but claude_cli keeps dying or runs out of steps
113
+ │ │ ├─ retry one more try after a stream death, on any provider but claude_cli
113
114
  │ │ │ └─ critic_tools model call with read-only workspace tools
114
115
  │ │ └─ pipe mark the verdict degraded
115
116
  │ │ └─ critic_toolless model call judging from the check output and a file list
@@ -122,8 +123,10 @@ the one in the `src/flow.ts` header to what the code builds, so neither drifts.
122
123
 
123
124
  ## Install
124
125
 
125
- Requires Node ≥ 24, pnpm, and the [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
126
- installed and authenticated.
126
+ Requires Node ≥ 24 and pnpm. The default seats run on the
127
+ [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code), installed and
128
+ authenticated; seats on [OpenRouter](#openrouter) or a
129
+ [local model server](#local-critic) don't need it.
127
130
 
128
131
  From a checkout:
129
132
 
@@ -147,6 +150,8 @@ volley [options]
147
150
  volley --config <path>
148
151
  volley resume <run-id>
149
152
  volley matrix --builders <models> --critics <models> --config <path>
153
+ volley acp --builder-provider <name> --builder-model <model> \
154
+ --critic-provider <name> --critic-model <model> [options]
150
155
  ```
151
156
 
152
157
  | Flag | Default | Description |
@@ -156,11 +161,13 @@ volley matrix --builders <models> --critics <models> --config <path>
156
161
  | `--criteria` | (required) | Acceptance criteria. Literal string or `@path`. |
157
162
  | `--check` | `auto` | `auto` (checkride if detected, else none), `none`, or a shell command. |
158
163
  | `--builder-model` | `opus` | `opus`/`sonnet`/`haiku` or a full model id. |
159
- | `--builder-provider` | `claude_cli` | `claude_cli`, `ollama`, or `lmstudio` (see [Local builder](#local-builder)). |
160
- | `--builder-max-steps` | `50` | Local builder tool-loop step cap per iteration. Ignored for `claude_cli`. |
161
- | `--allow-unsandboxed-builder` | off | Permit a local builder to run without a sandbox (see [Local builder](#local-builder)). |
164
+ | `--builder-provider` | `claude_cli` | `claude_cli`, `ollama`, `lmstudio`, or `openrouter` (see [Local builder](#local-builder) and [OpenRouter](#openrouter)). |
165
+ | `--builder-url` | the provider's env var | The builder's model server base URL, for `ollama`, `lmstudio`, or `openrouter` only. Lets the builder and critic run on different machines with the same provider (see [Seats on different machines](#seats-on-different-machines)). |
166
+ | `--builder-max-steps` | `50` | Tool-loop builder step cap per iteration. Ignored for `claude_cli`. |
167
+ | `--allow-unsandboxed-builder` | off | Permit a builder other than `claude_cli` to run without a sandbox (see [Local builder](#local-builder)). |
162
168
  | `--critic-model` | `opus` | Model for the critic. |
163
- | `--critic-provider` | `claude_cli` | `claude_cli`, `ollama`, or `lmstudio` (see [Local critic](#local-critic)). |
169
+ | `--critic-provider` | `claude_cli` | `claude_cli`, `ollama`, `lmstudio`, or `openrouter` (see [Local critic](#local-critic) and [OpenRouter](#openrouter)). |
170
+ | `--critic-url` | the provider's env var | The critic's model server base URL, for `ollama`, `lmstudio`, or `openrouter` only. |
164
171
  | `--builder-permission-mode` | `acceptEdits` | Or `bypassPermissions` for fully trusted workspaces. |
165
172
  | `--critic` | `reviewer` | `reviewer`, `optimizer`, `researcher`, or a prompt file path. |
166
173
  | `--max-iterations` | `10` | Hard iteration cap. |
@@ -171,8 +178,9 @@ volley matrix --builders <models> --critics <models> --config <path>
171
178
  | `--gate-paths` | (built-in list) | Comma-separated globs naming the gate — tests, fixtures, check config. Replaces the defaults; see [What the builder changed](#what-the-builder-changed). |
172
179
  | `--fail-on-gate-edit` | off | Halt the run (exit 8) if the builder edits a gate path, instead of reporting it. |
173
180
  | `--sandbox-image` | `volley-sandbox:latest` | Container image for the local-builder sandbox. Or `VOLLEY_SANDBOX_IMAGE`. |
174
- | `--run-id` | a fresh UUID | The run's id, naming its `.volley/` state and its `volley/<id>` branch, so a harness that owns the id finds it in both. Letters, digits, `-`, and `_`, starting with a letter or digit. |
175
- | `--dry-run` | off | Validate config, run `checkride doctor` and (for a local critic) the critic-seat canary, then exit. |
181
+ | `--state-dir` | the workspace's `.volley/` | Keep each run's state under `<path>/<run id>/` instead, and none in the workspace (see [Run artifacts](#run-artifacts)). Or `state_dir` in a config file. |
182
+ | `--run-id` | a fresh UUID | The run's id, naming its run state and its `volley/<id>` branch, so a harness that owns the id finds it in both. Letters, digits, `-`, and `_`, starting with a letter or digit. |
183
+ | `--dry-run` | off | Validate config, run `checkride doctor`, probe each tool-loop seat's model endpoint (local or OpenRouter), run the critic-seat canary (for a local critic), then exit. |
176
184
  | `--config` | — | TypeScript config file (`VolleyConfig` default export). CLI flags override. |
177
185
  | `--json` | off | Machine mode: one summary JSON document on stdout. |
178
186
  | `--verbose` | off | Full tool inputs/outputs (truncated at 4000 chars). |
@@ -223,8 +231,13 @@ export default config;
223
231
  ```sh
224
232
  volley resume <run-id> # in the workspace directory
225
233
  volley resume <run-id> --workspace ./my-project
234
+ volley resume <run-id> --state-dir ~/volley-runs # a run started with --state-dir
226
235
  ```
227
236
 
237
+ Given the root a run was started under, `resume` finds it at
238
+ `<root>/<run-id>/` from any directory, since the run's recorded config names its
239
+ workspace.
240
+
228
241
  Continues from the last completed iteration with the saved feedback and cost
229
242
  totals. An iteration interrupted mid-phase is discarded and re-run.
230
243
 
@@ -244,7 +257,7 @@ volley matrix \
244
257
 
245
258
  | Matrix flag | Description |
246
259
  |---|---|
247
- | `--config` | Base `VolleyConfig` (TS/JS): the task, workspace, providers, and caps held fixed. Required. |
260
+ | `--config` | Base `VolleyConfig` (TS/JS): the task, workspace, providers, seat URLs, and caps held fixed. Required. |
248
261
  | `--builders` | Comma-separated builder models to sweep. |
249
262
  | `--critics` | Comma-separated critic models to sweep. |
250
263
  | `--repeat` | Attempts per seat (default 1). A pass *rate* needs more than one. |
@@ -264,11 +277,11 @@ repository**. Every attempt's run state is written to
264
277
  `.volley-matrix/matrix.json`, and one aggregate table goes to stderr:
265
278
 
266
279
  ```
267
- builder critic pass iters wall cost salvage flags why
268
- ────────────── ───────────── ──── ───── ─────── ───── ─────── ───── ──────────────────
269
- qwen3.6:latest qwen3:8b 3/3 1.0 403.4s $0.00 0% — —
270
- qwen3.6:latest gemma4:12b 2/3 1.5 512.0s $0.00 0% — unmet: 2 criteria
271
- qwen3.6:latest glm-4.7-flash 0/3 — 184.0s $0.00 12% deg check: types, test
280
+ builder critic pass iters wall cost salvage flags why
281
+ ────────────── ───────────── ──── ───── ────── ───── ─────── ────────── ──────────────────
282
+ qwen3.6:latest qwen3:8b 3/3 1.0 403.4s $0.00 0% — —
283
+ qwen3.6:latest gemma4:12b 2/3 1.5 512.0s $0.00 0% — unmet: 2 criteria
284
+ qwen3.6:latest glm-4.7-flash 0/3 — 184.0s $0.00 12% deg:stream check: types, test
272
285
  ```
273
286
 
274
287
  **Why `--repeat`.** One run answers "did this pairing converge that time", and
@@ -286,7 +299,9 @@ totals rather than a mean of ratios.
286
299
  failing check slots, the criteria the critic still judged unmet, a cost cap, a
287
300
  gate edit, or the error that broke the run. `flags` carries what qualifies a
288
301
  pass rather than explaining a failure: `deg` for a degraded critic verdict,
289
- `gate` for a builder that edited the gate. Every attempt's full detail —
302
+ named for what degraded it (`deg:stream` when the critic's stream kept dying,
303
+ `deg:steps` when it ran out of steps), and `gate` for a builder that edited the
304
+ gate. Every attempt's full detail —
290
305
  statuses, verdicts, failing slots, unmet criteria, gate edits — is in the
291
306
  `--json` aggregate and in `matrix.json`.
292
307
 
@@ -296,8 +311,8 @@ produced none at all (`status: broke`).
296
311
 
297
312
  ## Local critic
298
313
 
299
- The builder always runs on the Claude Code CLI, but the critic is read-only
300
- and schema-constrained — a good fit for a cheaper local model. Point it at a
314
+ The critic is read-only and schema-constrained, which makes it a good fit for
315
+ a cheaper local model even when the builder stays on Claude. Point it at a
301
316
  local provider with `--critic-provider`:
302
317
 
303
318
  ```sh
@@ -317,19 +332,29 @@ Providers and their setup:
317
332
 
318
333
  | `--critic-provider` | Server | Base-URL env (default) | Required peer dependency |
319
334
  |---|---|---|---|
320
- | `ollama` | [Ollama](https://ollama.com) | `VOLLEY_OLLAMA_URL` (`http://localhost:11434`) | `ai-sdk-ollama@^4` |
335
+ | `ollama` | [Ollama](https://ollama.com) | `VOLLEY_OLLAMA_URL` (`http://localhost:11434`) | `ai-sdk-ollama@^4.3.0` |
321
336
  | `lmstudio` | [LM Studio](https://lmstudio.ai) | `VOLLEY_LMSTUDIO_URL` (`http://localhost:1234/v1`) | `@ai-sdk/openai-compatible` |
322
337
 
323
338
  Install the peer for your provider in the project running volley (e.g.
324
- `pnpm add ai-sdk-ollama@^4`); fascicle loads it lazily only when the local
325
- critic actually runs. The major matters: it must satisfy fascicle's declared
326
- peer range (`^4` for fascicle 0.12.13, which tracks the AI SDK v7 line; a bare
327
- `pnpm add ai-sdk-ollama` installs v4, which is exactly what this major wants).
339
+ `pnpm add ai-sdk-ollama@^4.3.0`); fascicle loads it lazily only when the local
340
+ critic actually runs. The version matters. The major must satisfy fascicle's
341
+ declared peer range (`^4` for fascicle 0.16.3, which tracks the AI SDK v7
342
+ line), and volley declares 4.3.0 as its floor (an optional peer, so your
343
+ package manager flags an older copy at install time). Before 4.3.0,
344
+ ai-sdk-ollama streams a thinking model's reasoning only when the request sets
345
+ `think`, which volley never does: the reasoning disappears from the terminal
346
+ and the trajectory, and the phase's throughput reads high. A bare
347
+ `pnpm add ai-sdk-ollama` installs the latest 4.x, which clears the floor.
328
348
  The Ollama base URL is the **server root** — `ai-sdk-ollama` adds the `/api`
329
349
  prefix itself (volley strips a trailing `/api` from `VOLLEY_OLLAMA_URL` for
330
350
  compatibility). Local providers are free, so the critic's cost is reported as
331
351
  `$0.000` and the run total reflects builder spend only.
332
352
 
353
+ The env var sets a provider's URL for both seats. To give the critic a server
354
+ of its own, pass `--critic-url` (`critic_url` in a config file). A seat uses its
355
+ own URL, then the provider's env var, then the default, and never the other
356
+ seat's URL; see [Seats on different machines](#seats-on-different-machines).
357
+
333
358
  Keeping the builder on Claude while the critic goes local is the low-risk split:
334
359
  the recurring per-iteration critic cost drops to zero and the critic is
335
360
  read-only, so a weaker local model can only mis-judge, never mis-edit. A local
@@ -344,7 +369,14 @@ the *same* model drives the builder tool loop fine, so the defect is specific to
344
369
  the constrained-verdict path. volley no longer lets that kill a run: the critic
345
370
  phase degrades (a bounded retry, then a tool-less fallback that keeps the
346
371
  schema), marks the verdict `critic_degraded: true`, and `--dry-run` predicts the
347
- doomed combo up front with a critic-seat canary. Even so, prefer a critic whose
372
+ doomed combo up front with a critic-seat canary. A critic that runs out of steps
373
+ before its verdict (its 10-step budget spent, or its last turn a tool call) skips
374
+ the retry, since another identical tool loop would only run out again, and goes
375
+ straight to the tool-less pass. The record's `degrade_cause` says which happened:
376
+ `stream_error` or `step_exhaustion`. A verdict cut off by the token limit or
377
+ blocked by a content filter isn't degraded: it fails the critic phase (exit 6)
378
+ with the finish reason in the message, because taking the tools away fixes
379
+ neither. Even so, prefer a critic whose
348
380
  tool-call encoding Ollama parses cleanly — `qwen3:8b`, `gemma4:12b`, and
349
381
  `glm-4.7-flash` all pass. The full model-vs-transport write-up is in
350
382
  [`research/v3-comparison-finding.md`](./research/v3-comparison-finding.md).
@@ -408,7 +440,8 @@ is discarded wholesale in all three modes: an abandoned phase leaves nothing
408
440
  behind.
409
441
 
410
442
  Provider setup is the same as the [local critic](#local-critic) table
411
- (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL`, same peer dependencies). At
443
+ (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL`, or `--builder-url` for the builder
444
+ alone; same peer dependencies). At
412
445
  builder start (and before a local critic's first verdict) volley pre-loads an
413
446
  Ollama model with a load-only `/api/generate` call, so a cold multi-GB model
414
447
  load doesn't eat the first real request's time-to-first-byte budget — without
@@ -443,6 +476,264 @@ weak local model to drive a tool loop reliably has three sharp edges:
443
476
  Local providers are free, so a local builder's cost is reported as `$0.000` and
444
477
  never trips `--max-cost-usd`.
445
478
 
479
+ ### Seats on different machines
480
+
481
+ Each seat can reach its own model server, so the builder and the critic can run
482
+ on different machines even when they use the same provider. Give each seat a
483
+ URL with `--builder-url` and `--critic-url`, or in a config file:
484
+
485
+ ```ts
486
+ const config: VolleyConfig = {
487
+ // ...the task...
488
+ builder_provider: 'ollama',
489
+ builder_model: 'qwen3-coder:30b',
490
+ builder_url: 'http://localhost:11434', // this machine
491
+ critic_provider: 'ollama',
492
+ critic_model: 'qwen3:8b',
493
+ critic_url: 'http://localhost:11522', // a second machine, forwarded here
494
+ };
495
+ ```
496
+
497
+ - **Fallback.** A seat without a URL of its own uses the provider's env var,
498
+ then the default, never the other seat's URL.
499
+ - **Same rules as the env var.** Ollama's trailing `/api` is dropped. The URL
500
+ must be plain `http(s)`, with no credentials, query, or fragment. Include the
501
+ port: a URL without one goes to 80 or 443, not the provider's default.
502
+ - **`claude_cli` takes no URL.** Setting one on a `claude_cli` seat is a
503
+ configuration error (exit 5).
504
+ - **Everything a seat sends goes to its own URL.** That covers the model calls,
505
+ the Ollama prewarm and context check, and the `--dry-run` endpoint probe and
506
+ critic canary. `volley matrix` holds both URLs fixed across every pairing.
507
+
508
+ Inside the sandbox container, a loopback seat URL crosses to the host at
509
+ `VOLLEY_MODEL_HOST` the way the env var does, keeping its port. So the two seats
510
+ can be two forwarded ports on the host (`localhost:11522`, `localhost:11523`).
511
+ With the allowlist's host rules in place the container reaches only the host
512
+ gateway, so it can reach a seat on another machine only through a port forward
513
+ on the host, for example `ssh -N -L 11522:localhost:11434 gpu-box`. On a Linux
514
+ host, `host-gateway` is the Docker bridge's address, so the forward, like Ollama
515
+ itself, has to listen there rather than on `127.0.0.1` alone.
516
+
517
+ ## OpenRouter
518
+
519
+ `--builder-provider openrouter` and `--critic-provider openrouter` put a seat on
520
+ [OpenRouter](https://openrouter.ai), so one API key runs both seats, Claude
521
+ included, with no Claude Code CLI and no local model server:
522
+
523
+ ```sh
524
+ OPENROUTER_API_KEY=sk-or-v1-... volley \
525
+ --prompt "@task.md" --workspace ./my-project --criteria "@criteria.md" \
526
+ --builder-provider openrouter --builder-model anthropic/claude-sonnet-5.5 \
527
+ --critic-provider openrouter --critic-model anthropic/claude-haiku-5.5 \
528
+ --allow-unsandboxed-builder --max-iterations 3
529
+ ```
530
+
531
+ - **The key.** `OPENROUTER_API_KEY` is required whenever either seat is on
532
+ `openrouter`. Unset, volley exits 5 naming it, before any work. It goes out
533
+ as a Bearer token, with `X-Title: volley` so OpenRouter attributes the
534
+ traffic.
535
+ - **The URL.** `VOLLEY_OPENROUTER_URL` (default `https://openrouter.ai/api/v1`)
536
+ points both seats at a proxy or another OpenAI-compatible gateway, and
537
+ `--builder-url` / `--critic-url` give one seat a URL of its own, under the
538
+ rules in [Seats on different machines](#seats-on-different-machines).
539
+ OpenRouter runs on fascicle's native transport, so there's no peer to
540
+ install, and the run summary records the transport as `native`.
541
+ - **The seats.** An OpenRouter seat runs volley's tool loop, the same as a
542
+ local one. The builder gets volley's tools, `bash` included, so it's refused
543
+ uncontained unless you opt out, exactly like a
544
+ [local builder](#local-builder). The critic gets the read-only tools and the
545
+ same retry-and-degrade ladder as a [local critic](#local-critic).
546
+ `--dry-run` probes each OpenRouter seat's endpoint but runs no critic canary,
547
+ which would cost money here.
548
+ - **Cost and reasoning.** OpenRouter reports what each call cost, and that
549
+ charge is the seat's cost, recorded as `provider_reported`, so
550
+ `--max-cost-usd` trips on an OpenRouter seat as on any other. A model that
551
+ reasons streams its reasoning, which the terminal shows dimmed unless
552
+ `--no-thinking` hides it, and which Zed shows as a thought.
553
+ - **In the sandbox.** The container's allowlist reaches only the host gateway,
554
+ so a contained OpenRouter seat needs `openrouter.ai` let through your
555
+ `DOCKER-USER` rules. volley doesn't install that rule for you.
556
+
557
+ ## Run volley from Zed
558
+
559
+ `volley acp` serves volley to an editor over the
560
+ [Agent Client Protocol](https://agentclientprotocol.com), through fascicle's
561
+ `serve_acp`, so a prompt in [Zed](https://zed.dev)'s agent panel starts a volley
562
+ run on the folder Zed has open. The builder's work streams in as the reply, each
563
+ round opens with a **Round N** line, the check shows as a command card, the
564
+ critic's verdict arrives as a thought, and the turn ends with a summary.
565
+
566
+ ```sh
567
+ volley acp \
568
+ --builder-provider openrouter --builder-model anthropic/claude-sonnet-5.5 \
569
+ --critic-provider openrouter --critic-model anthropic/claude-haiku-5.5 \
570
+ --check "pnpm test"
571
+ ```
572
+
573
+ It reads the protocol on stdin and writes nothing but the protocol to stdout,
574
+ with its own progress on stderr, which Zed shows in its agent log. Zed starts it
575
+ for you ([below](#zed-setup)).
576
+
577
+ ### Each prompt is a run
578
+
579
+ - **Fresh, in place.** Each prompt is a new volley run on the open folder, with
580
+ no worktree. Its task and its acceptance criteria are both the conversation so
581
+ far, then the new prompt. An earlier reply keeps only its last 2,000
582
+ characters, where its summary sits. An earlier prompt's attached files shrink
583
+ to a line naming each, since they're still on disk. A turn you stopped is
584
+ marked as stopped.
585
+ - **Three rounds.** `--max-iterations` defaults to 3 per prompt, not 10, since
586
+ a prompt is one short exchange, and `--max-cost-usd` caps what it spends.
587
+ Follow a run that didn't converge with "keep going": the summary it ended
588
+ with lists what the critic still found missing.
589
+ - **The tools.** The builder gets fascicle's workspace tools (`read_file`,
590
+ `list_files`, `search`, `write_file`, `edit_file`, `run_command`) plus
591
+ volley's `finish`. The critic gets the three reads. Both stay inside the open
592
+ folder, and neither gets `fetch`. volley doesn't offer the editor additional
593
+ directories, so in a workspace with several folders Zed opens the session on
594
+ the first, and a client that sends more anyway has its session refused.
595
+ - **The summary.** The turn's last message says whether the run converged, in
596
+ how many rounds, and with what last verdict. When it didn't converge, it
597
+ lists the unmet criteria, and it names any gate edits and any verdict the
598
+ critic reached without its tools. A run that fails ends with a summary saying
599
+ in which round and phase, and why, so "try again" has a request to retry.
600
+ - **Stop.** Pressing stop ends the turn as cancelled, and kills a running
601
+ command or check along with its whole process group.
602
+
603
+ ### Flags and refusals
604
+
605
+ `volley acp` takes the run command's seat flags, and requires four of them:
606
+ `--builder-provider`, `--builder-model`, `--critic-provider`, and
607
+ `--critic-model`. A missing one is refused with every missing flag named. The
608
+ rest are optional: `--builder-url`, `--critic-url`, `--builder-max-steps`,
609
+ `--allow-unsandboxed-builder`, `--critic`, `--check`, `--max-iterations`,
610
+ `--max-cost-usd`, and `--state-dir`.
611
+
612
+ It refuses these before serving, each with exit 5:
613
+
614
+ | Refused | Why |
615
+ |---|---|
616
+ | `--worktree` | A sibling worktree sits outside the project the editor has open. |
617
+ | `--git` | It commits to the open repo after every phase, which no permission prompt covers. |
618
+ | `--json` | Stdout carries the protocol alone. |
619
+ | a `claude_cli` builder | The Claude CLI runs its own tools under its own permission model, so its edits would never reach the editor's prompts. |
620
+
621
+ A `claude_cli` critic is allowed: it only reads, and it runs on the host, where
622
+ its login works. The whole config resolves once at startup, so a bad flag or an
623
+ unset `OPENROUTER_API_KEY` exits 5 there, where Zed's agent log shows it, rather
624
+ than failing every prompt.
625
+
626
+ ### Modes and approvals
627
+
628
+ Each session starts in Manual. Zed's mode picker switches it, and a switch holds
629
+ from the next call on, mid-turn included. Zed also saves the mode you pick as the
630
+ entry's `default_config_options`, and starts the entry's later threads in it.
631
+
632
+ | Mode | Asks before an edit | Asks before a command |
633
+ |---|---|---|
634
+ | Manual | yes | yes |
635
+ | Accept edits | no | yes |
636
+ | Auto | no | no; offered only contained or opted out (below) |
637
+
638
+ An edit asks with its diff, and offers "Allow all edits this session", which
639
+ switches the session to Accept edits. A command offers "Always allow" for that
640
+ exact command line: allowing `pnpm test` doesn't cover `pnpm test --watch=false`,
641
+ which asks again. It never offers one for a command with shell control syntax
642
+ such as `;` or `|`. A call you reject reaches the builder as
643
+ `tool_approval_denied`, and its prompt tells it not to reach the same result
644
+ another way.
645
+
646
+ - **Uncontained, Auto isn't offered.** Outside volley's
647
+ [sandbox container](#local-builder) (`VOLLEY_CONTAINED`), a person approving
648
+ each command takes the container's place, so the mode picker lists Manual and
649
+ Accept edits alone, and the builder's `run_command` asks in both. No client
650
+ can switch the session to Auto, mid-turn included. A Zed entry left on Auto
651
+ (`"default_config_options": { "mode": "auto" }`, from a time it was offered)
652
+ falls back to Manual, with a warning in Zed's log.
653
+ `--allow-unsandboxed-builder` or `VOLLEY_ALLOW_UNSANDBOXED_BUILDER=1` opts
654
+ out, and Auto is offered again. So unlike the run command, `volley acp` never
655
+ refuses an uncontained builder.
656
+ - **The check asks whenever a command would.** In Accept edits and Auto the
657
+ builder can rewrite a test without asking, and the check runs it next. So the
658
+ check asks on its own card exactly when a builder command would ask, offering
659
+ only Allow and Reject, since an "always" for checkride's `pnpm exec` would
660
+ cover the builder's commands too. A rejected check counts as a failed one, so
661
+ a round can't converge on work nobody checked.
662
+ - **No keys in commands.** The builder's commands and the check run with the
663
+ agent's environment minus `OPENROUTER_API_KEY` and `ANTHROPIC_API_KEY`,
664
+ `PATH` kept, so nothing the builder writes can read the key a seat runs on.
665
+
666
+ ### Where its state goes
667
+
668
+ Nothing of volley's own lands in the repo. Each prompt's run state goes to
669
+ `<root>/<run id>/`, where the root is `--state-dir`, else
670
+ `$XDG_STATE_HOME/volley`, else `~/.local/state/volley`. Nothing prunes old runs.
671
+ The whole root is safe to delete whenever no prompt is running. A run's
672
+ `trajectory.jsonl` there replays in `fascicle-viewer` like a CLI run's
673
+ ([Run artifacts](#run-artifacts)).
674
+
675
+ ### Zed setup
676
+
677
+ Run `pnpm build` first: Zed runs `dist/cli.js`, and nothing else rebuilds it.
678
+ Then add volley as a custom agent server in Zed's `settings.json`:
679
+
680
+ ```json
681
+ {
682
+ "agent_servers": {
683
+ "Volley": {
684
+ "type": "custom",
685
+ "command": "node",
686
+ "args": [
687
+ "/abs/path/to/volley/dist/cli.js", "acp",
688
+ "--builder-provider", "openrouter", "--builder-model", "anthropic/claude-sonnet-5.5",
689
+ "--critic-provider", "openrouter", "--critic-model", "anthropic/claude-haiku-5.5",
690
+ "--check", "pnpm test"
691
+ ],
692
+ "env": {
693
+ "OPENROUTER_API_KEY": "sk-or-v1-...",
694
+ "PATH": "/usr/local/bin:/usr/bin:/bin"
695
+ }
696
+ }
697
+ }
698
+ }
699
+ ```
700
+
701
+ `PATH` is there for the builder's commands and the check. Each runs with
702
+ `/bin/sh -c`, which reads no shell profile, so a `node` or `pnpm` from nvm, fnm,
703
+ volta, or asdf can come back as `command not found` unless it's on this `PATH`.
704
+ Put the directories that `dirname "$(command -v node)"` and
705
+ `dirname "$(command -v pnpm)"` print at its front.
706
+
707
+ ### Check it in Zed
708
+
709
+ Run this by hand against a scratch repository with a test command (a
710
+ `package.json` whose `test` script runs `node --test` is enough), using the
711
+ snippet above. Start a thread with "Volley", and check that the mode picker
712
+ shows Manual: Zed starts a thread in the last mode you picked, so an earlier run
713
+ of step 8 leaves it on Accept edits. Stay in Manual unless a step says
714
+ otherwise:
715
+
716
+ 1. Ask for a small change. The builder's work should stream in as the reply,
717
+ under a **Round 1** line.
718
+ 2. When it edits a file, a permission request should show the diff. Allow it,
719
+ and the card should keep the diff.
720
+ 3. When it runs a command, a permission request should show the command before
721
+ it runs, and once allowed, the card should show the output.
722
+ 4. Once the builder finishes, the check should ask, then run as a command card
723
+ titled `pnpm test`, with its output.
724
+ 5. The critic's verdict should show as a thought.
725
+ 6. The turn should end with the summary: whether it converged, in how many
726
+ rounds, and the last verdict.
727
+ 7. Ask it to run `sleep 60`, allow it, and press stop. The turn should end as
728
+ cancelled, and `pgrep -fl 'sleep 60'` should find nothing.
729
+ 8. Open the mode picker. It should list Manual and Accept edits, and no Auto,
730
+ since volley runs uncontained. Switch to Accept edits and ask for another
731
+ change. The edits shouldn't ask, but a command still should.
732
+
733
+ Two more round it out. Ask it to run `echo "key=$OPENROUTER_API_KEY"`, and the
734
+ output should be `key=` with nothing after it. And the repository should hold no
735
+ `.volley`.
736
+
446
737
  ## What the builder changed
447
738
 
448
739
  A check that exits 0 proves the check passed. It does not prove the builder did
@@ -486,7 +777,7 @@ that `--fail-on-gate-edit` cannot fire there.
486
777
 
487
778
  ## Run artifacts
488
779
 
489
- Everything volley knows lives in the workspace:
780
+ By default, everything volley knows lives in the workspace:
490
781
 
491
782
  ```
492
783
  .volley/
@@ -500,18 +791,30 @@ Everything volley knows lives in the workspace:
500
791
 
501
792
  A stale `.volley/` from a previous run is rotated to `.volley.bak.<timestamp>/`.
502
793
 
794
+ With `--state-dir <root>` (or `state_dir` in a config file), each run writes the
795
+ same files to `<root>/<run id>/` instead, and nothing to the workspace. A
796
+ directory per run never needs rotating, and `config.json` records the root.
797
+ `volley acp` always works this way, under a root of its own
798
+ ([Where its state goes](#where-its-state-goes)). `volley matrix` still writes
799
+ its sweep to `.volley-matrix/` in the workspace, and a base config's
800
+ `state_dir` moves only each seat's run state.
801
+
503
802
  The trajectory replays in fascicle's bundled viewer — the `fascicle-viewer` bin
504
803
  ships inside the `fascicle` package; there is no separate viewer package:
505
804
 
506
805
  ```sh
507
806
  pnpm dlx --package=fascicle fascicle-viewer .volley/trajectory.jsonl
807
+ pnpm dlx --package=fascicle fascicle-viewer <root>/<run id>/trajectory.jsonl # under --state-dir
508
808
  ```
509
809
 
510
810
  ## Environment variables
511
811
 
512
812
  See [.env.example](./.env.example): `VOLLEY_CLAUDE_BIN`, `VOLLEY_AUTH_MODE`,
513
- `VOLLEY_LOG_LEVEL`, `VOLLEY_NO_COLOR`, `VOLLEY_PRICING_PATH`, and the
514
- `ANTHROPIC_API_KEY` billing-meter warning.
813
+ `VOLLEY_LOG_LEVEL`, `VOLLEY_NO_COLOR`, `VOLLEY_PRICING_PATH`, the
814
+ `ANTHROPIC_API_KEY` billing-meter warning, each seat's base URL
815
+ (`VOLLEY_OLLAMA_URL`, `VOLLEY_LMSTUDIO_URL`, `VOLLEY_OPENROUTER_URL`), and
816
+ `OPENROUTER_API_KEY` for [OpenRouter](#openrouter) seats. `volley acp` also
817
+ reads `XDG_STATE_HOME` for its state root.
515
818
 
516
819
  ## Development
517
820
 
@@ -520,7 +823,7 @@ pnpm install
520
823
  pnpm test # vitest
521
824
  pnpm typecheck # tsc --noEmit
522
825
  pnpm check # checkride — volley dogfoods its own definition of done
523
- pnpm build # tsup → dist/
826
+ pnpm build # tsdown → dist/
524
827
  pnpm diagram # draw the flow from src/flow.ts with describe.diagram
525
828
  ```
526
829