pitroom 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pitroom",
3
3
  "description": "A free pit crew for your expensive coding agent: the superpowers development workflow, run by cheap workers. Delegate research, fixes, tests, reviews and whole plans from Claude Code, Codex or any agent to OpenCode, Codex or Claude Code workers, with receipts that prove the savings.",
4
- "version": "0.26.0",
4
+ "version": "0.27.0",
5
5
  "author": {
6
6
  "name": "TahaAtas"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pitroom",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "A free pit crew for your expensive coding agent: delegate research, fixes, tests, reviews and parallel investigations to cheap worker agents running your own models, with verified answers, exact diffs and cost receipts.",
5
5
  "author": {
6
6
  "name": "TahaAtas"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.0
4
+
5
+ ### Workers
6
+ - **Antigravity edits work again with agy 1.4.** agy 1.4 asks for a `write_file` grant before an edit in the workspace, `--mode accept-edits` or not, and a headless run cannot ask: every `-i` and `-w` run with Antigravity ended without an answer ("a tool required the write_file permission"). Edit runs now grant writes in their own workspace; read runs still may not write, and the workspace's secret files stay closed.
7
+ - **Antigravity is out of beta on macOS.** `scripts/check-worker.mjs` held in all eight checks with agy 1.4.7 there; on Linux and Windows, where it has not been run yet, it stays beta, and `pitroom doctor` says so there.
8
+ - **`node scripts/check-worker.mjs <worker>`** tries a worker CLI the way Pitroom uses it (a read, a follow-up, a read outside the project, a shell command, an isolated edit, a commit the git guard refuses, an unknown model) in a scratch repository with a Pitroom state folder of its own, and prints what held: for a platform a worker has not been tried on. It found the agy 1.4 change above.
9
+ - **`autoFallback`** (off by default): with no `fallback` list, a run falls back to the other workers your config already names (tiers, `models`, `worker`), the installed ones, cheapest first by `costs`, at most four. It never adds a model you did not name somewhere, nor the worker itself at another effort level; `pitroom doctor` shows the chain and says where it came from.
10
+
11
+ ### Reviews
12
+ - **A review's file:line is the file's again.** A review package shows the change as a diff, and a reviewer that reads files with a line-numbered tool saw the package's own line numbers: Claude Code cited those for a third of the references in its task reviews and half of those in its branch reviews (`browser-observation.ts:177-250` for a file of 75 lines), so the reference check failed answers that were right. Every line of a hunk in the package now starts with its line number in the file after the change (`+ 99| new`), and all three review templates say to cite those (the branch review's said nothing about it).
13
+ - **The reference check finds more of the files an answer means.** A path with a folder left out (`api-contracts/integrations.ts` for `packages/api-contracts/src/integrations.ts`) is matched when the folders written appear in the file's path in order, and a file only a dependency or the build output has (`streamableHttp.d.ts:73`, under `node_modules`) is looked for there when the project has none of that name; the lines and the symbol are still checked. On one machine's past answers, checked again against today's files, a quarter of OpenCode's failed references and a sixth of Claude's held up.
14
+
15
+ ### Windows
16
+ - **The git shim is a `git.exe` now**, built on the spot with the C# compiler that ships with Windows (.NET Framework 4). It hands its command line to `sh.exe` unread, so every argument arrives as written, an unquoted `& | < >` from PowerShell too (the `cmd /c` line a batch file is started with acted on those), and a program that starts git without a shell no longer skips the shim. Without a compiler, `git.cmd` stays the door, and `pitroom doctor` says so.
17
+
18
+ ### Docs and tests
19
+ - `benchmarks/outcome-prediction` again, on four weeks of runs (1,455), with two more labels: a review's verdict on a change, and whether an answer's references check out. Who answers predicts the latter well (AUC 0.85), but the label measures the reference checker too; failures are still about providers, and audit and review verdicts are still too few. No learned model yet.
20
+ - The review benchmark's harmless packages are reviewed in full: `big-pickle` raised no false alarm in 16, `nemotron-3-ultra` 4 and `mimo-v2.6-flash` 2.
21
+
3
22
  ## 0.26.0
4
23
 
5
24
  ### Workers
package/README.md CHANGED
@@ -72,7 +72,7 @@ Pitroom uses your worker CLI's default model: a free tier, a local MLX/Ollama mo
72
72
 
73
73
  ### Any worker CLI
74
74
 
75
- OpenCode, Codex CLI, Claude Code, Gemini CLI, Qwen Code, Kilo Code and Antigravity CLI (beta).
75
+ OpenCode, Codex CLI, Claude Code, Gemini CLI, Qwen Code, Kilo Code and Antigravity CLI (beta on Linux and Windows).
76
76
 
77
77
  One group can mix them, and a fallback chain can cross them ([how adapters work](docs/backends.md)).
78
78
 
@@ -252,12 +252,12 @@ Only workers that ran are in the table. Models that could not answer at all (a p
252
252
 
253
253
  | Worker and model | Bug found | Exact line | False alarm | Reviews | Mean time | Slowest |
254
254
  |---|---|---|---|---|---|---|
255
- | OpenCode `nemotron-3-ultra-free` | 100% | 83% | 29% (4 of 14) | 29 + 14 | 1 min 33 s | 3 min 29 s |
255
+ | OpenCode `nemotron-3-ultra-free` | 100% | 83% | 25% (4 of 16) | 29 + 16 | 1 min 30 s | 3 min 29 s |
256
256
  | OpenCode `space-bunny-free` | 100% | 100% | - | 29 | 2 min 19 s | 5 min 46 s |
257
257
  | Codex `gpt-6-sol` | 100% | 100% | - | 9 | 38 s | 52 s |
258
- | OpenCode `big-pickle` | 97% | 90% | **0% (0 of 13)** | 29 + 13 | 1 min 50 s | 6 min 16 s |
258
+ | OpenCode `big-pickle` | 97% | 90% | **0% (0 of 16)** | 29 + 16 | 1 min 45 s | 6 min 16 s |
259
259
  | OpenCode `muse-spark-1.3-contributor-free` | 97% | 62% | - | 29 | 43 s | 2 min 0 s |
260
- | OpenCode `mimo-v2.6-flash-free` | 90% | 90% | 15% (2 of 13) | 29 + 13 (4 timed out) | 4 min 33 s | 8 min 1 s |
260
+ | OpenCode `mimo-v2.6-flash-free` | 90% | 90% | 13% (2 of 16) | 29 + 16 (4 timed out) | 4 min 36 s | 8 min 1 s |
261
261
  | OpenCode `longcat-2.5-preview-free` | 86% | 86% | - | 29 (1 timed out) | 2 min 53 s | 8 min 0 s |
262
262
 
263
263
  Almost every model found almost every bug, so finding them does not separate the models; leaving the harmless change alone does. `big-pickle` never raised a false alarm. `nemotron-3-ultra` and `mimo-v2.6-flash` each said, in their own words, that the change altered nothing, and still filed it as Important ("a Yoda condition, inconsistent with the rest of the file"), where the review template puts style under Minor; an agent that acts on Important findings would have reverted a harmless commit. `muse-spark` described the right bug but often quoted a wrong line number (62% exact). The models with a dash were not available any more, or out of quota, when the harmless packages were added; Codex `gpt-6-sol` reached its plan's usage limit after 9 reviews, and the others' left-out reviews hit the free tier's limit, so they are not counted as misses. Every change is only 2 to 5 lines, which is easy; the harmless edits are all of one kind (a mirrored comparison); and React, Django and Kubernetes may be in a model's training data. The harness, the rule that keeps an edit harmless (and its test) and every raw review are in [`benchmarks/review-bugs`](benchmarks/review-bugs).
@@ -278,14 +278,14 @@ To repeat it on your own repository: `pitroom crew -d <repo> -g bench "<question
278
278
 
279
279
  macOS and Linux are the main platforms. **Windows** runs the same test suite in CI (Windows Server, Node 22 and 24, with the fake workers; the worker CLIs themselves are not run there), with these differences:
280
280
 
281
- - **The git guard** is the same two layers as elsewhere, built on the `sh.exe` that Git for Windows ships: git's own config (a `reference-transaction` hook that refuses every ref update, and push URLs that go nowhere) is always on; the `git` shim (`git.cmd` for cmd.exe and PowerShell, `git` for Git Bash) adds friendly refusals and also blocks `add`, `restore`, `clean` and the rest. A worker that starts git without a shell (a plain `CreateProcess`) skips the shim, and is left with the config layer. Git installed with Scoop works too (its `shims\git.exe` is followed to the real one, and so to its `sh.exe`). PowerShell starts `git.cmd` through a `cmd /c` line, which removes a `^` and fills in a `%name%` that names a variable before the shim runs; the shim takes the arguments from the raw line instead, so `git show HEAD^` shows the parent, not `HEAD`. That line still acts on an unquoted `& | < >` (as it does for any batch file): quote such an argument. `pitroom doctor` says which is in effect. It is verified in CI with Git for Windows, not yet with the worker CLIs on a real Windows machine.
281
+ - **The git guard** is the same two layers as elsewhere, built on the `sh.exe` that Git for Windows ships: git's own config (a `reference-transaction` hook that refuses every ref update, and push URLs that go nowhere) is always on; the `git` shim adds friendly refusals and also blocks `add`, `restore`, `clean` and the rest. Its doors are `git.exe`, a small launcher Pitroom builds with the C# compiler that ships with Windows (.NET Framework 4), for every program that starts git, with a shell or without one; `git` for Git Bash; and `git.cmd` where no compiler is found. `git.exe` hands its command line to `sh.exe` unread, so arguments arrive as written (`HEAD^`, `%cd%`, an unquoted `&`). Through `git.cmd` alone, a program that starts git without a shell skips the shim and is left with the config layer, and the `cmd /c` line Windows starts a batch file with still acts on an unquoted `& | < >` (a `^` and a `%name%` are kept: the shim reads the raw line). Git installed with Scoop works too (its `shims\git.exe` is followed to the real one, and so to its `sh.exe`). `pitroom doctor` says which is in effect. It is verified in CI with Git for Windows, not yet with the worker CLIs on a real Windows machine.
282
282
  - **The launcher is `pitroom.cmd`** (`%USERPROFILE%\.local\bin`), started with the Node that ran `pitroom install` or the one in `PITROOM_NODE`; it does not search for another Node.
283
283
  - **Line endings.** Pitroom compares and applies patches byte for byte. Keep `core.autocrlf` off (or an `eol=lf` in `.gitattributes`, as this repository has); a checkout converted to CRLF has not been tested with `apply`.
284
284
  - A read snapshot's files are not made read-only (Windows has no POSIX modes), and a `--verify` command runs in `cmd.exe`, which reports a missing command as `is not recognized`.
285
285
 
286
286
  ## Quick start
287
287
 
288
- You need Node.js 22.13+ and at least one worker CLI: [OpenCode](https://opencode.ai) v2+ (the default worker, with free models), [Codex CLI](https://github.com/openai/codex), [Claude Code](https://claude.com/claude-code) [Gemini CLI](https://github.com/google-gemini/gemini-cli) (beta; it needs an API key from [Google AI Studio](https://aistudio.google.com/apikey), a Google account sign-in no longer works for it) [Qwen Code](https://github.com/QwenLM/qwen-code) (beta; any OpenAI-compatible provider, a DashScope key, or a local model) [Kilo Code](https://kilo.ai)'s CLI (beta; `npm i -g @kilocode/cli`, its 500+ models and providers) or the [Antigravity CLI](https://antigravity.google) (beta; `agy`, signed in with your Google account).
288
+ You need Node.js 22.13+ and at least one worker CLI: [OpenCode](https://opencode.ai) v2+ (the default worker, with free models), [Codex CLI](https://github.com/openai/codex), [Claude Code](https://claude.com/claude-code) [Gemini CLI](https://github.com/google-gemini/gemini-cli) (beta; it needs an API key from [Google AI Studio](https://aistudio.google.com/apikey), a Google account sign-in no longer works for it) [Qwen Code](https://github.com/QwenLM/qwen-code) (beta; any OpenAI-compatible provider, a DashScope key, or a local model) [Kilo Code](https://kilo.ai)'s CLI (beta; `npm i -g @kilocode/cli`, its 500+ models and providers) or the [Antigravity CLI](https://antigravity.google) (`agy`, signed in with your Google account; beta on Linux and Windows).
289
289
 
290
290
  ### 1. Install: pick your agent
291
291
 
@@ -684,7 +684,7 @@ pitroom run -W claude:haiku "…" # Claude Code on a ch
684
684
  pitroom run -W gemini:gemini-3.8-flash "…" # Gemini CLI (beta; needs a Google AI Studio API key)
685
685
  pitroom run -W qwen:qwen3-coder-plus "…" # Qwen Code (beta; your provider from ~/.qwen/settings.json)
686
686
  pitroom run -W kilo:kilo/<model> "…" # Kilo Code's CLI (beta; any model from `kilo models`)
687
- pitroom run -i -W antigravity "…" # Antigravity CLI (beta; your Google account, models from `agy models`)
687
+ pitroom run -i -W antigravity "…" # Antigravity CLI (your Google account, models from `agy models`; beta on Linux and Windows)
688
688
  pitroom run -m nvidia/z-ai/glm-5.3 "…" # another model on the preferred worker
689
689
  ```
690
690
 
@@ -696,7 +696,7 @@ pitroom run -m nvidia/z-ai/glm-5.3 "…" # another model on th
696
696
  | Gemini CLI | beta | `--approval-mode plan` plus Pitroom's policy rules | tokens only | an API key from Google AI Studio (sign in with `gemini` or set `GEMINI_API_KEY`; Google account sign-in is refused by Google); workers run in a private Gemini home without your hooks, MCP servers and skills; secret-file rules are not enforced by Gemini 0.62, but a read run with secret-looking files reads a [clean snapshot](#modes) without them; pin a model, e.g. `-W gemini:gemini-3.8-flash`; `--continue` works (by session id; see [the notes](docs/backends.md#gemini-cli-notes)) |
697
697
  | Qwen Code | beta | `--approval-mode plan`; edits only inside the working directory, the rest declined | tokens only | checked against Qwen Code 0.25 with a scripted model, not yet with a real provider; uses your sign-in and model from `~/.qwen/settings.json` (any OpenAI-compatible provider, DashScope, a local model); `--safe-mode` keeps your QWEN.md, hooks, skills and MCP servers out and stops the CLI writing memories of the run; reads `.env` if asked, so a read run with secret-looking files reads a [clean snapshot](#modes); see [the notes](docs/backends.md#qwen-code-notes) |
698
698
  | Kilo Code | beta | per-run permission rules (OpenCode's profiles) | reported | the CLI of `@kilocode/cli` (an OpenCode fork): the same `pitroom-read`/`pitroom-write` profiles through `KILO_CONFIG_CONTENT`, your sign-in and providers as they are; workers use a private state folder so a running `kilo daemon` (which would ignore the profiles) is never attached to; checked against Kilo 7.8.8 with a scripted model, not yet with a real provider; see [the notes](docs/backends.md#kilo-code-notes) |
699
- | Antigravity CLI | beta | a read-only [snapshot](#modes) of its own for every read run (agy has no read-only mode for headless runs) | tokens only | checked against agy 1.3.5 (macOS); use `-i` for changes, not `-w` (its deny rules are patterns, a layer on top of the git guard and the isolated copy); runs with a home of its own per workspace (agy reads its settings only from `~/.gemini`), so its commands do not see your dotfiles; an answerless run (a refused command can end its turn with no answer and exit 0) is a failure; your Google account sign-in (the one Gemini CLI now refuses); shell in agy's terminal sandbox (macOS, Linux); no web access for these workers; give it a fallback, its quota is per account; see [the notes](docs/backends.md#antigravity-cli-notes) |
699
+ | Antigravity CLI | macOS; beta on Linux and Windows | a read-only [snapshot](#modes) of its own for every read run (agy has no read-only mode for headless runs) | tokens only | checked against agy 1.4.7 on macOS (`scripts/check-worker.mjs`), not yet on Linux or Windows; use `-i` for changes, not `-w` (its deny rules are patterns, a layer on top of the git guard and the isolated copy); runs with a home of its own per workspace (agy reads its settings only from `~/.gemini`), so its commands do not see your dotfiles; an answerless run (a refused command can end its turn with no answer and exit 0) is a failure; your Google account sign-in (the one Gemini CLI now refuses); shell in agy's terminal sandbox (macOS, Linux); no web access for these workers; give it a fallback, its quota is per account; see [the notes](docs/backends.md#antigravity-cli-notes) |
700
700
 
701
701
  ### Models, costs and effort
702
702
 
@@ -771,6 +771,7 @@ Or put defaults in `~/.config/pitroom/config.json` (flags and env still win); `p
771
771
  - `auditFix` and `auditFocus`: send a disputed answer back to its worker for one correction, and lean the audit sample to list and count questions and to disputed workers (both default `false`; `PITROOM_AUDIT_FIX`, `PITROOM_AUDIT_FOCUS`), see [Audits](#audits).
772
772
  - `budgetDaily`: what the workers may cost per day, in USD (as their CLIs report it, or estimated from `workerPrices` and the price catalog; audits and corrections included). Past 80% a run warns; once it is spent only workers that cost nothing still run (a price of `0`, or a worker whose recent runs all cost exactly 0), a run with none in its chain is refused (exit 3), and audits and corrections do not start. A run that is still going counts with what it has used so far; runs started at the same moment count from their first finished step, so a crew can still end a day a little over. `0` allows only free workers. Default: no budget (`PITROOM_BUDGET_DAILY`).
773
773
  - `rankWorkers`: order the worker and its fallbacks by their record of the last 30 days on the same kind of work (a question, a change or a review: a model can answer well and still leave changes unfinished): the share of runs that ended well (rate limits left out), times the share of checked work that held up, with 3 checks or more (for an answer its audits, for a change the reviews of it that found the spec met and nothing to fix). Only workers with 5 runs or more of that kind move, among the places such workers hold, in steps of 10%; a worker you name (`-W`, `-m`, a tier) is never reordered, and the run says when the first worker changed (default `false`; `PITROOM_RANK_WORKERS=1`).
774
+ - `autoFallback`: with no `fallback` of yours, fall back to the other workers your config already names (its `tiers`, its `models` and its `worker`), the ones installed here, cheapest first by `costs` (those without a cost after, in the order named), at most four; never the worker itself at another effort level (default `false`; `PITROOM_AUTO_FALLBACK=1`). Pitroom still picks no model you did not name: it only reuses your own choices. Your `fallback` list, when you have one, and `--no-fallback` win; `pitroom doctor` says when the chain came from it.
774
775
  - `countRateLimits`: whether runs that failed on a rate limit or quota count in `pitroom history stats` and the dashboard's Stats (default `false`: they are left out of the run counts, success rates, times and tokens and shown apart as rate-limited, since they say nothing about the worker's work; `true` counts them as not ok).
775
776
 
776
777
  </details>
package/dist/lib.mjs CHANGED
@@ -542,7 +542,7 @@ function denyCommandRules() {
542
542
  "command(eval)"
543
543
  ];
544
544
  }
545
- function workerSettings(workspace, workerHome3, reads = []) {
545
+ function workerSettings(workspace, workerHome3, reads = [], writes = false) {
546
546
  const real = (p) => {
547
547
  try {
548
548
  return fs6.realpathSync.native(p);
@@ -566,6 +566,8 @@ function workerSettings(workspace, workerHome3, reads = []) {
566
566
  allow: [
567
567
  "command(*)",
568
568
  ...both(path5.join(dataDir(workerHome3), "brain")).map((p) => `write_file(${rulePath(p)})`),
569
+ // agy 1.4 asks for edits in the workspace too, --mode accept-edits or not, and a headless run cannot ask
570
+ ...writes ? both(workspace).map((p) => `write_file(${rulePath(p)})`) : [],
569
571
  ...toolchains().flatMap(both).map((p) => `read_file(${rulePath(p)})`),
570
572
  // what the task names outside the workspace: a headless agy cannot ask, and would end the turn unanswered
571
573
  ...reads.map((r) => r.path).flatMap(both).map((p) => `read_file(${rulePath(p)})`)
@@ -691,12 +693,12 @@ function baseSettings() {
691
693
  }
692
694
  var rulePath = (p) => p.replace(/^[A-Za-z]:(?=[\\/])/, "").replace(/\\/g, "/");
693
695
  var HOMES_KEPT_DAYS = 14;
694
- function workerHome(workspace, reads = []) {
696
+ function workerHome(workspace, reads = [], writes = false) {
695
697
  const root = path5.join(home(), "antigravity-home");
696
698
  const id2 = crypto3.createHash("sha256").update(path5.resolve(workspace)).digest("hex").slice(0, 16);
697
699
  const dir = path5.join(root, id2);
698
700
  fs6.mkdirSync(dataDir(dir), { recursive: true });
699
- writeIfChanged(path5.join(dataDir(dir), "settings.json"), `${JSON.stringify(workerSettings(workspace, dir, reads), null, 2)}
701
+ writeIfChanged(path5.join(dataDir(dir), "settings.json"), `${JSON.stringify(workerSettings(workspace, dir, reads, writes), null, 2)}
700
702
  `);
701
703
  if (process.platform === "darwin") {
702
704
  fs6.mkdirSync(path5.join(dir, "Library"), { recursive: true });
@@ -753,7 +755,7 @@ function invocation(req) {
753
755
  if (model) args.push("--model", model);
754
756
  if (effort) args.push("--effort", effort);
755
757
  const { command, prefix } = resolveCommand(binary());
756
- const workerDir = workerHome(req.cwd, outsideReads(prompt, req.cwd, req.project?.root));
758
+ const workerDir = workerHome(req.cwd, outsideReads(prompt, req.cwd, req.project?.root), req.mode !== "read");
757
759
  const env = { HOME: workerDir };
758
760
  if (process.platform === "win32") env.USERPROFILE = workerDir;
759
761
  return { command, args: [...prefix, ...args], env };
@@ -810,9 +812,10 @@ function doctor({ models, hasFallback }) {
810
812
  }
811
813
  const checks = [
812
814
  { level: "ok", message: `Antigravity CLI ${version.out.trim().split("\n")[0]} at ${binary()}` },
813
- {
815
+ // checked on macOS (agy 1.4.7, scripts/check-worker.mjs); Linux and Windows not run yet
816
+ process.platform === "darwin" ? { level: "ok", message: "agy has no read-only mode for headless runs, so its read runs always read a snapshot of the project; prefer -i for changes" } : {
814
817
  level: "warn",
815
- message: "the Antigravity worker is in beta (checked against agy 1.3.5). agy has no read-only mode for headless runs, so its read runs always read a snapshot of the project; prefer -i for changes"
818
+ message: `the Antigravity worker is in beta on ${process.platform === "win32" ? "Windows" : "Linux"} (checked on macOS only; node scripts/check-worker.mjs antigravity tries it here). agy has no read-only mode for headless runs, so its read runs always read a snapshot of the project; prefer -i for changes`
816
819
  }
817
820
  ];
818
821
  if (process.platform === "win32") {
@@ -2315,6 +2318,7 @@ var FILE_EXTENSIONS = new Set(
2315
2318
  );
2316
2319
  var SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", "target", "vendor", ".venv", "venv", "__pycache__", "coverage", ".turbo", ".cache"]);
2317
2320
  var MAX_INDEXED = 6e4;
2321
+ var MAX_SKIPPED = 3e5;
2318
2322
  var EXTENSIONLESS = "Makefile|Dockerfile|Containerfile|Gemfile|Rakefile|Procfile|Justfile|Vagrantfile|BUILD|WORKSPACE";
2319
2323
  var REF = new RegExp(
2320
2324
  String.raw`(?<![\w/:.-])(\.{0,2}/?(?:[\w@.+-]+/)*(?:[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,7}|${EXTENSIONLESS})):(\d+)(?:[-–](\d+))?`,
@@ -2373,13 +2377,17 @@ function verifyRefs(refs, dirs) {
2373
2377
  return lineCache.get(file);
2374
2378
  };
2375
2379
  let index;
2380
+ let skipped;
2376
2381
  const byName = () => index ??= indexFiles(roots);
2382
+ const inSkipped = () => skipped ??= indexFiles(roots, true);
2377
2383
  const candidates = (ref) => {
2378
2384
  const direct = resolve(ref.file, roots);
2379
2385
  if (direct) return [direct];
2380
2386
  if (path12.isAbsolute(ref.file)) return [];
2381
2387
  const wanted = ref.file.replace(/^(\.{1,2}\/)+/, "");
2382
- return (byName().get(path12.basename(wanted)) ?? []).filter((f) => f.split(path12.sep).join("/").endsWith(`/${wanted}`) || path12.basename(f) === wanted).slice(0, 20);
2388
+ const fits = (f) => path12.basename(f) === wanted || foldersInOrder(f.split(path12.sep).join("/"), wanted);
2389
+ const own = (byName().get(path12.basename(wanted)) ?? []).filter(fits);
2390
+ return (own.length || !isFileName(wanted) ? own : (inSkipped().get(path12.basename(wanted)) ?? []).filter(fits)).slice(0, 20);
2383
2391
  };
2384
2392
  const check = (file, ref) => {
2385
2393
  const lines = load(file);
@@ -2418,10 +2426,23 @@ var isFileName = (name) => {
2418
2426
  const base = path12.basename(name);
2419
2427
  return !base.includes(".") || new RegExp(`^(${EXTENSIONLESS})$`).test(base) || FILE_EXTENSIONS.has(base.split(".").pop().toLowerCase());
2420
2428
  };
2421
- function indexFiles(roots) {
2429
+ function foldersInOrder(file, wanted) {
2430
+ const want = wanted.split("/");
2431
+ const have = file.split("/");
2432
+ if (have.at(-1) !== want.at(-1)) return false;
2433
+ let i = have.length - 2;
2434
+ for (let j = want.length - 2; j >= 0; j--) {
2435
+ while (i >= 0 && have[i] !== want[j]) i--;
2436
+ if (i < 0) return false;
2437
+ i--;
2438
+ }
2439
+ return true;
2440
+ }
2441
+ function indexFiles(roots, skipped = false) {
2422
2442
  const index = /* @__PURE__ */ new Map();
2443
+ const cap = skipped ? MAX_SKIPPED : MAX_INDEXED;
2423
2444
  let count = 0;
2424
- const walk = (dir) => {
2445
+ const walk = (dir, within) => {
2425
2446
  let entries;
2426
2447
  try {
2427
2448
  entries = fs12.readdirSync(dir, { withFileTypes: true });
@@ -2429,10 +2450,12 @@ function indexFiles(roots) {
2429
2450
  return;
2430
2451
  }
2431
2452
  for (const e of entries) {
2432
- if (count >= MAX_INDEXED) return;
2453
+ if (count >= cap) return;
2433
2454
  if (e.isDirectory()) {
2434
- if (!SKIP_DIRS2.has(e.name)) walk(path12.join(dir, e.name));
2435
- } else if (e.isFile()) {
2455
+ if (e.name === ".git") continue;
2456
+ const skip = SKIP_DIRS2.has(e.name);
2457
+ if (skipped || !skip) walk(path12.join(dir, e.name), within || skip);
2458
+ } else if (e.isFile() && within === skipped) {
2436
2459
  count++;
2437
2460
  const list2 = index.get(e.name);
2438
2461
  if (list2) list2.push(path12.join(dir, e.name));
@@ -2440,7 +2463,7 @@ function indexFiles(roots) {
2440
2463
  }
2441
2464
  }
2442
2465
  };
2443
- for (const r of roots) walk(r);
2466
+ for (const r of roots) walk(r, false);
2444
2467
  return index;
2445
2468
  }
2446
2469
  function resolve(ref, roots) {
@@ -2601,7 +2624,8 @@ var SCHEMA = {
2601
2624
  priceFeedUrl: "string",
2602
2625
  priceFeedHours: "number",
2603
2626
  budgetDaily: "number",
2604
- rankWorkers: "boolean"
2627
+ rankWorkers: "boolean",
2628
+ autoFallback: "boolean"
2605
2629
  };
2606
2630
  function configPath() {
2607
2631
  if (process.env.PITROOM_CONFIG) return path15.resolve(process.env.PITROOM_CONFIG);
@@ -2711,6 +2735,7 @@ function effective(flags = {}) {
2711
2735
  priceFeedUrl: setting(void 0, e.PITROOM_PRICE_FEED_URL?.trim() || void 0, c.priceFeedUrl?.trim() || void 0, "https://models.dev/api.json"),
2712
2736
  priceFeedHours: setting(void 0, positiveInt(e.PITROOM_PRICE_FEED_HOURS), positiveInt(c.priceFeedHours), 24),
2713
2737
  rankWorkers: setting(void 0, flag01(e.PITROOM_RANK_WORKERS), c.rankWorkers, false),
2738
+ autoFallback: setting(void 0, flag01(e.PITROOM_AUTO_FALLBACK), c.autoFallback, false),
2714
2739
  budgetDaily: setting(void 0, amount(e.PITROOM_BUDGET_DAILY), amount(c.budgetDaily), void 0)
2715
2740
  };
2716
2741
  }
@@ -3156,6 +3181,7 @@ function summarize(rows) {
3156
3181
  }
3157
3182
 
3158
3183
  // src/vcs/guard.ts
3184
+ import { spawnSync as spawnSync9 } from "node:child_process";
3159
3185
  import fs19 from "node:fs";
3160
3186
  import path18 from "node:path";
3161
3187
  var VERSION = 2;
@@ -3293,6 +3319,63 @@ var cmdShim = (sh) => [
3293
3319
  "exit /b %errorlevel%",
3294
3320
  ""
3295
3321
  ].join("\r\n");
3322
+ var launcherSource = (sh) => `// pitroom git guard: git.exe launcher v${VERSION} (generated; edit src/vcs/guard.ts)
3323
+ using System;
3324
+ using System.Diagnostics;
3325
+ using System.IO;
3326
+
3327
+ static class PitroomGit {
3328
+ const string Sh = @"${sh.replace(/"/g, '""')}";
3329
+
3330
+ static int Main() {
3331
+ string line = Environment.CommandLine;
3332
+ // this program's own name, as Windows reads it: up to the closing quote when it starts with one, else to a space
3333
+ int i = 0;
3334
+ if (line.Length > 0 && line[0] == '"') { int q = line.IndexOf('"', 1); i = q < 0 ? line.Length : q + 1; }
3335
+ else { while (i < line.Length && line[i] != ' ' && line[i] != '\\t') i++; }
3336
+ string script = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "git");
3337
+ ProcessStartInfo start = new ProcessStartInfo(Sh, "\\"" + script + "\\"" + line.Substring(i));
3338
+ start.UseShellExecute = false;
3339
+ start.EnvironmentVariables["MSYS2_ARG_CONV_EXCL"] = "*";
3340
+ // Ctrl+C reaches git too: wait for it to end and pass its exit code on
3341
+ Console.CancelKeyPress += delegate (object o, ConsoleCancelEventArgs e) { e.Cancel = true; };
3342
+ using (Process git = Process.Start(start)) {
3343
+ git.WaitForExit();
3344
+ return git.ExitCode;
3345
+ }
3346
+ }
3347
+ }
3348
+ `;
3349
+ function findCsc() {
3350
+ const windir = process.env.WINDIR ?? process.env.SystemRoot ?? "C:\\Windows";
3351
+ for (const fw of ["Framework64", "Framework"]) {
3352
+ const p = path18.join(windir, "Microsoft.NET", fw, "v4.0.30319", "csc.exe");
3353
+ if (fs19.existsSync(p)) return p;
3354
+ }
3355
+ return void 0;
3356
+ }
3357
+ function buildLauncher(dir, sh) {
3358
+ const source = launcherSource(sh);
3359
+ const src = path18.join(dir, "git-launcher.cs");
3360
+ const exe = path18.join(dir, "git.exe");
3361
+ if (fs19.existsSync(exe) && fs19.existsSync(src) && fs19.readFileSync(src, "utf8") === source) return;
3362
+ const csc = findCsc();
3363
+ if (!csc) return;
3364
+ const tmp = path18.join(dir, `git.${process.pid}.tmp.exe`);
3365
+ const tmpSrc = `${src}.${process.pid}.tmp`;
3366
+ try {
3367
+ fs19.mkdirSync(dir, { recursive: true });
3368
+ fs19.writeFileSync(tmpSrc, source);
3369
+ const r = spawnSync9(csc, ["/nologo", "/optimize+", "/target:exe", `/out:${tmp}`, tmpSrc], { encoding: "utf8", timeout: 6e4, windowsHide: true });
3370
+ if (r.status !== 0 || !fs19.existsSync(tmp)) return;
3371
+ renameOver(tmp, exe);
3372
+ renameOver(tmpSrc, src);
3373
+ } catch {
3374
+ } finally {
3375
+ fs19.rmSync(tmp, { force: true });
3376
+ fs19.rmSync(tmpSrc, { force: true });
3377
+ }
3378
+ }
3296
3379
  var HOOK_VERSION = 1;
3297
3380
  var REF_HOOK = `#!/bin/sh
3298
3381
  # pitroom git guard (layer 2, v${HOOK_VERSION}): refuse ref updates made by workers.
@@ -3350,6 +3433,7 @@ function guardEnv(env) {
3350
3433
  sh = findGitSh(real);
3351
3434
  if (!sh) return { ...env, ...quiet };
3352
3435
  writeIfChanged2(path18.join(dir, "git.cmd"), cmdShim(sh));
3436
+ buildLauncher(dir, sh);
3353
3437
  }
3354
3438
  writeIfChanged2(path18.join(dir, "git"), SHIM);
3355
3439
  const key = pathKey(env);
@@ -3360,6 +3444,29 @@ function shimReady() {
3360
3444
  return Boolean(env[pathKey(env)]?.startsWith(shimDir()));
3361
3445
  }
3362
3446
 
3447
+ // src/vcs/git.ts
3448
+ function numberDiff(diff) {
3449
+ let next = 0;
3450
+ let width = 0;
3451
+ let inHunk = false;
3452
+ return diff.split("\n").map((l) => {
3453
+ if (l.startsWith("diff --git")) {
3454
+ inHunk = false;
3455
+ return l;
3456
+ }
3457
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(l);
3458
+ if (hunk) {
3459
+ inHunk = true;
3460
+ next = Number(hunk[1]);
3461
+ width = String(next + Number(hunk[2] ?? 1)).length;
3462
+ return l;
3463
+ }
3464
+ if (!inHunk || !l || !"+- ".includes(l[0])) return l;
3465
+ if (l[0] === "-") return `-${" ".repeat(width)}| ${l.slice(1)}`;
3466
+ return `${l[0]}${String(next++).padStart(width)}| ${l.slice(1)}`;
3467
+ }).join("\n");
3468
+ }
3469
+
3363
3470
  // src/core/slots.ts
3364
3471
  import crypto4 from "node:crypto";
3365
3472
  import fs20 from "node:fs";
@@ -3520,6 +3627,7 @@ export {
3520
3627
  loadTemplate,
3521
3628
  looksSecret,
3522
3629
  newRunId,
3630
+ numberDiff,
3523
3631
  parseAudit,
3524
3632
  parsePlan,
3525
3633
  parsePriceSpec,